First commit
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Read(//c/Users/ngoda/.claude/projects/d--Projects-LT-Janitorial-Quality-Control/**)",
|
||||
"Bash(cd /d \"D:\\\\Projects\\\\LT_Janitorial_Quality_Control\")",
|
||||
"Bash(python -c \"import docx; print\\('python-docx OK'\\)\")",
|
||||
"Bash(pip install *)",
|
||||
"Bash(python docs/convert_manual.py)"
|
||||
]
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# Environment & secrets — NEVER commit these
|
||||
.env
|
||||
*.env
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.eggs/
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
.venv/
|
||||
|
||||
# Flask / instance
|
||||
instance/
|
||||
app/static/uploads/
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
@@ -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.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
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:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
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>.
|
||||
@@ -0,0 +1,312 @@
|
||||
# JQC — Janitorial Quality Control System
|
||||
|
||||
A production-grade web application for managing janitorial service contracts, facility inspections, issue tracking, and client reporting.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Inspection Management** — Execute structured inspections against configurable templates with a drag-and-drop form builder supporting ratings, pass/fail, photos, signatures, and free-form fields. Starting an inspection requires only a Template and Facility selection.
|
||||
- **Issue Tracking** — Full lifecycle management (open → in-progress → pending verification → resolved) with SLA enforcement, follower subscriptions, resolution photo uploads, and inline quick-assign from the issues list
|
||||
- **Customer Portal** — Scoped facility visibility for client accounts with invitation-based onboarding; admin enters name and email only, customer chooses their own username and password via a 72-hour emailed link; expired invitation warnings surface on the admin dashboard
|
||||
- **Notification System** — In-app + email notifications driven by an admin-controlled routing matrix; per-user preferences including digest mode and a one-click "Pause All Emails" toggle
|
||||
- **Reports & Analytics** — Comprehensive reporting suite accessible from the second nav position:
|
||||
- *Overview & Trends* — facility scorecards, score trend charts, and PDF/CSV exports
|
||||
- *Issues Aging* — open issues grouped into age buckets (`<24h` → `>4 weeks`) with SLA status per issue; Excel export
|
||||
- *SLA Compliance* — resolved-issue SLA compliance % broken down by severity tier and facility, with progress bars; Excel export (2 sheets)
|
||||
- *Follow-up Closure Rate* — tracks which flagged inspections received a re-inspection; per-facility closure rates; Excel export
|
||||
- *Customer Facility PDF Summary* — downloadable PDF report for a facility covering KPIs, area scores, and open issues; accessible from the scorecard page
|
||||
- *Inspector Performance* — per-inspector KPI summary with Excel export (admin/director only)
|
||||
- *Scheduled Reports* — recurring email delivery of summary, facility, or issues reports (daily/weekly/monthly)
|
||||
- **Audit Trail** — Immutable log of every create, update, and delete action with actor and IP capture
|
||||
- **Support Chat** — Groq AI-powered chatbot for customers with preset FAQ quick-replies; automatic escalation to a ticketing system when the AI cannot resolve the issue; admins manage and reply to tickets at `/support/admin/tickets` with in-app and email notifications on every state change
|
||||
- **Inspector Performance Export** — Excel (`.xlsx`) export of the inspector performance summary with a color-coded KPI sheet and a detailed inspection log sheet
|
||||
- **Mobile API** — JWT-authenticated REST API (Phase 7) for the companion iPad native app; rate-limited login and refresh endpoints
|
||||
- **Contract Hierarchy** — Facilities grouped into Contracts (internally "Projects") with optional Contract Manager assignment and per-contract customer access control
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Backend | Python / Flask |
|
||||
| Database | MySQL |
|
||||
| ORM / Migrations | SQLAlchemy + Alembic |
|
||||
| Auth (web) | Flask-Login + Flask-WTF (CSRF) |
|
||||
| Auth (API) | JWT + opaque refresh tokens |
|
||||
| Rate limiting | Flask-Limiter (Redis-backed in production; in-process fallback for dev) |
|
||||
| Email | Flask-Mail (SMTP) |
|
||||
| PDF | ReportLab |
|
||||
| Frontend | Bootstrap 5, Chart.js, Jinja2 |
|
||||
| Excel export | openpyxl |
|
||||
| AI chatbot | Groq API (`llama-3.3-70b-versatile`) |
|
||||
| Server | Gunicorn + Nginx |
|
||||
| Mobile | SwiftUI + SwiftData (iOS 17+) |
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.11+
|
||||
- MySQL 5.7+ (or 8.0+)
|
||||
- A configured SMTP server (port 465 or 587)
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Clone and create a virtual environment
|
||||
|
||||
```bash
|
||||
git clone <repo-url> jqc
|
||||
cd jqc
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Linux / macOS
|
||||
# venv\Scripts\activate # Windows
|
||||
```
|
||||
|
||||
### 2. Install dependencies
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 3. Create the database
|
||||
|
||||
```sql
|
||||
CREATE DATABASE jqc CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE USER 'jqc'@'localhost' IDENTIFIED BY 'your_password';
|
||||
GRANT ALL PRIVILEGES ON jqc.* TO 'jqc'@'localhost';
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
### 4. Configure environment variables
|
||||
|
||||
Create a `.env` file in the project root (never commit this file):
|
||||
|
||||
```dotenv
|
||||
SECRET_KEY=<long-random-string>
|
||||
DATABASE_URL=mysql+pymysql://jqc:your_password@localhost/jqc
|
||||
|
||||
# Email
|
||||
MAIL_SERVER=smtp.example.com
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=noreply@example.com
|
||||
MAIL_PASSWORD=smtp_password
|
||||
MAIL_DEFAULT_SENDER=noreply@example.com
|
||||
|
||||
# Application
|
||||
APP_BASE_URL=https://your-domain.com
|
||||
DIGEST_SECRET=<random-secret-for-cron-auth>
|
||||
|
||||
# Rate limiting (optional — recommended for production multi-worker deployments)
|
||||
# REDIS_URL=redis://127.0.0.1:6379/0
|
||||
```
|
||||
|
||||
> **Email SSL:** Port 465 uses implicit SSL; port 587 uses STARTTLS. The application auto-detects based on `MAIL_PORT` — never set both flags to True.
|
||||
|
||||
### 5. Run database migrations
|
||||
|
||||
```bash
|
||||
flask db upgrade
|
||||
```
|
||||
|
||||
### 6. Start the development server
|
||||
|
||||
```bash
|
||||
python run.py
|
||||
```
|
||||
|
||||
The application will be available at `http://localhost:5000`.
|
||||
|
||||
---
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Gunicorn
|
||||
|
||||
```bash
|
||||
gunicorn -c gunicorn_config.py wsgi:app
|
||||
```
|
||||
|
||||
The included `gunicorn_config.py` binds to `127.0.0.1:8000` with sync workers. Log files are written to `/home/jqc/logs/`.
|
||||
|
||||
> **Rate limiting:** Flask-Limiter uses Redis when `REDIS_URL` is set in the environment, falling back to in-process memory for local development. In production with multiple Gunicorn workers, set `REDIS_URL=redis://127.0.0.1:6379/0` so rate-limit counters are shared across all workers. The `redis` package is included in `requirements.txt`.
|
||||
|
||||
### Nginx (recommended configuration)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name your-domain.com;
|
||||
|
||||
client_max_body_size 50M; # must match MAX_CONTENT_LENGTH in config.py
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /static/ {
|
||||
alias /path/to/jqc/app/static/;
|
||||
expires 30d;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Systemd service (example)
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=JQC Gunicorn
|
||||
After=network.target mysql.service
|
||||
|
||||
[Service]
|
||||
User=jqc
|
||||
WorkingDirectory=/home/jqc/lt_janitorial_quality_control
|
||||
EnvironmentFile=/home/jqc/.env
|
||||
ExecStart=/home/jqc/venv/bin/gunicorn -c gunicorn_config.py wsgi:app
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cron Jobs
|
||||
|
||||
Four background tasks require scheduled execution. All endpoints that require a token use `DIGEST_SECRET`.
|
||||
|
||||
```bash
|
||||
# SLA alerts — every 30 minutes
|
||||
*/30 * * * * curl -s -X POST "https://your-domain.com/notifications/check-sla" \
|
||||
-d "token=YOUR_DIGEST_SECRET"
|
||||
|
||||
# Daily digest emails — 7:00 AM
|
||||
0 7 * * * curl -s -X POST "https://your-domain.com/notifications/send-digest" \
|
||||
-d "token=YOUR_DIGEST_SECRET&frequency=daily"
|
||||
|
||||
# Expired/revoked API token cleanup — 3:00 AM
|
||||
0 3 * * * curl -s -X POST "https://your-domain.com/notifications/cleanup-tokens" \
|
||||
-d "token=YOUR_DIGEST_SECRET"
|
||||
|
||||
# Scheduled report delivery — 8:00 AM
|
||||
0 8 * * * curl -s -X POST "https://your-domain.com/scheduled-reports/run" \
|
||||
-d "secret=YOUR_DIGEST_SECRET"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## User Roles
|
||||
|
||||
| Role | Description |
|
||||
|---|---|
|
||||
| **admin** | Full access to all features including Audit Trail and Notification Matrix |
|
||||
| **director** | Broad access equivalent to admin, excluding Audit Trail and Notification Matrix |
|
||||
| **project_manager** | Manages contracts, facilities, and reports; cannot manage users or system settings |
|
||||
| **inspector** | Executes inspections and manages assigned issues |
|
||||
| **customer** | Portal scoped to assigned facilities; can create issues, comment on followed/reported issues, use the AI support chat, and submit/reply to support tickets |
|
||||
|
||||
### Creating the First Admin Account
|
||||
|
||||
```bash
|
||||
flask shell
|
||||
>>> from app import db
|
||||
>>> from app.models.user import User
|
||||
>>> u = User(username='admin', email='admin@example.com', role='admin')
|
||||
>>> u.set_password('your-secure-password')
|
||||
>>> db.session.add(u)
|
||||
>>> db.session.commit()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Customer Onboarding
|
||||
|
||||
1. Admin navigates to **Customers → New Customer**
|
||||
2. Enter the customer's **Full Name** and **Email** only — no username or password required from the admin
|
||||
3. The system auto-generates a temporary username and sends an invitation email with a secure **72-hour setup link**
|
||||
4. Customer clicks the link and arrives at the account setup page where they **choose their own username and set their password**
|
||||
5. The account activates immediately and the customer can log in
|
||||
6. Admin assigns the customer to Contracts/Facilities via **Customers → Manage**
|
||||
|
||||
> Expired invitations (token past 72 hours, password never set) are surfaced as a warning banner on the Customers page with inline **Resend** buttons. Resending generates a fresh 72-hour token.
|
||||
|
||||
---
|
||||
|
||||
## Mobile API
|
||||
|
||||
The REST API is available at `/api/v1/` and uses JWT Bearer token authentication.
|
||||
|
||||
### Authentication Endpoints
|
||||
|
||||
| Method | Endpoint | Rate Limit | Description |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/v1/auth/login` | 10/min | Login; returns access + refresh tokens |
|
||||
| POST | `/api/v1/auth/refresh` | 30/min | Rotate refresh token; returns new access token |
|
||||
| POST | `/api/v1/auth/logout` | — | Revoke refresh token |
|
||||
| GET | `/api/v1/auth/me` | — | Return current user profile |
|
||||
| POST | `/api/v1/devices/register` | — | Register APNs device token |
|
||||
|
||||
Access tokens expire after 60 minutes. Refresh tokens are valid for 30 days and rotate on every use. Expired and revoked tokens are cleaned up automatically on each login and via a nightly cron job.
|
||||
|
||||
---
|
||||
|
||||
## Database Migrations
|
||||
|
||||
```bash
|
||||
# Apply all pending migrations
|
||||
flask db upgrade
|
||||
|
||||
# Create a new migration after model changes
|
||||
flask db migrate -m "description of change"
|
||||
|
||||
# Roll back one migration
|
||||
flask db downgrade
|
||||
```
|
||||
|
||||
### MySQL Compatibility Notes
|
||||
|
||||
- **ENUM changes** require three steps: expand → migrate data → contract. Never skip steps.
|
||||
- **`CREATE INDEX IF NOT EXISTS`** is not supported on MySQL < 8.0.1. Use `information_schema.statistics` existence checks instead — see `phase12_performance_indexes.py` for the reusable `_index_exists()` helper pattern.
|
||||
|
||||
---
|
||||
|
||||
## Application Logs
|
||||
|
||||
| Log File | Contents |
|
||||
|---|---|
|
||||
| `logs/jqc.log` | Application log (rotating, 5 × 5 MB) |
|
||||
| `/home/jqc/logs/gunicorn-error.log` | Gunicorn worker errors |
|
||||
| `/home/jqc/logs/gunicorn-access.log` | HTTP access log |
|
||||
|
||||
---
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `SECRET_KEY` | — *required* | Flask session signing key |
|
||||
| `DATABASE_URL` | — *required* | SQLAlchemy connection URI |
|
||||
| `MAIL_SERVER` | — | SMTP hostname |
|
||||
| `MAIL_PORT` | `587` | SMTP port (465 = SSL, 587 = STARTTLS) |
|
||||
| `MAIL_USERNAME` | — | SMTP username |
|
||||
| `MAIL_PASSWORD` | — | SMTP password |
|
||||
| `MAIL_DEFAULT_SENDER` | `noreply@janitorialqc.local` | From address |
|
||||
| `APP_BASE_URL` | `""` | Base URL for links in emails |
|
||||
| `DIGEST_SECRET` | — | Authenticates all cron endpoints |
|
||||
| `MAX_CONTENT_LENGTH` | `50MB` | Maximum upload size per request |
|
||||
| `REDIS_URL` | — | Redis connection URI for shared rate-limit storage; optional but recommended in production |
|
||||
| `GROQ_API_KEY` | — | Groq API key. When absent the AI chatbot is disabled; customers can still submit support tickets. |
|
||||
| `GROQ_MODEL` | `llama-3.3-70b-versatile` | Groq model ID override |
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
See `LICENSE` for terms.
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
from flask import Flask
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import LoginManager
|
||||
from flask_migrate import Migrate
|
||||
from flask_mail import Mail
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
from config import config
|
||||
import os
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
db = SQLAlchemy()
|
||||
login_manager = LoginManager()
|
||||
migrate = Migrate()
|
||||
mail = Mail()
|
||||
csrf = CSRFProtect() # initialized here; .init_app() called in create_app()
|
||||
limiter = Limiter(
|
||||
key_func = get_remote_address,
|
||||
default_limits = [], # no global limit — applied per-route only
|
||||
# Use Redis when REDIS_URL is set in the environment (production multi-worker).
|
||||
# Falls back to in-process memory for local development (single-worker only;
|
||||
# counters are NOT shared across Gunicorn workers in memory:// mode).
|
||||
storage_uri = os.environ.get('REDIS_URL', 'memory://'),
|
||||
)
|
||||
|
||||
|
||||
def create_app(config_name='default'):
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(config[config_name])
|
||||
|
||||
db.init_app(app)
|
||||
login_manager.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
mail.init_app(app)
|
||||
csrf.init_app(app) # enables CSRF protection for all web routes
|
||||
limiter.init_app(app) # rate limiting — applied per-route via @limiter.limit()
|
||||
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message = 'Please log in to access this page.'
|
||||
login_manager.login_message_category = 'info'
|
||||
|
||||
# ── Logging setup ────────────────────────────────────────────────────────
|
||||
# Configure root logger so that logger.info/error calls in all modules
|
||||
# (notifications.py, issues.py, etc.) actually write output.
|
||||
# Writes to stdout (captured by journalctl/gunicorn) AND a rotating file.
|
||||
if not app.debug or os.environ.get('LOG_TO_FILE'):
|
||||
log_level = logging.INFO
|
||||
|
||||
formatter = logging.Formatter(
|
||||
'[%(asctime)s] %(levelname)s in %(module)s: %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S',
|
||||
)
|
||||
|
||||
# Stream handler — always on; journalctl captures stdout
|
||||
stream_handler = logging.StreamHandler()
|
||||
stream_handler.setLevel(log_level)
|
||||
stream_handler.setFormatter(formatter)
|
||||
|
||||
# Rotating file handler — keeps 5 × 5 MB log files
|
||||
log_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'logs')
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
file_handler = RotatingFileHandler(
|
||||
os.path.join(log_dir, 'jqc.log'),
|
||||
maxBytes=5 * 1024 * 1024,
|
||||
backupCount=5,
|
||||
)
|
||||
file_handler.setLevel(log_level)
|
||||
file_handler.setFormatter(formatter)
|
||||
|
||||
# Apply to both the Flask app logger and the root logger so all
|
||||
# getLogger(__name__) calls in sub-modules are captured.
|
||||
app.logger.setLevel(log_level)
|
||||
app.logger.addHandler(stream_handler)
|
||||
app.logger.addHandler(file_handler)
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(log_level)
|
||||
if not root_logger.handlers:
|
||||
root_logger.addHandler(stream_handler)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
# Register csrf_token() as an app-wide Jinja2 global so templates that
|
||||
# render manual forms (no WTForms object) can still inject the CSRF token.
|
||||
# CSRFProtect (initialized above) is the authoritative guard for web routes;
|
||||
# the /api/v1 blueprint is exempted below via csrf.exempt().
|
||||
from flask_wtf.csrf import generate_csrf
|
||||
app.jinja_env.globals['csrf_token'] = generate_csrf
|
||||
app.jinja_env.globals['enumerate'] = enumerate
|
||||
|
||||
# SLA helpers available in all templates
|
||||
from app.utils.sla import sla_status, sla_deadline, sla_hours_remaining, SLA_HOURS
|
||||
app.jinja_env.globals['sla_status'] = sla_status
|
||||
app.jinja_env.globals['sla_deadline'] = sla_deadline
|
||||
app.jinja_env.globals['sla_hours_remaining'] = sla_hours_remaining
|
||||
app.jinja_env.globals['SLA_HOURS'] = SLA_HOURS
|
||||
|
||||
# ── Inject unread notification count into every template context ──────
|
||||
# This powers the red badge on the navbar bell icon without requiring
|
||||
# individual routes to pass the count manually.
|
||||
from flask_login import current_user
|
||||
|
||||
@app.context_processor
|
||||
def inject_notification_count():
|
||||
if not current_user.is_authenticated:
|
||||
return {
|
||||
'unread_notification_count': 0,
|
||||
'pending_verification_count': 0,
|
||||
'open_support_tickets_count': 0,
|
||||
}
|
||||
|
||||
# ── Unread notification count (all roles) ──────────────────────────
|
||||
# Computed first, in its own try/except, so a failure in the
|
||||
# director-specific queries below never zeroes out the bell badge.
|
||||
try:
|
||||
from app.models.notification import Notification
|
||||
unread = Notification.query.filter_by(
|
||||
user_id=current_user.id, is_read=False
|
||||
).count()
|
||||
except Exception as exc:
|
||||
import logging as _logging
|
||||
_logging.getLogger(__name__).warning(
|
||||
'inject_notification_count: unread query failed: %s', exc
|
||||
)
|
||||
unread = 0
|
||||
|
||||
# ── Director/admin-only counts ─────────────────────────────────────
|
||||
pv_count = 0
|
||||
open_support = 0
|
||||
if current_user.role in ('admin', 'director'):
|
||||
try:
|
||||
from app.models.issue import Issue
|
||||
pv_count = Issue.query.filter_by(
|
||||
status='pending_verification'
|
||||
).count()
|
||||
except Exception as exc:
|
||||
import logging as _logging
|
||||
_logging.getLogger(__name__).warning(
|
||||
'inject_notification_count: pv_count query failed: %s', exc
|
||||
)
|
||||
try:
|
||||
from app.models.support import SupportTicket
|
||||
open_support = SupportTicket.query.filter_by(status='open').count()
|
||||
except Exception as exc:
|
||||
import logging as _logging
|
||||
_logging.getLogger(__name__).warning(
|
||||
'inject_notification_count: support_tickets query failed: %s', exc
|
||||
)
|
||||
|
||||
return {
|
||||
'unread_notification_count': unread,
|
||||
'pending_verification_count': pv_count,
|
||||
'open_support_tickets_count': open_support,
|
||||
}
|
||||
|
||||
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
||||
|
||||
from app.routes import auth, dashboard, inspections, templates, reports, facilities
|
||||
from app.routes import issues # Phase 3
|
||||
from app.routes import notifications # Notification system
|
||||
from app.routes import audit # Audit Trail
|
||||
from app.routes import projects # Phase 1/2 — Project management
|
||||
from app.routes import customers # Phase 5 — Customer management
|
||||
from app.routes import scheduled_reports # Phase 6 — Scheduled reports
|
||||
from app.routes import support # Support chat + admin tickets
|
||||
from app.routes import broadcast # Admin broadcast notifications
|
||||
|
||||
app.register_blueprint(auth.bp)
|
||||
app.register_blueprint(dashboard.bp)
|
||||
app.register_blueprint(inspections.bp)
|
||||
app.register_blueprint(templates.bp)
|
||||
app.register_blueprint(reports.bp)
|
||||
app.register_blueprint(facilities.bp)
|
||||
app.register_blueprint(issues.bp)
|
||||
app.register_blueprint(notifications.bp)
|
||||
app.register_blueprint(audit.bp)
|
||||
app.register_blueprint(projects.bp)
|
||||
app.register_blueprint(customers.bp)
|
||||
app.register_blueprint(scheduled_reports.bp)
|
||||
app.register_blueprint(support.bp)
|
||||
app.register_blueprint(broadcast.bp)
|
||||
|
||||
# ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ───────────────────
|
||||
# The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed.
|
||||
#
|
||||
# Flask-WTF's _is_exempt() checks whether the *leaf* blueprint object
|
||||
# is in _exempt_blueprints. Exempting the parent api_bp does NOT cascade
|
||||
# to sub-blueprints. Each child blueprint must be exempted individually.
|
||||
from app.api import register_api, api_bp
|
||||
from app.api.auth import bp as _api_auth_bp
|
||||
from app.api.facilities import bp as _api_facilities_bp
|
||||
from app.api.templates import bp as _api_templates_bp
|
||||
from app.api.inspections import bp as _api_inspections_bp
|
||||
from app.api.issues import bp as _api_issues_bp
|
||||
from app.api.photos import bp as _api_photos_bp
|
||||
from app.api.notifications import bp as _api_notifications_bp
|
||||
from app.api.stats import bp as _api_stats_bp
|
||||
from app.api.comments import bp as _api_comments_bp
|
||||
csrf.exempt(_api_auth_bp)
|
||||
csrf.exempt(_api_facilities_bp)
|
||||
csrf.exempt(_api_templates_bp)
|
||||
csrf.exempt(_api_inspections_bp)
|
||||
csrf.exempt(_api_issues_bp)
|
||||
csrf.exempt(_api_photos_bp)
|
||||
csrf.exempt(_api_notifications_bp)
|
||||
csrf.exempt(_api_stats_bp)
|
||||
csrf.exempt(_api_comments_bp)
|
||||
register_api(app)
|
||||
|
||||
# ── Security response headers ─────────────────────────────────────────
|
||||
# Applied to every response. Blocks clickjacking, MIME sniffing, and
|
||||
# obvious XSS vectors without breaking Bootstrap CDN / Google Fonts.
|
||||
@app.after_request
|
||||
def set_security_headers(response):
|
||||
response.headers.setdefault('X-Content-Type-Options', 'nosniff')
|
||||
response.headers.setdefault('X-Frame-Options', 'SAMEORIGIN')
|
||||
response.headers.setdefault('Referrer-Policy', 'strict-origin-when-cross-origin')
|
||||
response.headers.setdefault(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
||||
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; "
|
||||
"font-src 'self' data: https://fonts.gstatic.com https://cdn.jsdelivr.net; "
|
||||
"img-src 'self' data: blob: https://maps.gstatic.com https://maps.googleapis.com; "
|
||||
"connect-src 'self' https://cdn.jsdelivr.net; "
|
||||
"frame-src https://maps.google.com https://www.google.com; "
|
||||
"frame-ancestors 'none';"
|
||||
)
|
||||
return response
|
||||
|
||||
# ── Error handler: 413 Request Entity Too Large ───────────────────────
|
||||
# Nginx can return 413 before Flask sees the request; this handler covers
|
||||
# the Flask-side rejection and gives users a clear, actionable message
|
||||
# with a redirect back into the inspection workflow.
|
||||
from werkzeug.exceptions import RequestEntityTooLarge
|
||||
|
||||
@app.errorhandler(RequestEntityTooLarge)
|
||||
@app.errorhandler(413)
|
||||
def handle_413(e):
|
||||
from flask import request as flask_request, flash as flask_flash, redirect, url_for
|
||||
flask_flash(
|
||||
f'The uploaded file(s) are too large. '
|
||||
f'Please reduce the photo size or upload fewer photos at once '
|
||||
f'(maximum {app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024)}MB per submission).',
|
||||
'danger'
|
||||
)
|
||||
# Redirect back to the referring page if available, otherwise dashboard
|
||||
referrer = flask_request.referrer
|
||||
return redirect(referrer or url_for('dashboard.index')), 302
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Add form_schema column to inspection_templates
|
||||
|
||||
Run this once on your server:
|
||||
python add_form_schema.py
|
||||
|
||||
Or via Flask-Migrate:
|
||||
flask db migrate -m "add form_schema to inspection_templates"
|
||||
flask db upgrade
|
||||
"""
|
||||
|
||||
# If you prefer to run this as a standalone script:
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app import create_app, db
|
||||
from sqlalchemy import text
|
||||
|
||||
app = create_app()
|
||||
|
||||
with app.app_context():
|
||||
with db.engine.connect() as conn:
|
||||
# Check if column already exists
|
||||
result = conn.execute(text("""
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'inspection_templates'
|
||||
AND column_name = 'form_schema'
|
||||
"""))
|
||||
exists = result.scalar()
|
||||
|
||||
if not exists:
|
||||
conn.execute(text("""
|
||||
ALTER TABLE inspection_templates
|
||||
ADD COLUMN form_schema JSON NULL COMMENT 'JSON schema for the dynamic form builder'
|
||||
"""))
|
||||
conn.commit()
|
||||
print("✓ Column 'form_schema' added to inspection_templates.")
|
||||
else:
|
||||
print("✓ Column 'form_schema' already exists — no changes made.")
|
||||
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
app/api/__init__.py
|
||||
-------------------
|
||||
Registers the /api/v1 blueprint group.
|
||||
|
||||
Phase A: /api/v1/facilities/*, /api/v1/templates/*
|
||||
Phase B: /api/v1/inspections/*, /api/v1/issues/*, /api/v1/photos/*
|
||||
Phase C: /api/v1/notifications/*
|
||||
"""
|
||||
|
||||
from flask import Blueprint
|
||||
from app.api.errors import register_error_handlers
|
||||
|
||||
api_bp = Blueprint('api', __name__, url_prefix='/api/v1')
|
||||
register_error_handlers(api_bp)
|
||||
|
||||
|
||||
def register_api(app):
|
||||
"""
|
||||
Register all API sub-blueprints.
|
||||
CSRF exemption for each child blueprint is handled in app/__init__.py.
|
||||
"""
|
||||
# Phase 7: Auth
|
||||
from app.api.auth import bp as auth_bp
|
||||
api_bp.register_blueprint(auth_bp)
|
||||
|
||||
# Phase A: Reference data
|
||||
from app.api.facilities import bp as facilities_bp
|
||||
from app.api.templates import bp as templates_bp
|
||||
api_bp.register_blueprint(facilities_bp)
|
||||
api_bp.register_blueprint(templates_bp)
|
||||
|
||||
# Phase B: Offline inspection submission
|
||||
from app.api.inspections import bp as inspections_bp
|
||||
from app.api.issues import bp as issues_bp
|
||||
from app.api.photos import bp as photos_bp
|
||||
api_bp.register_blueprint(inspections_bp)
|
||||
api_bp.register_blueprint(issues_bp)
|
||||
api_bp.register_blueprint(photos_bp)
|
||||
|
||||
# Phase C: Notification polling
|
||||
from app.api.notifications import bp as notifications_bp
|
||||
api_bp.register_blueprint(notifications_bp)
|
||||
|
||||
# Phase B (stats): Dashboard KPI endpoint
|
||||
from app.api.stats import bp as stats_bp
|
||||
api_bp.register_blueprint(stats_bp)
|
||||
|
||||
# Phase D: Issue comments
|
||||
from app.api.comments import bp as comments_bp
|
||||
api_bp.register_blueprint(comments_bp)
|
||||
|
||||
app.register_blueprint(api_bp)
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
"""
|
||||
app/api/auth.py
|
||||
---------------
|
||||
Authentication endpoints for the JQC mobile app.
|
||||
|
||||
POST /api/v1/auth/login
|
||||
Accepts username + password.
|
||||
Returns a short-lived access token (JWT) and a long-lived refresh token
|
||||
(opaque, stored in DB). The app stores both in the iOS Keychain.
|
||||
|
||||
POST /api/v1/auth/refresh
|
||||
Accepts a refresh token.
|
||||
Returns a new access token. The refresh token is rotated — the old one
|
||||
is revoked and a new one is issued, preventing replay attacks.
|
||||
|
||||
POST /api/v1/auth/logout
|
||||
Accepts a refresh token.
|
||||
Revokes it so it can no longer be used to issue new access tokens.
|
||||
The app should discard both tokens from the Keychain after this call.
|
||||
|
||||
POST /api/v1/devices/register
|
||||
Registers or updates the APNs device token for push notifications.
|
||||
Called on every app launch after the user has already authenticated.
|
||||
Requires a valid access token (JWT).
|
||||
|
||||
GET /api/v1/auth/me
|
||||
Returns the current user's profile from the access token.
|
||||
Useful for the app to verify the token is still valid on launch.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, request, g
|
||||
from app import db, limiter
|
||||
from app.models.user import User
|
||||
from app.models.api_token import RefreshToken, DeviceToken
|
||||
from app.api.errors import api_ok, api_error
|
||||
from app.api.jwt_utils import generate_access_token
|
||||
from app.api.decorators import jwt_required
|
||||
from app.utils.audit import log_action, ACTION_LOGIN, ACTION_LOGOUT
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('api_auth', __name__)
|
||||
|
||||
|
||||
def _user_payload(user: User) -> dict:
|
||||
"""Serialize a User to the dict returned in auth responses."""
|
||||
return {
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
'full_name': user.full_name or '',
|
||||
'email': user.email,
|
||||
'role': user.role,
|
||||
'created_at': user.created_at.isoformat() if user.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
# ── Login ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/auth/login', methods=['POST'])
|
||||
@limiter.limit('10 per minute; 3 per second')
|
||||
def login():
|
||||
"""
|
||||
Authenticate with username + password.
|
||||
|
||||
Request JSON
|
||||
------------
|
||||
{
|
||||
"username": "john",
|
||||
"password": "secret",
|
||||
"device_id": "A1B2C3D4...", // UIDevice.identifierForVendor (optional)
|
||||
"device_name": "John's iPhone" // (optional)
|
||||
}
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"access_token": "<jwt>",
|
||||
"refresh_token": "<opaque_hex>",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"user": { id, username, email, role, created_at }
|
||||
}
|
||||
}
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
username = (data.get('username') or '').strip()
|
||||
password = data.get('password') or ''
|
||||
|
||||
if not username or not password:
|
||||
return api_error('username and password are required', 400)
|
||||
|
||||
user = User.query.filter_by(username=username).first()
|
||||
|
||||
# Generic message — never reveal whether the username exists
|
||||
if user is None or not user.check_password(password):
|
||||
logger.warning('API login failed | username=%s | ip=%s',
|
||||
username, request.remote_addr)
|
||||
return api_error('Invalid credentials', 401)
|
||||
|
||||
if not user.active:
|
||||
return api_error('Account is disabled. Please contact an administrator.', 401)
|
||||
|
||||
device_id = (data.get('device_id') or '')[:64] or None
|
||||
device_name = (data.get('device_name') or '')[:100] or None
|
||||
|
||||
# Issue tokens
|
||||
access_token = generate_access_token(user)
|
||||
raw_refresh, rt_row = RefreshToken.create_for(
|
||||
user,
|
||||
device_id=device_id,
|
||||
device_name=device_name,
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
# Passive cleanup — delete expired/revoked tokens for this user only
|
||||
# so the table never accumulates dead rows without a cron dependency.
|
||||
try:
|
||||
from app.utils.time_utils import now_eastern
|
||||
now = now_eastern()
|
||||
RefreshToken.query.filter(
|
||||
RefreshToken.user_id == user.id,
|
||||
db.or_(
|
||||
RefreshToken.expires_at < now,
|
||||
RefreshToken.revoked == True, # noqa: E712
|
||||
),
|
||||
).delete(synchronize_session=False)
|
||||
db.session.commit()
|
||||
except Exception as _cleanup_exc:
|
||||
logger.warning('API LOGIN passive token cleanup failed: %s', _cleanup_exc)
|
||||
db.session.rollback()
|
||||
|
||||
log_action(ACTION_LOGIN, 'User', user.id, user.username,
|
||||
f'source=mobile_api; device_id={device_id}')
|
||||
|
||||
logger.info('API LOGIN | user=%s | role=%s | device_id=%s',
|
||||
user.username, user.role, device_id)
|
||||
|
||||
return api_ok({
|
||||
'access_token': access_token,
|
||||
'refresh_token': raw_refresh,
|
||||
'token_type': 'Bearer',
|
||||
'expires_in': 3600, # seconds — matches ACCESS_TOKEN_LIFETIME_MINUTES * 60
|
||||
'user': _user_payload(user),
|
||||
})
|
||||
|
||||
|
||||
# ── Refresh ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/auth/refresh', methods=['POST'])
|
||||
@limiter.limit('30 per minute; 5 per second')
|
||||
def refresh():
|
||||
"""
|
||||
Exchange a valid refresh token for a new access token.
|
||||
|
||||
The refresh token is rotated on every call — the submitted token is
|
||||
revoked and a brand new one is issued. This limits the damage window
|
||||
if a token is ever stolen.
|
||||
|
||||
Request JSON
|
||||
------------
|
||||
{ "refresh_token": "<opaque_hex>" }
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"access_token": "<new_jwt>",
|
||||
"refresh_token": "<new_opaque_hex>",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600
|
||||
}
|
||||
}
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
raw_token = (data.get('refresh_token') or '').strip()
|
||||
|
||||
if not raw_token:
|
||||
return api_error('refresh_token is required', 400)
|
||||
|
||||
rt_row = RefreshToken.verify(raw_token)
|
||||
if rt_row is None:
|
||||
logger.warning('API refresh rejected | invalid/expired token | ip=%s',
|
||||
request.remote_addr)
|
||||
return api_error('Refresh token is invalid or expired', 401)
|
||||
|
||||
user = db.session.get(User, rt_row.user_id)
|
||||
if user is None or not user.active:
|
||||
rt_row.revoke()
|
||||
db.session.commit()
|
||||
return api_error('Account not available', 401)
|
||||
|
||||
# Rotate: revoke old token, issue new pair
|
||||
device_id = rt_row.device_id
|
||||
device_name = rt_row.device_name
|
||||
rt_row.revoke()
|
||||
|
||||
new_access = generate_access_token(user)
|
||||
new_raw_refresh, new_rt = RefreshToken.create_for(
|
||||
user,
|
||||
device_id=device_id,
|
||||
device_name=device_name,
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
logger.info('API TOKEN REFRESH | user=%s | device_id=%s',
|
||||
user.username, device_id)
|
||||
|
||||
return api_ok({
|
||||
'access_token': new_access,
|
||||
'refresh_token': new_raw_refresh,
|
||||
'token_type': 'Bearer',
|
||||
'expires_in': 3600,
|
||||
})
|
||||
|
||||
|
||||
# ── Logout ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/auth/logout', methods=['POST'])
|
||||
@jwt_required
|
||||
def logout():
|
||||
"""
|
||||
Revoke the current session's refresh token.
|
||||
|
||||
The app should call this when the user taps "Log out" and then discard
|
||||
both the access token and refresh token from the Keychain.
|
||||
|
||||
Request JSON
|
||||
------------
|
||||
{ "refresh_token": "<opaque_hex>" }
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{ "ok": true, "data": { "message": "Logged out" } }
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
raw_token = (data.get('refresh_token') or '').strip()
|
||||
|
||||
if raw_token:
|
||||
rt_row = RefreshToken.verify(raw_token)
|
||||
if rt_row and rt_row.user_id == g.api_user.id:
|
||||
rt_row.revoke()
|
||||
db.session.commit()
|
||||
|
||||
log_action(ACTION_LOGOUT, 'User', g.api_user.id, g.api_user.username,
|
||||
'source=mobile_api')
|
||||
logger.info('API LOGOUT | user=%s', g.api_user.username)
|
||||
|
||||
return api_ok({'message': 'Logged out successfully'})
|
||||
|
||||
|
||||
# ── Current user ──────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/auth/me', methods=['GET'])
|
||||
@jwt_required
|
||||
def me():
|
||||
"""
|
||||
Return the authenticated user's profile.
|
||||
|
||||
Called by the app on launch to verify the stored access token is still
|
||||
valid and to refresh the local user record.
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{ "ok": true, "data": { "user": { id, username, email, role, ... } } }
|
||||
"""
|
||||
return api_ok({'user': _user_payload(g.api_user)})
|
||||
|
||||
|
||||
# ── Device token registration ─────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/devices/register', methods=['POST'])
|
||||
@jwt_required
|
||||
def register_device():
|
||||
"""
|
||||
Register or update the APNs device token for the authenticated user.
|
||||
|
||||
Called on every app launch after authentication so the server always
|
||||
has the current token (APNs rotates tokens periodically).
|
||||
|
||||
Request JSON
|
||||
------------
|
||||
{
|
||||
"device_id": "<UIDevice.identifierForVendor>",
|
||||
"apns_token": "<hex_string_from_didRegisterForRemoteNotifications>",
|
||||
"device_name": "John's iPhone", // optional
|
||||
"app_version": "1.0.3" // optional
|
||||
}
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{ "ok": true, "data": { "registered": true } }
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
device_id = (data.get('device_id') or '').strip()[:64]
|
||||
apns_token = (data.get('apns_token') or '').strip()[:200]
|
||||
device_name = (data.get('device_name') or '').strip()[:100] or None
|
||||
app_version = (data.get('app_version') or '').strip()[:20] or None
|
||||
|
||||
if not device_id or not apns_token:
|
||||
return api_error('device_id and apns_token are required', 400)
|
||||
|
||||
# Upsert: update existing row or insert new one
|
||||
existing = DeviceToken.query.filter_by(
|
||||
user_id=g.api_user.id,
|
||||
device_id=device_id,
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
existing.apns_token = apns_token
|
||||
existing.device_name = device_name
|
||||
existing.app_version = app_version
|
||||
existing.registered_at = now_eastern()
|
||||
else:
|
||||
db.session.add(DeviceToken(
|
||||
user_id = g.api_user.id,
|
||||
device_id = device_id,
|
||||
apns_token = apns_token,
|
||||
device_name = device_name,
|
||||
app_version = app_version,
|
||||
))
|
||||
|
||||
db.session.commit()
|
||||
logger.info('API DEVICE REGISTERED | user=%s | device_id=%s | apns_token=...%s',
|
||||
g.api_user.username, device_id, apns_token[-6:])
|
||||
|
||||
return api_ok({'registered': True})
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
app/api/comments.py
|
||||
-------------------
|
||||
Mobile API endpoints for issue comments.
|
||||
|
||||
GET /api/v1/issues/<id>/comments
|
||||
Returns all comments for an issue, ordered oldest-first.
|
||||
Inspectors may only access issues within their contracted facilities.
|
||||
|
||||
POST /api/v1/issues/<id>/comments
|
||||
Adds a comment to an issue.
|
||||
Inspectors may only comment on issues within their contracted facilities.
|
||||
Fires notify_by_matrix('issue_comment') so the relevant staff are notified.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, request, g
|
||||
from app import db
|
||||
from app.models.issue import Issue, IssueComment
|
||||
from app.api.errors import api_ok, api_error
|
||||
from app.api.decorators import jwt_required
|
||||
from app.utils.audit import log_action, ACTION_CREATE
|
||||
from app.utils.notifications import notify_by_matrix
|
||||
from app.utils.scope import get_inspector_scope
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('api_comments', __name__)
|
||||
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
||||
|
||||
|
||||
def _comment_payload(comment: IssueComment) -> dict:
|
||||
"""Serialise an IssueComment to the dict returned in API responses."""
|
||||
return {
|
||||
'id': comment.id,
|
||||
'issue_id': comment.issue_id,
|
||||
'author_name': comment.author.display_name if comment.author else 'Unknown',
|
||||
'author_role': comment.author.role if comment.author else '',
|
||||
'status_at_time': comment.status_at_time or '',
|
||||
'body': comment.body,
|
||||
'created_at': comment.created_at.isoformat() if comment.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _check_issue_access(issue: Issue, user) -> bool:
|
||||
"""Return True if user may read/write this issue. False = 403."""
|
||||
if user.role == 'inspector':
|
||||
fids = get_inspector_scope(user)
|
||||
facility = issue.resolved_facility
|
||||
if not fids or not facility or facility.id not in fids:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# ── GET comments ─────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/issues/<int:issue_id>/comments', methods=['GET'])
|
||||
@jwt_required
|
||||
def list_comments(issue_id):
|
||||
"""
|
||||
Return all comments for the given issue, oldest-first.
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"issue_id": 42,
|
||||
"comments": [
|
||||
{
|
||||
"id": 1,
|
||||
"issue_id": 42,
|
||||
"author_name": "Jane Smith",
|
||||
"author_role": "director",
|
||||
"status_at_time": "in_progress",
|
||||
"body": "Cleaning crew has been notified.",
|
||||
"created_at": "2026-05-10T09:15:00"
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
}
|
||||
"""
|
||||
user = g.api_user
|
||||
if user.role not in _ALLOWED_ROLES:
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
issue = db.session.get(Issue, issue_id)
|
||||
if issue is None:
|
||||
return api_error('Issue not found', 404)
|
||||
|
||||
if not _check_issue_access(issue, user):
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
comments = (
|
||||
issue.comments
|
||||
.order_by(IssueComment.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
payload = [_comment_payload(c) for c in comments]
|
||||
|
||||
logger.info('API COMMENTS | list | issue_id=%d | count=%d | user=%s',
|
||||
issue_id, len(payload), user.username)
|
||||
|
||||
return api_ok({'issue_id': issue_id, 'comments': payload, 'count': len(payload)})
|
||||
|
||||
|
||||
# ── POST comment ──────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/issues/<int:issue_id>/comments', methods=['POST'])
|
||||
@jwt_required
|
||||
def add_comment(issue_id):
|
||||
"""
|
||||
Add a comment to an issue.
|
||||
|
||||
Request JSON
|
||||
------------
|
||||
{ "body": "The spill has been cleaned up." }
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{ "ok": true, "data": { "comment_id": 7 } }
|
||||
"""
|
||||
user = g.api_user
|
||||
if user.role not in _ALLOWED_ROLES:
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
issue = db.session.get(Issue, issue_id)
|
||||
if issue is None:
|
||||
return api_error('Issue not found', 404)
|
||||
|
||||
if not _check_issue_access(issue, user):
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
body = (data.get('body') or '').strip()
|
||||
if not body:
|
||||
return api_error('body is required', 400)
|
||||
|
||||
comment = IssueComment(
|
||||
issue_id = issue_id,
|
||||
user_id = user.id,
|
||||
status_at_time = issue.status,
|
||||
body = body,
|
||||
)
|
||||
db.session.add(comment)
|
||||
db.session.flush() # get comment.id
|
||||
|
||||
# Notify via matrix — same event type as web-originated comments
|
||||
facility = issue.resolved_facility
|
||||
area_name = issue.area.name if issue.area else (facility.name if facility else '—')
|
||||
facility_id = facility.id if facility else None
|
||||
|
||||
try:
|
||||
from flask import url_for
|
||||
issue_link = url_for('issues.view', issue_id=issue.id, _external=False)
|
||||
except RuntimeError:
|
||||
issue_link = f'/issues/{issue.id}'
|
||||
|
||||
if facility_id:
|
||||
notify_by_matrix(
|
||||
event_type = 'issue_comment',
|
||||
title = f'New Comment on Issue #{issue.id}',
|
||||
body = (
|
||||
f'{user.display_name} commented on Issue #{issue.id} '
|
||||
f'at {area_name}: '
|
||||
f'"{body[:120]}{"…" if len(body) > 120 else ""}"'
|
||||
),
|
||||
link = issue_link,
|
||||
issue_id = issue.id,
|
||||
facility_id = facility_id,
|
||||
exclude_user_ids = {user.id},
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
log_action(ACTION_CREATE, 'IssueComment', comment.id,
|
||||
f'comment on Issue #{issue_id}',
|
||||
f'source=mobile; author={user.username}; issue_status={issue.status}')
|
||||
|
||||
logger.info('API COMMENTS | created | comment_id=%d | issue_id=%d | user=%s',
|
||||
comment.id, issue_id, user.username)
|
||||
|
||||
return api_ok({'comment_id': comment.id})
|
||||
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
app/api/decorators.py
|
||||
---------------------
|
||||
Request-level guards for all /api/v1/ endpoints.
|
||||
|
||||
@jwt_required
|
||||
Validates the Bearer token in the Authorization header.
|
||||
On success, sets flask.g.api_user to the authenticated User instance
|
||||
so any route can access it without a second DB query.
|
||||
|
||||
@api_role_required(*roles)
|
||||
Must be applied AFTER @jwt_required.
|
||||
Rejects callers whose role is not in the allowed set.
|
||||
|
||||
Usage
|
||||
-----
|
||||
@bp.route('/inspections')
|
||||
@jwt_required
|
||||
def list_inspections():
|
||||
user = g.api_user
|
||||
...
|
||||
|
||||
@bp.route('/admin/users')
|
||||
@jwt_required
|
||||
@api_role_required('admin')
|
||||
def admin_only():
|
||||
...
|
||||
"""
|
||||
|
||||
import logging
|
||||
from functools import wraps
|
||||
|
||||
from flask import request, g, abort
|
||||
|
||||
from app.api.jwt_utils import decode_access_token
|
||||
from app.api.errors import api_error
|
||||
from app import db
|
||||
from app.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def jwt_required(f):
|
||||
"""
|
||||
Validate the JWT Bearer token and load the user into flask.g.api_user.
|
||||
|
||||
Returns 401 if:
|
||||
- Authorization header is missing or malformed
|
||||
- Token is expired or invalid
|
||||
- User referenced by the token no longer exists
|
||||
- User account has been disabled (active=False)
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
if not auth_header.startswith('Bearer '):
|
||||
return api_error('Missing or malformed Authorization header', 401)
|
||||
|
||||
raw_token = auth_header[len('Bearer '):]
|
||||
payload = decode_access_token(raw_token)
|
||||
|
||||
if payload is None:
|
||||
return api_error('Access token is invalid or expired', 401)
|
||||
|
||||
user_id = int(payload.get('sub', 0))
|
||||
user = db.session.get(User, user_id)
|
||||
|
||||
if user is None:
|
||||
return api_error('User not found', 401)
|
||||
|
||||
if not user.active:
|
||||
return api_error('Account is disabled', 401)
|
||||
|
||||
# Make the user available to the route without re-querying
|
||||
g.api_user = user
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
|
||||
def api_role_required(*roles):
|
||||
"""
|
||||
Restrict an endpoint to users whose role is in the provided list.
|
||||
|
||||
Must be stacked BELOW @jwt_required so that g.api_user is already set.
|
||||
|
||||
Example
|
||||
-------
|
||||
@jwt_required
|
||||
@api_role_required('admin', 'supervisor')
|
||||
def supervisor_only_route():
|
||||
...
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
user = getattr(g, 'api_user', None)
|
||||
if user is None:
|
||||
# Defensive: jwt_required should always run first
|
||||
return api_error('Authentication required', 401)
|
||||
if user.role not in roles:
|
||||
logger.warning(
|
||||
'API role denied | user=%s role=%s required=%s endpoint=%s',
|
||||
user.username, user.role, roles, request.endpoint,
|
||||
)
|
||||
return api_error('Insufficient permissions', 403)
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
return decorator
|
||||
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
app/api/errors.py
|
||||
-----------------
|
||||
Consistent JSON error responses for every API endpoint.
|
||||
|
||||
Every response — success or failure — uses the same envelope:
|
||||
|
||||
{
|
||||
"ok": true | false,
|
||||
"data": { ... } | null,
|
||||
"error": null | "Human-readable message"
|
||||
}
|
||||
|
||||
Usage
|
||||
-----
|
||||
from app.api.errors import api_error, api_ok
|
||||
|
||||
return api_ok({'inspection': {...}})
|
||||
return api_error('Inspection not found', 404)
|
||||
"""
|
||||
|
||||
from flask import jsonify
|
||||
|
||||
|
||||
def api_ok(data=None, status=200):
|
||||
"""Return a successful JSON response."""
|
||||
return jsonify({
|
||||
'ok': True,
|
||||
'data': data,
|
||||
'error': None,
|
||||
}), status
|
||||
|
||||
|
||||
def api_error(message: str, status: int = 400):
|
||||
"""Return an error JSON response."""
|
||||
return jsonify({
|
||||
'ok': False,
|
||||
'data': None,
|
||||
'error': message,
|
||||
}), status
|
||||
|
||||
|
||||
# ── Registered error handlers (attached to the api blueprint) ─────────────────
|
||||
|
||||
def register_error_handlers(bp):
|
||||
"""
|
||||
Attach JSON error handlers to the given blueprint so that Flask
|
||||
exceptions (404, 405, 500, etc.) return JSON instead of HTML within
|
||||
the /api/v1/ prefix.
|
||||
"""
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
@bp.errorhandler(400)
|
||||
def bad_request(e):
|
||||
return api_error(str(e.description) if hasattr(e, 'description') else 'Bad request', 400)
|
||||
|
||||
@bp.errorhandler(401)
|
||||
def unauthorized(e):
|
||||
return api_error('Authentication required', 401)
|
||||
|
||||
@bp.errorhandler(403)
|
||||
def forbidden(e):
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
@bp.errorhandler(404)
|
||||
def not_found(e):
|
||||
return api_error('Resource not found', 404)
|
||||
|
||||
@bp.errorhandler(405)
|
||||
def method_not_allowed(e):
|
||||
return api_error('Method not allowed', 405)
|
||||
|
||||
@bp.errorhandler(413)
|
||||
def payload_too_large(e):
|
||||
return api_error('Uploaded file is too large', 413)
|
||||
|
||||
@bp.errorhandler(422)
|
||||
def unprocessable(e):
|
||||
return api_error('Unprocessable request', 422)
|
||||
|
||||
@bp.errorhandler(500)
|
||||
def internal_error(e):
|
||||
return api_error('Internal server error', 500)
|
||||
|
||||
@bp.errorhandler(HTTPException)
|
||||
def generic_http(e):
|
||||
return api_error(e.description or e.name, e.code)
|
||||
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
app/api/facilities.py
|
||||
---------------------
|
||||
Mobile API endpoints for facilities and areas.
|
||||
|
||||
GET /api/v1/facilities
|
||||
Returns all active facilities accessible to the current user.
|
||||
Respects customer scoping via get_customer_scope().
|
||||
Staff roles (admin, director, inspector, project_manager) receive all
|
||||
active facilities.
|
||||
|
||||
GET /api/v1/facilities/<facility_id>/areas
|
||||
Returns all areas for a specific facility.
|
||||
Used by the iPad app to populate the area picker when starting an
|
||||
inspection.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, g
|
||||
from app import db
|
||||
from app.models.facility import Facility, Area
|
||||
from app.models.project import Project
|
||||
from app.api.errors import api_ok, api_error
|
||||
from app.api.decorators import jwt_required
|
||||
from app.utils.scope import get_customer_scope, get_inspector_scope
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('api_facilities', __name__)
|
||||
|
||||
|
||||
def _facility_payload(facility: Facility) -> dict:
|
||||
"""Serialize a Facility to the dict returned in API responses."""
|
||||
project_name = facility.project.name if facility.project else None
|
||||
return {
|
||||
'id': facility.id,
|
||||
'name': facility.name,
|
||||
'address': facility.address or '',
|
||||
'contact_person': facility.contact_person or '',
|
||||
'contact_phone': facility.contact_phone or '',
|
||||
'project_id': facility.project_id,
|
||||
'project_name': project_name,
|
||||
'is_active': facility.active,
|
||||
}
|
||||
|
||||
|
||||
def _area_payload(area: Area) -> dict:
|
||||
"""Serialize an Area to the dict returned in API responses."""
|
||||
return {
|
||||
'id': area.id,
|
||||
'facility_id': area.facility_id,
|
||||
'name': area.name,
|
||||
'area_type': area.area_type or '',
|
||||
}
|
||||
|
||||
|
||||
# ── Facilities List ───────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/facilities', methods=['GET'])
|
||||
@jwt_required
|
||||
def list_facilities():
|
||||
"""
|
||||
Return all active facilities the current user has access to.
|
||||
|
||||
Staff roles (admin, director, inspector, project_manager) receive all
|
||||
active facilities across all contracts.
|
||||
|
||||
Customer role receives only their scoped facilities (via
|
||||
CustomerAssignment records).
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"facilities": [
|
||||
{
|
||||
"id": 7,
|
||||
"name": "Main Office Building",
|
||||
"address": "123 Corporate Dr",
|
||||
"contact_person": "Jane Smith",
|
||||
"contact_phone": "555-1234",
|
||||
"project_id": 2,
|
||||
"project_name": "Corporate Cleaning Contract",
|
||||
"is_active": true
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
}
|
||||
"""
|
||||
user = g.api_user
|
||||
|
||||
customer_fids = get_customer_scope(user)
|
||||
inspector_fids = get_inspector_scope(user)
|
||||
|
||||
if customer_fids is not None:
|
||||
# Customer — scope to assigned facilities only
|
||||
if not customer_fids:
|
||||
logger.info('API FACILITIES | user=%s | role=customer | no_assignments',
|
||||
user.username)
|
||||
return api_ok({'facilities': [], 'count': 0})
|
||||
facilities = (
|
||||
Facility.query
|
||||
.filter(Facility.id.in_(customer_fids), Facility.active == True)
|
||||
.order_by(Facility.name)
|
||||
.all()
|
||||
)
|
||||
elif inspector_fids is not None:
|
||||
# Inspector — scope to contracted facilities
|
||||
if not inspector_fids:
|
||||
logger.info('API FACILITIES | user=%s | role=inspector | no_assignments',
|
||||
user.username)
|
||||
return api_ok({'facilities': [], 'count': 0})
|
||||
facilities = (
|
||||
Facility.query
|
||||
.filter(Facility.id.in_(inspector_fids), Facility.active == True)
|
||||
.order_by(Facility.name)
|
||||
.all()
|
||||
)
|
||||
else:
|
||||
# All other staff — all active facilities
|
||||
facilities = (
|
||||
Facility.query
|
||||
.filter(Facility.active == True) # noqa: E712
|
||||
.order_by(Facility.name)
|
||||
.all()
|
||||
)
|
||||
|
||||
payload = [_facility_payload(f) for f in facilities]
|
||||
|
||||
logger.info('API FACILITIES | list | user=%s | role=%s | count=%d',
|
||||
user.username, user.role, len(payload))
|
||||
|
||||
return api_ok({'facilities': payload, 'count': len(payload)})
|
||||
|
||||
|
||||
# ── Areas for a Facility ──────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/facilities/<int:facility_id>/areas', methods=['GET'])
|
||||
@jwt_required
|
||||
def list_areas(facility_id):
|
||||
"""
|
||||
Return all areas for the given facility.
|
||||
|
||||
Used by the iPad app to populate the area picker when starting an
|
||||
inspection. Customer users are validated against their scope before
|
||||
the areas are returned.
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"facility_id": 7,
|
||||
"areas": [
|
||||
{ "id": 12, "facility_id": 7, "name": "Main Lobby", "area_type": "lobby" }
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
}
|
||||
"""
|
||||
user = g.api_user
|
||||
|
||||
facility = db.session.get(Facility, facility_id)
|
||||
if facility is None or not facility.active:
|
||||
return api_error('Facility not found', 404)
|
||||
|
||||
# Scope validation — customers and inspectors may only access their facilities
|
||||
customer_fids = get_customer_scope(user)
|
||||
inspector_fids = get_inspector_scope(user)
|
||||
if customer_fids is not None and facility_id not in customer_fids:
|
||||
logger.warning('API FACILITIES/AREAS | access denied | user=%s | facility_id=%d',
|
||||
user.username, facility_id)
|
||||
return api_error('Access denied', 403)
|
||||
if inspector_fids is not None and facility_id not in inspector_fids:
|
||||
logger.warning('API FACILITIES/AREAS | access denied | user=%s | facility_id=%d',
|
||||
user.username, facility_id)
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
areas = (
|
||||
Area.query
|
||||
.filter_by(facility_id=facility_id)
|
||||
.order_by(Area.name)
|
||||
.all()
|
||||
)
|
||||
|
||||
payload = [_area_payload(a) for a in areas]
|
||||
|
||||
logger.info('API FACILITIES/AREAS | user=%s | facility_id=%d | count=%d',
|
||||
user.username, facility_id, len(payload))
|
||||
|
||||
return api_ok({'facility_id': facility_id, 'areas': payload, 'count': len(payload)})
|
||||
@@ -0,0 +1,601 @@
|
||||
"""
|
||||
app/api/inspections.py
|
||||
----------------------
|
||||
Mobile API endpoints for submitting and retrieving inspections.
|
||||
|
||||
POST /api/v1/inspections
|
||||
Creates a new inspection (offline sync submission).
|
||||
Idempotent via mobile_local_id.
|
||||
|
||||
PATCH /api/v1/inspections/<inspection_id>
|
||||
Updates an existing inspection (draft → completed).
|
||||
|
||||
GET /api/v1/inspections
|
||||
Returns the authenticated inspector's own inspection history.
|
||||
Supports ?limit=N&offset=N&facility_id=N&status=completed
|
||||
"""
|
||||
|
||||
import logging
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, request, g
|
||||
from app import db
|
||||
from app.models.inspection import Inspection, InspectionTemplate
|
||||
from app.models.facility import Facility, Area
|
||||
from app.api.errors import api_ok, api_error
|
||||
from app.api.decorators import jwt_required
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE
|
||||
from app.utils.notifications import notify_by_matrix
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('api_inspections', __name__)
|
||||
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
||||
|
||||
|
||||
def _merge_form_data(existing: dict, incoming: dict) -> dict:
|
||||
"""Merge incoming form_data into existing, preserving file paths.
|
||||
|
||||
New non-empty values always win. The one exception: an empty string
|
||||
coming from the client will NOT overwrite an existing server-side upload
|
||||
path (any value that starts with 'uploads/'). This protects photo paths
|
||||
stored during an earlier POST from being silently blanked when the iOS
|
||||
sends a final PATCH whose form_data was rebuilt without re-including the
|
||||
already-uploaded paths.
|
||||
"""
|
||||
merged = dict(existing)
|
||||
for k, v in incoming.items():
|
||||
existing_v = merged.get(k)
|
||||
if (not v
|
||||
and isinstance(existing_v, str)
|
||||
and existing_v.startswith('uploads/')):
|
||||
continue # keep the saved photo path
|
||||
merged[k] = v
|
||||
return merged
|
||||
_UUID_RE = re.compile(
|
||||
r'^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _parse_datetime(value):
|
||||
"""Parse an ISO 8601 datetime string; return a naive Eastern datetime.
|
||||
|
||||
Handles the formats produced by both the web forms and iOS
|
||||
ISO8601DateFormatter():
|
||||
2026-05-28T09:41:00 (web form, already Eastern-naive)
|
||||
2026-05-28T09:41:00.000 (web form with ms)
|
||||
2026-05-28T09:41:00Z (iOS ISO8601DateFormatter, UTC)
|
||||
2026-05-28T09:41:00.000000Z (iOS with fractional seconds, UTC)
|
||||
|
||||
Values ending with 'Z' are treated as UTC and converted to Eastern.
|
||||
Values without a timezone suffix are assumed to already be Eastern-local.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
from datetime import datetime, timezone as _tz
|
||||
from app.utils.time_utils import EASTERN
|
||||
|
||||
is_utc = isinstance(value, str) and value.endswith('Z')
|
||||
normalised = value.rstrip('Z') if isinstance(value, str) else value
|
||||
for fmt in ('%Y-%m-%dT%H:%M:%S', '%Y-%m-%dT%H:%M:%S.%f', '%Y-%m-%d'):
|
||||
try:
|
||||
dt = datetime.strptime(normalised, fmt)
|
||||
if is_utc:
|
||||
dt = (dt.replace(tzinfo=_tz.utc)
|
||||
.astimezone(EASTERN)
|
||||
.replace(tzinfo=None))
|
||||
return dt
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _inspection_payload(inspection):
|
||||
"""Serialize an Inspection to the dict returned in API responses."""
|
||||
# Extract form responses from the notes JSON blob.
|
||||
# Mobile submissions store form data as {"_form_data": {...}, "_inspector_notes": "..."}.
|
||||
# Web submissions store form data in the form_data column directly.
|
||||
form_data = {}
|
||||
inspector_notes = ''
|
||||
if inspection.notes:
|
||||
try:
|
||||
notes_obj = json.loads(inspection.notes)
|
||||
if isinstance(notes_obj, dict):
|
||||
form_data = notes_obj.get('_form_data', {}) or {}
|
||||
inspector_notes = notes_obj.get('_inspector_notes', '') or ''
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
# Fallback: web-created inspections store responses in form_data column
|
||||
if not form_data and inspection.form_data:
|
||||
form_data = inspection.form_data if isinstance(inspection.form_data, dict) else {}
|
||||
|
||||
# Include the template's form_schema so the iPad can render history
|
||||
# without needing a locally cached copy of the template.
|
||||
form_schema = []
|
||||
if inspection.template:
|
||||
form_schema = inspection.template.get_form_schema()
|
||||
|
||||
return {
|
||||
'id': inspection.id,
|
||||
'template_id': inspection.template_id,
|
||||
'template_name': inspection.template.name if inspection.template else '',
|
||||
'facility_id': inspection.facility_id,
|
||||
'facility_name': inspection.facility.name if inspection.facility else '',
|
||||
'area_id': inspection.area_id,
|
||||
'area_name': inspection.area.name if inspection.area else None,
|
||||
'status': inspection.status,
|
||||
'overall_score': float(inspection.overall_score) if inspection.overall_score is not None else None,
|
||||
'inspection_date': inspection.inspection_date.isoformat()
|
||||
if inspection.inspection_date else None,
|
||||
'completed_at': inspection.completed_at.isoformat()
|
||||
if inspection.completed_at else None,
|
||||
'mobile_local_id': inspection.mobile_local_id,
|
||||
'form_data': form_data,
|
||||
'form_schema': form_schema,
|
||||
'inspector_notes': inspector_notes,
|
||||
# ── Follow-up / re-inspection fields ──────────────────────────────
|
||||
'follow_up_required': inspection.follow_up_required,
|
||||
'follow_up_note': inspection.follow_up_note,
|
||||
'parent_inspection_id': inspection.parent_inspection_id,
|
||||
}
|
||||
|
||||
|
||||
# ── Inspection History ────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/inspections', methods=['GET'])
|
||||
@jwt_required
|
||||
def list_inspections():
|
||||
"""
|
||||
Return the authenticated user's inspection history.
|
||||
|
||||
Inspectors see only their own inspections.
|
||||
Admins/directors/project_managers see all inspections.
|
||||
|
||||
Query parameters
|
||||
----------------
|
||||
limit int default 50, max 200
|
||||
offset int default 0
|
||||
facility_id int filter by facility
|
||||
status str filter by status (completed, in_progress, flagged)
|
||||
from_date str ISO date (YYYY-MM-DD) — include inspections on/after this date
|
||||
to_date str ISO date (YYYY-MM-DD) — include inspections on/before this date
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"inspections": [...],
|
||||
"total": 42,
|
||||
"limit": 50,
|
||||
"offset": 0
|
||||
}
|
||||
}
|
||||
"""
|
||||
user = g.api_user
|
||||
|
||||
if user.role not in _ALLOWED_ROLES:
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
limit = min(int(request.args.get('limit', 50)), 200)
|
||||
offset = max(int(request.args.get('offset', 0)), 0)
|
||||
|
||||
query = Inspection.query
|
||||
|
||||
# Inspectors only see their own inspections
|
||||
if user.role == 'inspector':
|
||||
query = query.filter(Inspection.inspector_id == user.id)
|
||||
|
||||
# Optional filters
|
||||
facility_id = request.args.get('facility_id', type=int)
|
||||
if facility_id:
|
||||
query = query.filter(Inspection.facility_id == facility_id)
|
||||
|
||||
status = request.args.get('status')
|
||||
if status:
|
||||
query = query.filter(Inspection.status == status)
|
||||
|
||||
from_date_str = request.args.get('from_date')
|
||||
if from_date_str:
|
||||
try:
|
||||
from_dt = datetime.strptime(from_date_str, '%Y-%m-%d').date()
|
||||
query = query.filter(Inspection.inspection_date >= from_dt)
|
||||
except ValueError:
|
||||
pass # malformed date — ignore silently
|
||||
|
||||
to_date_str = request.args.get('to_date')
|
||||
if to_date_str:
|
||||
try:
|
||||
to_dt = datetime.strptime(to_date_str, '%Y-%m-%d').date()
|
||||
query = query.filter(Inspection.inspection_date <= to_dt)
|
||||
except ValueError:
|
||||
pass # malformed date — ignore silently
|
||||
|
||||
total = query.count()
|
||||
inspections = (
|
||||
query
|
||||
.order_by(Inspection.inspection_date.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
payload = [_inspection_payload(i) for i in inspections]
|
||||
|
||||
logger.info('API INSPECTIONS | list | user=%s | count=%d | total=%d',
|
||||
user.username, len(payload), total)
|
||||
|
||||
return api_ok({
|
||||
'inspections': payload,
|
||||
'total': total,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
})
|
||||
|
||||
|
||||
# ── Create Inspection ─────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/inspections', methods=['POST'])
|
||||
@jwt_required
|
||||
def create_inspection():
|
||||
"""
|
||||
Create a new inspection submitted from the iPad app.
|
||||
|
||||
Idempotency: if mobile_local_id is provided and an inspection with that
|
||||
ID already exists, the existing record is returned without duplication.
|
||||
|
||||
Request JSON
|
||||
------------
|
||||
{
|
||||
"template_id": 3,
|
||||
"facility_id": 7,
|
||||
"area_id": 12,
|
||||
"status": "completed",
|
||||
"form_data": { ... },
|
||||
"notes": "...",
|
||||
"overall_score": 87.5,
|
||||
"inspection_date": "2026-05-01T14:30:00",
|
||||
"completed_at": "2026-05-01T15:00:00",
|
||||
"mobile_local_id": "uuid-string"
|
||||
}
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{ "ok": true, "data": { "inspection_id": 42, "duplicate": false } }
|
||||
"""
|
||||
user = g.api_user
|
||||
|
||||
if user.role not in _ALLOWED_ROLES:
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
# ── Idempotency check ─────────────────────────────────────────────────
|
||||
mobile_local_id = data.get('mobile_local_id')
|
||||
if mobile_local_id:
|
||||
if not _UUID_RE.match(str(mobile_local_id)):
|
||||
return api_error('mobile_local_id must be a valid UUID', 400)
|
||||
existing = Inspection.query.filter_by(mobile_local_id=mobile_local_id).first()
|
||||
if existing:
|
||||
logger.info('API INSPECTIONS | duplicate | local_id=%s | inspection_id=%d | user=%s',
|
||||
mobile_local_id, existing.id, user.username)
|
||||
return api_ok({'inspection_id': existing.id, 'duplicate': True})
|
||||
|
||||
# ── Validate required fields ──────────────────────────────────────────
|
||||
template_id = data.get('template_id')
|
||||
facility_id = data.get('facility_id')
|
||||
|
||||
if not template_id or not facility_id:
|
||||
return api_error('template_id and facility_id are required', 400)
|
||||
|
||||
template = db.session.get(InspectionTemplate, template_id)
|
||||
if template is None:
|
||||
return api_error('Template not found', 404)
|
||||
|
||||
facility = db.session.get(Facility, facility_id)
|
||||
if facility is None or not facility.active:
|
||||
return api_error('Facility not found', 404)
|
||||
|
||||
area_id = data.get('area_id')
|
||||
if area_id:
|
||||
area = db.session.get(Area, area_id)
|
||||
if area is None or area.facility_id != facility_id:
|
||||
return api_error('Area not found or does not belong to the facility', 400)
|
||||
|
||||
status = data.get('status', 'completed')
|
||||
if status not in ('in_progress', 'completed'):
|
||||
return api_error('status must be "in_progress" or "completed"', 400)
|
||||
|
||||
# ── Optional parent link (re-inspection) ──────────────────────────────
|
||||
parent_inspection_id = data.get('parent_inspection_id')
|
||||
if parent_inspection_id:
|
||||
parent = db.session.get(Inspection, parent_inspection_id)
|
||||
if parent is None:
|
||||
return api_error('Parent inspection not found', 404)
|
||||
|
||||
# ── Score calculation ─────────────────────────────────────────────────
|
||||
overall_score = data.get('overall_score')
|
||||
if overall_score is None and status == 'completed':
|
||||
form_data = data.get('form_data') or {}
|
||||
form_fields = template.get_form_schema()
|
||||
overall_score = _compute_score(form_fields, form_data)
|
||||
|
||||
# ── Build inspection record ───────────────────────────────────────────
|
||||
inspection_date = _parse_datetime(data.get('inspection_date')) or now_eastern()
|
||||
completed_at = _parse_datetime(data.get('completed_at'))
|
||||
|
||||
form_data = data.get('form_data') or {}
|
||||
notes_payload = {}
|
||||
if data.get('notes'):
|
||||
notes_payload['_inspector_notes'] = data['notes']
|
||||
notes_payload['_form_data'] = form_data
|
||||
|
||||
# If the client sent completed_at but it failed to parse (e.g. unrecognised
|
||||
# format), fall back to now rather than storing NULL. This mirrors the
|
||||
# PATCH handler's behaviour.
|
||||
if completed_at is None and status == 'completed':
|
||||
completed_at = now_eastern()
|
||||
|
||||
# ── GPS (mobile submission) ───────────────────────────────────────────
|
||||
# The iPad sends submit_latitude / submit_longitude when CoreLocation
|
||||
# granted permission and a fix was obtained before the inspector confirmed
|
||||
# submission. Both fields are nullable — absence is silently ignored.
|
||||
_lat = data.get('submit_latitude')
|
||||
_lng = data.get('submit_longitude')
|
||||
try:
|
||||
submit_latitude = float(_lat) if _lat is not None else None
|
||||
submit_longitude = float(_lng) if _lng is not None else None
|
||||
except (ValueError, TypeError):
|
||||
submit_latitude = None
|
||||
submit_longitude = None
|
||||
|
||||
inspection = Inspection(
|
||||
template_id = template_id,
|
||||
facility_id = facility_id,
|
||||
area_id = area_id,
|
||||
inspector_id = user.id,
|
||||
inspection_date = inspection_date,
|
||||
overall_score = overall_score,
|
||||
status = status,
|
||||
notes = json.dumps(notes_payload),
|
||||
completed_at = completed_at if status == 'completed' else None,
|
||||
mobile_local_id = mobile_local_id,
|
||||
parent_inspection_id = parent_inspection_id,
|
||||
submit_latitude = submit_latitude,
|
||||
submit_longitude = submit_longitude,
|
||||
)
|
||||
|
||||
db.session.add(inspection)
|
||||
db.session.flush()
|
||||
|
||||
# ── Auto-clear follow-up flag on parent ───────────────────────────────
|
||||
# When a completed re-inspection arrives that links to a parent, clear
|
||||
# follow_up_required on the parent automatically. This mirrors the web
|
||||
# list view's implicit logic (which hides the badge when follow_ups.any())
|
||||
# and ensures the History API response reflects the resolved state.
|
||||
# Capture parent label strings before commit while ORM objects are loaded.
|
||||
# log_action() for the parent update must fire AFTER db.session.commit() to
|
||||
# avoid audit.py's internal commit() persisting the parent flag change before
|
||||
# the new inspection row is committed — a partial state that would be incorrect
|
||||
# if the main commit subsequently failed.
|
||||
_parent_log_args = None
|
||||
if parent_inspection_id and status == 'completed':
|
||||
parent_insp = db.session.get(Inspection, parent_inspection_id)
|
||||
if parent_insp and parent_insp.follow_up_required:
|
||||
parent_insp.follow_up_required = False
|
||||
logger.info(
|
||||
'API INSPECTIONS | follow_up cleared | parent_id=%d | '
|
||||
'by_inspection_id=%d | user=%s',
|
||||
parent_inspection_id, inspection.id, user.username,
|
||||
)
|
||||
# Snapshot label strings now — ORM objects may be expired after commit
|
||||
_parent_log_args = (
|
||||
parent_inspection_id,
|
||||
f'{parent_insp.template.name} @ {parent_insp.facility.name}',
|
||||
f'follow_up_required=False (cleared by re-inspection '
|
||||
f'#{inspection.id} via mobile API)',
|
||||
)
|
||||
|
||||
# ── Notifications ─────────────────────────────────────────────────────
|
||||
if status == 'completed':
|
||||
score_display = f'{overall_score:.1f}%' if overall_score is not None else 'N/A'
|
||||
try:
|
||||
from flask import url_for
|
||||
inspection_link = url_for('inspections.view',
|
||||
inspection_id=inspection.id, _external=False)
|
||||
except RuntimeError:
|
||||
inspection_link = f'/inspections/{inspection.id}'
|
||||
|
||||
notify_by_matrix(
|
||||
event_type = 'inspection_completed',
|
||||
title = f'Inspection #{inspection.id} Completed (Mobile)',
|
||||
body = (
|
||||
f'{user.display_name} completed an inspection at '
|
||||
f'{facility.name} using the "{template.name}" template. '
|
||||
f'Overall score: {score_display}.'
|
||||
),
|
||||
link = inspection_link,
|
||||
inspection_id = inspection.id,
|
||||
facility_id = facility_id,
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# ── Post-commit audit logging ──────────────────────────────────────────
|
||||
# All log_action() calls must come AFTER db.session.commit() because
|
||||
# audit.py calls db.session.commit() internally. Calling it before the
|
||||
# main commit would persist the audit row (and any dirty ORM state) before
|
||||
# the primary transaction completes.
|
||||
if _parent_log_args:
|
||||
log_action(ACTION_UPDATE, 'Inspection', *_parent_log_args)
|
||||
|
||||
log_action(ACTION_CREATE, 'Inspection', inspection.id,
|
||||
f'{template.name} @ {facility.name}',
|
||||
f'source=mobile; status={status}; score={overall_score}; '
|
||||
f'local_id={mobile_local_id}; parent_id={parent_inspection_id}')
|
||||
|
||||
logger.info('API INSPECTIONS | created | inspection_id=%d | facility=%s | '
|
||||
'template=%s | status=%s | score=%s | user=%s',
|
||||
inspection.id, facility.name, template.name,
|
||||
status, overall_score, user.username)
|
||||
|
||||
return api_ok({'inspection_id': inspection.id, 'duplicate': False})
|
||||
|
||||
|
||||
# ── Update Inspection ─────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/inspections/<int:inspection_id>', methods=['PATCH'])
|
||||
@jwt_required
|
||||
def update_inspection(inspection_id):
|
||||
"""
|
||||
Update an existing inspection (e.g. draft → completed).
|
||||
|
||||
Request JSON (all fields optional)
|
||||
------------
|
||||
{
|
||||
"status": "completed",
|
||||
"form_data": { ... },
|
||||
"notes": "...",
|
||||
"overall_score": 91.0,
|
||||
"completed_at": "2026-05-01T15:30:00"
|
||||
}
|
||||
"""
|
||||
user = g.api_user
|
||||
|
||||
if user.role not in _ALLOWED_ROLES:
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
inspection = db.session.get(Inspection, inspection_id)
|
||||
if inspection is None:
|
||||
return api_error('Inspection not found', 404)
|
||||
|
||||
if user.role == 'inspector' and inspection.inspector_id != user.id:
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
if 'form_data' in data or 'notes' in data:
|
||||
existing_notes = {}
|
||||
if inspection.notes:
|
||||
try:
|
||||
existing_notes = json.loads(inspection.notes)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
existing_notes = {}
|
||||
if 'form_data' in data:
|
||||
existing_notes['_form_data'] = _merge_form_data(
|
||||
existing_notes.get('_form_data') or {},
|
||||
data['form_data'] or {},
|
||||
)
|
||||
if 'notes' in data:
|
||||
existing_notes['_inspector_notes'] = data['notes']
|
||||
inspection.notes = json.dumps(existing_notes)
|
||||
|
||||
prev_status = inspection.status
|
||||
|
||||
if 'status' in data:
|
||||
inspection.status = data['status']
|
||||
|
||||
if 'overall_score' in data:
|
||||
inspection.overall_score = data['overall_score']
|
||||
elif data.get('status') == 'completed' and inspection.overall_score is None:
|
||||
form_data = data.get('form_data') or {}
|
||||
form_fields = inspection.template.get_form_schema()
|
||||
inspection.overall_score = _compute_score(form_fields, form_data)
|
||||
|
||||
if 'completed_at' in data:
|
||||
inspection.completed_at = _parse_datetime(data['completed_at']) or now_eastern()
|
||||
elif data.get('status') == 'completed' and not inspection.completed_at:
|
||||
inspection.completed_at = now_eastern()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Notify when a draft transitions to completed — mirrors the POST handler.
|
||||
transitioning_to_complete = (
|
||||
data.get('status') == 'completed' and prev_status != 'completed'
|
||||
)
|
||||
if transitioning_to_complete:
|
||||
score_val = inspection.overall_score
|
||||
score_display = f'{score_val:.1f}%' if score_val is not None else 'N/A'
|
||||
template_name = inspection.template.name if inspection.template else 'Unknown'
|
||||
facility_name = inspection.facility.name if inspection.facility else 'Unknown'
|
||||
try:
|
||||
from flask import url_for
|
||||
inspection_link = url_for('inspections.view',
|
||||
inspection_id=inspection.id, _external=False)
|
||||
except RuntimeError:
|
||||
inspection_link = f'/inspections/{inspection.id}'
|
||||
notify_by_matrix(
|
||||
event_type = 'inspection_completed',
|
||||
title = f'Inspection #{inspection.id} Completed (Mobile)',
|
||||
body = (
|
||||
f'{user.display_name} completed an inspection at '
|
||||
f'{facility_name} using the "{template_name}" template. '
|
||||
f'Overall score: {score_display}.'
|
||||
),
|
||||
link = inspection_link,
|
||||
inspection_id = inspection.id,
|
||||
facility_id = inspection.facility_id,
|
||||
)
|
||||
db.session.commit() # persist notification rows added by notify()
|
||||
|
||||
log_action(ACTION_UPDATE, 'Inspection', inspection.id,
|
||||
f'{inspection.template.name} @ {inspection.facility.name}',
|
||||
f'source=mobile; fields_updated={list(data.keys())}')
|
||||
|
||||
logger.info('API INSPECTIONS | updated | inspection_id=%d | user=%s | fields=%s',
|
||||
inspection_id, user.username, list(data.keys()))
|
||||
|
||||
return api_ok({'inspection_id': inspection_id})
|
||||
|
||||
|
||||
# ── Score helper ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _compute_score(form_fields, responses):
|
||||
"""
|
||||
Mirror of routes/inspections.py::_compute_score_from_form().
|
||||
Rating value 0 = unanswered — excluded from calculation.
|
||||
"""
|
||||
scoreable = [f for f in form_fields
|
||||
if f.get('type') in ('rating', 'checkbox', 'radio', 'pass_fail')]
|
||||
if not scoreable:
|
||||
return None
|
||||
|
||||
total, earned = 0, 0
|
||||
for field in scoreable:
|
||||
fid = field.get('id')
|
||||
val = responses.get(str(fid), responses.get(fid, ''))
|
||||
ftype = field.get('type')
|
||||
|
||||
if ftype == 'rating':
|
||||
try:
|
||||
v = int(val)
|
||||
if v == 0:
|
||||
continue
|
||||
earned += v
|
||||
total += 5
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
elif ftype == 'checkbox':
|
||||
total += 1
|
||||
if val == 'true':
|
||||
earned += 1
|
||||
|
||||
elif ftype == 'radio':
|
||||
total += 1
|
||||
if str(val).lower() in ('pass', 'yes', 'ok', 'good', 'acceptable', 'compliant'):
|
||||
earned += 1
|
||||
|
||||
elif ftype == 'pass_fail':
|
||||
if not val:
|
||||
continue
|
||||
total += 1
|
||||
if str(val).lower() in ('pass', 'yes', 'ok', 'good', 'acceptable', 'compliant'):
|
||||
earned += 1
|
||||
|
||||
return round((earned / total) * 100, 2) if total else None
|
||||
@@ -0,0 +1,435 @@
|
||||
"""
|
||||
app/api/issues.py
|
||||
-----------------
|
||||
Mobile API endpoint for submitting issues from the iPad app.
|
||||
|
||||
GET /api/v1/issues
|
||||
Returns issues assigned to the authenticated inspector (or all for admin/director).
|
||||
Used by the iPad to display assigned issues that were created via the web portal.
|
||||
|
||||
POST /api/v1/issues
|
||||
Creates a new issue record.
|
||||
Accepts a mobile_local_id for idempotency.
|
||||
Triggers notify_by_matrix() and log_action() identically to the web route.
|
||||
|
||||
GET /api/v1/issues/<id>
|
||||
Returns current status, severity, description, and assigned_to for an issue.
|
||||
Inspectors may only fetch issues assigned to them.
|
||||
|
||||
PATCH /api/v1/issues/<id>/status
|
||||
Updates the status of an issue.
|
||||
Inspectors may only update issues assigned to them.
|
||||
Admins/directors may update any issue.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from flask import Blueprint, request, g, current_app
|
||||
from app import db
|
||||
from app.models.issue import Issue
|
||||
from app.models.facility import Facility, Area
|
||||
from app.models.inspection import Inspection
|
||||
from app.api.errors import api_ok, api_error
|
||||
from app.api.decorators import jwt_required
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE
|
||||
from app.utils.notifications import notify_by_matrix
|
||||
from app.utils.time_utils import now_eastern
|
||||
from app.utils.scope import get_inspector_scope
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('api_issues', __name__)
|
||||
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
||||
_VALID_SEVERITY = {'low', 'medium', 'high', 'critical'}
|
||||
_VALID_STATUSES = {'open', 'in_progress', 'resolved', 'pending_verification'}
|
||||
_UUID_RE = re.compile(
|
||||
r'^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _issue_payload(issue):
|
||||
"""Serialise an Issue to the dict returned in list/detail responses."""
|
||||
facility = issue.resolved_facility
|
||||
return {
|
||||
'id': issue.id,
|
||||
'status': issue.status,
|
||||
'severity': issue.severity,
|
||||
'description': issue.description,
|
||||
'assigned_to': issue.assigned_to,
|
||||
'facility_id': facility.id if facility else None,
|
||||
'facility_name': facility.name if facility else None,
|
||||
'reported_at': issue.reported_at.isoformat() if issue.reported_at else None,
|
||||
'resolved_at': issue.resolved_at.isoformat() if issue.resolved_at else None,
|
||||
'mobile_local_id': issue.mobile_local_id,
|
||||
# photo_path: primary evidence photo (first iPad photo).
|
||||
# mobile_photo_paths: extra evidence photos from iPad (shown under Photo Evidence).
|
||||
# result_photos: resolution photos added via the web update form.
|
||||
'photo_path': issue.photo_path or None,
|
||||
'mobile_photo_paths': issue.mobile_photo_paths or [],
|
||||
'result_photos': issue.result_photos or [],
|
||||
# Resolution details — set by web staff after fixing the issue.
|
||||
'result_notes': issue.result_notes or None,
|
||||
# Verification fields — set after a director/admin confirms fix.
|
||||
'verified_at': issue.verified_at.isoformat() if issue.verified_at else None,
|
||||
'verification_note': issue.verification_note or None,
|
||||
# Reporter display name — shows who filed the issue.
|
||||
'reported_by_name': issue.reporter.display_name if issue.reporter else None,
|
||||
# Area name — set when the issue was flagged during an area-specific inspection.
|
||||
'area_name': issue.area.name if issue.area else None,
|
||||
# Assigned-to display name — set when a director assigns the issue to a user.
|
||||
'assigned_to_name': issue.assigned_user.display_name if issue.assigned_user else None,
|
||||
}
|
||||
|
||||
|
||||
# ── List Assigned Issues ──────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/issues', methods=['GET'])
|
||||
@jwt_required
|
||||
def list_issues():
|
||||
"""
|
||||
Return active issues for the authenticated user.
|
||||
|
||||
All roles see all non-resolved issues (inspectors included) so the iPad
|
||||
shows the full picture of open work at their facilities.
|
||||
A ?status= filter can be used to override the default exclusion.
|
||||
|
||||
Query parameters
|
||||
----------------
|
||||
status str Filter by status. Omit to get all non-resolved issues.
|
||||
limit int Default 100, max 200.
|
||||
offset int Default 0.
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"issues": [...],
|
||||
"total": 12,
|
||||
"limit": 100,
|
||||
"offset": 0
|
||||
}
|
||||
}
|
||||
"""
|
||||
user = g.api_user
|
||||
if user.role not in _ALLOWED_ROLES:
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
limit = min(int(request.args.get('limit', 100)), 200)
|
||||
offset = max(int(request.args.get('offset', 0)), 0)
|
||||
|
||||
query = Issue.query
|
||||
|
||||
if user.role == 'inspector':
|
||||
fids = get_inspector_scope(user)
|
||||
if not fids:
|
||||
return api_ok({'issues': [], 'total': 0, 'limit': limit, 'offset': offset})
|
||||
query = query.outerjoin(Area, Issue.area_id == Area.id).filter(
|
||||
db.or_(
|
||||
Issue.facility_id.in_(fids),
|
||||
db.and_(Issue.area_id.isnot(None), Area.facility_id.in_(fids)),
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Broader roles: exclude resolved by default so the list stays manageable
|
||||
status_filter = request.args.get('status')
|
||||
if status_filter:
|
||||
query = query.filter(Issue.status == status_filter)
|
||||
else:
|
||||
query = query.filter(Issue.status != 'resolved')
|
||||
|
||||
total = query.count()
|
||||
issues = (
|
||||
query
|
||||
.order_by(Issue.reported_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
payload = [_issue_payload(i) for i in issues]
|
||||
|
||||
logger.info('API ISSUES | list | user=%s | count=%d | total=%d',
|
||||
user.username, len(payload), total)
|
||||
|
||||
return api_ok({'issues': payload, 'total': total, 'limit': limit, 'offset': offset})
|
||||
|
||||
@bp.route('/issues', methods=['POST'])
|
||||
@jwt_required
|
||||
def create_issue():
|
||||
"""
|
||||
Create a new issue submitted from the iPad app.
|
||||
|
||||
Idempotency: if mobile_local_id is provided and an issue with that ID
|
||||
already exists, the existing record is returned without duplication.
|
||||
|
||||
Request JSON
|
||||
------------
|
||||
{
|
||||
"inspection_id": 42, // optional
|
||||
"facility_id": 5, // required
|
||||
"severity": "high", // "low"|"medium"|"high"|"critical"
|
||||
"description": "...", // required
|
||||
"photo_path": "uploads/...",// optional — already uploaded via /photos/upload
|
||||
"mobile_local_id": "uuid-string" // idempotency key
|
||||
}
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{ "ok": true, "data": { "issue_id": 99, "duplicate": false } }
|
||||
"""
|
||||
user = g.api_user
|
||||
|
||||
if user.role not in _ALLOWED_ROLES:
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
# ── Idempotency check ─────────────────────────────────────────────────
|
||||
mobile_local_id = data.get('mobile_local_id')
|
||||
if mobile_local_id:
|
||||
if not _UUID_RE.match(str(mobile_local_id)):
|
||||
return api_error('mobile_local_id must be a valid UUID', 400)
|
||||
existing = Issue.query.filter_by(mobile_local_id=mobile_local_id).first()
|
||||
if existing:
|
||||
logger.info('API ISSUES | duplicate | local_id=%s | issue_id=%d | user=%s',
|
||||
mobile_local_id, existing.id, user.username)
|
||||
return api_ok({'issue_id': existing.id, 'duplicate': True})
|
||||
|
||||
# ── Validate ──────────────────────────────────────────────────────────
|
||||
facility_id = data.get('facility_id')
|
||||
severity = data.get('severity', '').lower()
|
||||
description = (data.get('description') or '').strip()
|
||||
|
||||
if not facility_id:
|
||||
return api_error('facility_id is required', 400)
|
||||
if severity not in _VALID_SEVERITY:
|
||||
return api_error(f'severity must be one of: {", ".join(sorted(_VALID_SEVERITY))}', 400)
|
||||
if not description:
|
||||
return api_error('description is required', 400)
|
||||
|
||||
facility = db.session.get(Facility, facility_id)
|
||||
if facility is None:
|
||||
return api_error('Facility not found', 404)
|
||||
|
||||
if user.role == 'inspector':
|
||||
fids = get_inspector_scope(user)
|
||||
if not fids or facility_id not in fids:
|
||||
return api_error('Access denied — facility is not in your assigned contracts', 403)
|
||||
|
||||
inspection_id = data.get('inspection_id')
|
||||
if inspection_id:
|
||||
inspection = db.session.get(Inspection, inspection_id)
|
||||
if inspection is None:
|
||||
return api_error('Inspection not found', 404)
|
||||
else:
|
||||
inspection = None
|
||||
|
||||
# ── Create ────────────────────────────────────────────────────────────
|
||||
# result_photos in the POST body = extra evidence photos from the iPad.
|
||||
# Store in mobile_photo_paths (not result_photos) so they appear under
|
||||
# "Photo Evidence" on the web, not "Resolution Details".
|
||||
raw_mobile = data.get('result_photos')
|
||||
mobile_photo_paths = [p for p in raw_mobile if isinstance(p, str) and p.strip()] \
|
||||
if isinstance(raw_mobile, list) else []
|
||||
|
||||
issue = Issue(
|
||||
inspection_id = inspection_id,
|
||||
facility_id = facility_id,
|
||||
severity = severity,
|
||||
description = description,
|
||||
photo_path = data.get('photo_path') or None,
|
||||
mobile_photo_paths = mobile_photo_paths or None,
|
||||
status = 'open',
|
||||
reported_at = now_eastern(),
|
||||
reported_by = user.id,
|
||||
mobile_local_id = mobile_local_id,
|
||||
)
|
||||
db.session.add(issue)
|
||||
db.session.commit() # commit issue first so FK references in notifications are valid
|
||||
|
||||
# ── Notifications ─────────────────────────────────────────────────────
|
||||
try:
|
||||
from flask import url_for
|
||||
issue_link = url_for('issues.view', issue_id=issue.id, _external=False)
|
||||
except RuntimeError:
|
||||
issue_link = f'/issues/{issue.id}'
|
||||
|
||||
inspection_ref = f'inspection #{inspection_id}' if inspection_id else 'a standalone report'
|
||||
notify_by_matrix(
|
||||
event_type = 'issue_created',
|
||||
title = f'New Issue #{issue.id} at {facility.name} (Mobile)',
|
||||
body = (
|
||||
f'A new {severity.title()}-severity issue was logged at '
|
||||
f'{facility.name} during {inspection_ref}. '
|
||||
f'Description: {description[:120]}'
|
||||
f'{"…" if len(description) > 120 else ""}'
|
||||
),
|
||||
link = issue_link,
|
||||
issue_id = issue.id,
|
||||
facility_id = facility_id,
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
log_action(ACTION_CREATE, 'Issue', issue.id,
|
||||
f'{severity} issue at {facility.name}',
|
||||
f'source=mobile; inspection_id={inspection_id}; '
|
||||
f'local_id={mobile_local_id}')
|
||||
|
||||
logger.info('API ISSUES | created | issue_id=%d | facility=%s | severity=%s | user=%s',
|
||||
issue.id, facility.name, severity, user.username)
|
||||
|
||||
return api_ok({'issue_id': issue.id, 'duplicate': False})
|
||||
|
||||
|
||||
# ── Get Issue Detail ──────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/issues/<int:issue_id>', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_issue(issue_id):
|
||||
"""
|
||||
Return current status, severity, description, assigned_to, and facility
|
||||
for a single issue.
|
||||
|
||||
Access: all allowed roles may fetch any issue.
|
||||
"""
|
||||
user = g.api_user
|
||||
if user.role not in _ALLOWED_ROLES:
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
issue = db.session.get(Issue, issue_id)
|
||||
if issue is None:
|
||||
return api_error('Issue not found', 404)
|
||||
|
||||
if user.role == 'inspector':
|
||||
fids = get_inspector_scope(user)
|
||||
facility = issue.resolved_facility
|
||||
if not fids or not facility or facility.id not in fids:
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
return api_ok(_issue_payload(issue))
|
||||
|
||||
|
||||
# ── Update Issue Status ───────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/issues/<int:issue_id>/status', methods=['PATCH'])
|
||||
@jwt_required
|
||||
def update_issue_status(issue_id):
|
||||
"""
|
||||
Update the status of an issue.
|
||||
|
||||
Request JSON
|
||||
------------
|
||||
{ "status": "in_progress" } // one of: open | in_progress | resolved | pending_verification
|
||||
|
||||
Access:
|
||||
- admin / director : any issue
|
||||
- inspector : only issues where assigned_to == current user
|
||||
"""
|
||||
user = g.api_user
|
||||
if user.role not in _ALLOWED_ROLES:
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
issue = db.session.get(Issue, issue_id)
|
||||
if issue is None:
|
||||
return api_error('Issue not found', 404)
|
||||
|
||||
if user.role == 'inspector':
|
||||
fids = get_inspector_scope(user)
|
||||
facility = issue.resolved_facility
|
||||
if not fids or not facility or facility.id not in fids:
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
new_status = (data.get('status') or '').strip().lower()
|
||||
|
||||
if new_status not in _VALID_STATUSES:
|
||||
return api_error(
|
||||
f'status must be one of: {", ".join(sorted(_VALID_STATUSES))}', 400
|
||||
)
|
||||
|
||||
old_status = issue.status
|
||||
issue.status = new_status
|
||||
|
||||
if new_status == 'resolved' and not issue.resolved_at:
|
||||
issue.resolved_at = now_eastern()
|
||||
issue.sla_notified = None
|
||||
elif new_status != 'resolved':
|
||||
issue.resolved_at = None
|
||||
if old_status == 'resolved':
|
||||
issue.sla_notified = None
|
||||
|
||||
db.session.commit()
|
||||
|
||||
log_action(ACTION_UPDATE, 'Issue', issue.id,
|
||||
f'status {old_status} → {new_status}',
|
||||
f'source=mobile; updated_by={user.username}')
|
||||
|
||||
logger.info('API ISSUES | status_updated | issue_id=%d | %s→%s | user=%s',
|
||||
issue.id, old_status, new_status, user.username)
|
||||
|
||||
return api_ok({'issue_id': issue.id, 'status': issue.status})
|
||||
|
||||
# ── Update Issue Photos (mobile) ──────────────────────────────────────────────
|
||||
|
||||
@bp.route('/issues/<int:issue_id>/photos', methods=['PATCH'])
|
||||
@jwt_required
|
||||
def update_issue_photos(issue_id):
|
||||
"""
|
||||
Attach additional evidence photos to an issue created from the mobile app.
|
||||
|
||||
Called by the iOS app after create_issue when the inspector attached more
|
||||
than one photo. Photos are already uploaded via /api/v1/photos/upload.
|
||||
Stored in mobile_photo_paths so they display under "Photo Evidence" on the
|
||||
web, not "Resolution Details".
|
||||
|
||||
Request JSON
|
||||
------------
|
||||
{ "result_photos": ["uploads/issue_photos/a.jpg", "uploads/issue_photos/b.jpg"] }
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{ "ok": true, "data": { "issue_id": 99, "result_photos_count": 2 } }
|
||||
"""
|
||||
user = g.api_user
|
||||
if user.role not in _ALLOWED_ROLES:
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
issue = db.session.get(Issue, issue_id)
|
||||
if issue is None:
|
||||
return api_error('Issue not found', 404)
|
||||
|
||||
if user.role == 'inspector':
|
||||
fids = get_inspector_scope(user)
|
||||
facility = issue.resolved_facility
|
||||
if not fids or not facility or facility.id not in fids:
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
raw = data.get('result_photos')
|
||||
|
||||
if not isinstance(raw, list):
|
||||
return api_error('result_photos must be a list of path strings', 400)
|
||||
|
||||
new_photos = [p for p in raw if isinstance(p, str) and p.strip()]
|
||||
if not new_photos:
|
||||
return api_error('result_photos must contain at least one valid path', 400)
|
||||
|
||||
# Merge idempotently with any existing mobile_photo_paths
|
||||
existing = issue.mobile_photo_paths or []
|
||||
merged = existing + [p for p in new_photos if p not in existing]
|
||||
issue.mobile_photo_paths = merged
|
||||
|
||||
db.session.commit()
|
||||
|
||||
log_action(ACTION_UPDATE, 'Issue', issue.id,
|
||||
f'mobile_photo_paths updated (+{len(new_photos)} photos)',
|
||||
f'source=mobile; updated_by={user.username}')
|
||||
|
||||
logger.info('API ISSUES | photos_updated | issue_id=%d | added=%d | user=%s',
|
||||
issue.id, len(new_photos), user.username)
|
||||
|
||||
return api_ok({'issue_id': issue.id, 'result_photos_count': len(merged)})
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
app/api/jwt_utils.py
|
||||
--------------------
|
||||
Thin wrappers around PyJWT for signing and verifying access tokens.
|
||||
|
||||
Access tokens are short-lived JWTs (default 60 minutes) signed with
|
||||
HMAC-SHA256 using the app's SECRET_KEY. They carry only the minimum
|
||||
claims needed to identify the caller:
|
||||
|
||||
{
|
||||
"sub": "42", # user.id as string
|
||||
"role": "inspector", # user.role
|
||||
"iat": 1710000000, # issued-at (UTC epoch)
|
||||
"exp": 1710003600, # expiry (UTC epoch, 60 min later)
|
||||
}
|
||||
|
||||
Refresh tokens are opaque random strings stored in the DB
|
||||
(see app/models/api_token.py). This module only handles JWTs.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
import jwt
|
||||
from flask import current_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ACCESS_TOKEN_LIFETIME_MINUTES = 60
|
||||
|
||||
|
||||
def _secret():
|
||||
return current_app.config['SECRET_KEY']
|
||||
|
||||
|
||||
def generate_access_token(user, lifetime_minutes: int = ACCESS_TOKEN_LIFETIME_MINUTES) -> str:
|
||||
"""
|
||||
Create and sign a new access token for the given user.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
user : User ORM instance
|
||||
lifetime_minutes : Token validity window (default 60 min)
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
Signed JWT string ready to include in Authorization header.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
'sub': str(user.id),
|
||||
'role': user.role,
|
||||
'iat': now,
|
||||
'exp': now + timedelta(minutes=lifetime_minutes),
|
||||
}
|
||||
return jwt.encode(payload, _secret(), algorithm='HS256')
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict | None:
|
||||
"""
|
||||
Decode and verify a JWT access token.
|
||||
|
||||
Returns the payload dict on success, or None if the token is invalid,
|
||||
expired, or tampered with. Logs the failure reason at DEBUG level.
|
||||
"""
|
||||
try:
|
||||
return jwt.decode(token, _secret(), algorithms=['HS256'])
|
||||
except jwt.ExpiredSignatureError:
|
||||
logger.debug('JWT decode failed: token expired')
|
||||
return None
|
||||
except jwt.InvalidTokenError as exc:
|
||||
logger.debug('JWT decode failed: %s', exc)
|
||||
return None
|
||||
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
app/api/notifications.py
|
||||
------------------------
|
||||
Mobile API endpoint for polling in-app notifications.
|
||||
|
||||
GET /api/v1/notifications
|
||||
Returns unread notifications for the authenticated user.
|
||||
Accepts ?since=<ISO 8601> to fetch only notifications created after
|
||||
that datetime — used by the iPad poller to avoid re-delivering already-
|
||||
seen alerts. Returns a maximum of 50 notifications per call.
|
||||
|
||||
PATCH /api/v1/notifications/mark-read
|
||||
Marks a list of notification IDs as read.
|
||||
Request JSON: { "ids": [1, 2, 3] }
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import sqlalchemy.exc
|
||||
from flask import Blueprint, request, g
|
||||
from app import db
|
||||
from app.models.notification import Notification
|
||||
from app.api.errors import api_ok, api_error
|
||||
from app.api.decorators import jwt_required
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('api_notifications', __name__)
|
||||
|
||||
|
||||
def _parse_since(value):
|
||||
"""Parse ?since= ISO 8601 string; return None on failure."""
|
||||
if not value:
|
||||
return None
|
||||
for fmt in ('%Y-%m-%dT%H:%M:%S', '%Y-%m-%dT%H:%M:%S.%f', '%Y-%m-%d'):
|
||||
try:
|
||||
return datetime.strptime(value, fmt)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _build_payload(notifications, has_event_type):
|
||||
"""Serialise notification rows to dicts. Works before and after phase17 migration."""
|
||||
rows = []
|
||||
for n in notifications:
|
||||
rows.append({
|
||||
'id': n.id,
|
||||
'title': n.title,
|
||||
'body': n.body,
|
||||
'event_type': (n.event_type if has_event_type else None),
|
||||
'issue_id': n.issue_id,
|
||||
'created_at': n.created_at.strftime('%Y-%m-%dT%H:%M:%S'),
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
@bp.route('/notifications', methods=['GET'])
|
||||
@jwt_required
|
||||
def list_notifications():
|
||||
"""
|
||||
Return unread notifications for the authenticated user.
|
||||
|
||||
Query parameters
|
||||
----------------
|
||||
since ISO 8601 datetime Only return notifications created after this time.
|
||||
limit int (default 50, max 50)
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{ "ok": true, "data": { "notifications": [...], "count": N } }
|
||||
"""
|
||||
user = g.api_user
|
||||
since = _parse_since(request.args.get('since'))
|
||||
limit = min(int(request.args.get('limit', 50)), 50)
|
||||
|
||||
def _run_orm():
|
||||
q = Notification.query.filter_by(user_id=user.id, is_read=False)
|
||||
if since:
|
||||
q = q.filter(Notification.created_at > since)
|
||||
return q.order_by(Notification.created_at.asc()).limit(limit).all()
|
||||
|
||||
# Attempt the ORM query (works after phase17 migration runs).
|
||||
# If the event_type column does not yet exist in the DB, MySQL raises
|
||||
# OperationalError: Unknown column 'notifications.event_type' in SELECT.
|
||||
# getattr() does NOT protect against this — the failure is at the SQL layer.
|
||||
# The fallback raw-SQL query selects only the pre-phase17 columns so the
|
||||
# endpoint stays functional before the migration runs.
|
||||
try:
|
||||
notifications = _run_orm()
|
||||
has_event_type = True
|
||||
except sqlalchemy.exc.OperationalError:
|
||||
db.session.rollback()
|
||||
sql_parts = (
|
||||
"SELECT id, title, body, issue_id, is_read, created_at "
|
||||
"FROM notifications "
|
||||
"WHERE user_id = :uid AND is_read = 0 "
|
||||
)
|
||||
params = {'uid': user.id, 'lim': limit}
|
||||
if since:
|
||||
sql_parts += "AND created_at > :since "
|
||||
params['since'] = since
|
||||
sql_parts += "ORDER BY created_at ASC LIMIT :lim"
|
||||
|
||||
rows = db.session.execute(db.text(sql_parts), params).fetchall()
|
||||
|
||||
class _Row:
|
||||
"""Minimal shim so _build_payload works with raw SQL rows."""
|
||||
__slots__ = ('id', 'title', 'body', 'issue_id', 'created_at')
|
||||
def __init__(self, r):
|
||||
self.id = r[0]
|
||||
self.title = r[1]
|
||||
self.body = r[2]
|
||||
self.issue_id = r[3]
|
||||
self.created_at = r[5]
|
||||
|
||||
notifications = [_Row(r) for r in rows]
|
||||
has_event_type = False
|
||||
|
||||
payload = _build_payload(notifications, has_event_type)
|
||||
|
||||
logger.info('API NOTIFICATIONS | list | user=%s | since=%s | count=%d | has_event_type=%s',
|
||||
user.username, since, len(payload), has_event_type)
|
||||
|
||||
return api_ok({'notifications': payload, 'count': len(payload)})
|
||||
|
||||
|
||||
@bp.route('/notifications/mark-read', methods=['PATCH'])
|
||||
@jwt_required
|
||||
def mark_read():
|
||||
"""
|
||||
Mark a list of notification IDs as read.
|
||||
|
||||
Request JSON: { "ids": [1, 2, 3] }
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{ "ok": true, "data": { "marked": 3 } }
|
||||
"""
|
||||
user = g.api_user
|
||||
data = request.get_json(silent=True) or {}
|
||||
ids = data.get('ids') or []
|
||||
|
||||
if not isinstance(ids, list):
|
||||
return api_error('ids must be a list', 400)
|
||||
|
||||
if ids:
|
||||
updated = (
|
||||
Notification.query
|
||||
.filter(
|
||||
Notification.id.in_(ids),
|
||||
Notification.user_id == user.id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for n in updated:
|
||||
n.is_read = True
|
||||
db.session.commit()
|
||||
count = len(updated)
|
||||
else:
|
||||
count = 0
|
||||
|
||||
logger.info('API NOTIFICATIONS | mark_read | user=%s | count=%d', user.username, count)
|
||||
return api_ok({'marked': count})
|
||||
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
app/api/photos.py
|
||||
-----------------
|
||||
Mobile API endpoint for uploading photos from the iPad app.
|
||||
|
||||
POST /api/v1/photos/upload
|
||||
Accepts a multipart/form-data upload.
|
||||
Saves the file to the server's upload folder.
|
||||
Returns the relative server path used in subsequent inspection/issue submissions.
|
||||
|
||||
The iPad app uploads photos BEFORE submitting the inspection or issue,
|
||||
then includes the returned server_path in the inspection/issue payload.
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, request, g, current_app
|
||||
from app.api.errors import api_ok, api_error
|
||||
from app.api.decorators import jwt_required
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('api_photos', __name__)
|
||||
|
||||
_ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'}
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
||||
|
||||
|
||||
def _allowed_file(filename: str) -> bool:
|
||||
return (
|
||||
'.' in filename
|
||||
and filename.rsplit('.', 1)[-1].lower() in _ALLOWED_EXTENSIONS
|
||||
)
|
||||
|
||||
|
||||
# ── Upload Photo ──────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/photos/upload', methods=['POST'])
|
||||
@jwt_required
|
||||
def upload_photo():
|
||||
"""
|
||||
Upload a photo from the iPad app.
|
||||
|
||||
Multipart form fields
|
||||
---------------------
|
||||
file — binary image data (jpg / png / gif)
|
||||
entity_type — "inspection" | "issue" (controls subfolder)
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"server_path": "uploads/inspection_photos/abc123.jpg"
|
||||
}
|
||||
}
|
||||
"""
|
||||
user = g.api_user
|
||||
|
||||
if user.role not in _ALLOWED_ROLES:
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
if 'file' not in request.files:
|
||||
return api_error('No file provided', 400)
|
||||
|
||||
file_obj = request.files['file']
|
||||
entity_type = request.form.get('entity_type', 'inspection')
|
||||
|
||||
if not file_obj or not file_obj.filename:
|
||||
return api_error('Empty file', 400)
|
||||
|
||||
if not _allowed_file(file_obj.filename):
|
||||
return api_error(
|
||||
f'File type not allowed. Accepted: {", ".join(sorted(_ALLOWED_EXTENSIONS))}',
|
||||
400
|
||||
)
|
||||
|
||||
# Determine destination subfolder
|
||||
if entity_type == 'issue':
|
||||
subfolder = 'issue_photos'
|
||||
else:
|
||||
subfolder = 'inspection_photos'
|
||||
|
||||
ext = file_obj.filename.rsplit('.', 1)[-1].lower()
|
||||
filename = f'{uuid.uuid4().hex}.{ext}'
|
||||
|
||||
dest_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], subfolder)
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
|
||||
dest_path = os.path.join(dest_dir, filename)
|
||||
file_obj.save(dest_path)
|
||||
|
||||
server_path = f'uploads/{subfolder}/{filename}'
|
||||
|
||||
logger.info('API PHOTOS | uploaded | entity_type=%s | path=%s | user=%s',
|
||||
entity_type, server_path, user.username)
|
||||
|
||||
return api_ok({'server_path': server_path})
|
||||
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
app/api/stats.py
|
||||
----------------
|
||||
Mobile API endpoint for dashboard statistics.
|
||||
|
||||
GET /api/v1/stats/dashboard
|
||||
Returns inspector-scoped counts used by the iPad dashboard card:
|
||||
- today_inspections : inspections started or completed today
|
||||
- completed_today : completed inspections today
|
||||
- open_issues : open + in_progress issues in contracted facilities
|
||||
- avg_score_30d : average overall_score (last 30 days, own inspections)
|
||||
- pending_followups : completed inspections with follow_up_required and no
|
||||
child re-inspection yet
|
||||
- sla_breached : open/in-progress issues past their SLA deadline
|
||||
- sla_at_risk : open/in-progress issues past 75% of SLA window
|
||||
|
||||
Admins and directors receive org-wide numbers (no facility scoping).
|
||||
Project managers receive unscoped numbers same as admin.
|
||||
Inspectors receive numbers scoped to their contracted facilities / own work.
|
||||
Customers are denied (403) — stats are for operational staff only.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from flask import Blueprint, g
|
||||
from sqlalchemy import func
|
||||
|
||||
from app import db
|
||||
from app.models.inspection import Inspection
|
||||
from app.models.issue import Issue
|
||||
from app.models.facility import Area
|
||||
from app.api.errors import api_ok, api_error
|
||||
from app.api.decorators import jwt_required
|
||||
from app.utils.scope import get_inspector_scope
|
||||
from app.utils.sla import sla_status
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('api_stats', __name__)
|
||||
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
||||
|
||||
|
||||
@bp.route('/stats/dashboard', methods=['GET'])
|
||||
@jwt_required
|
||||
def dashboard_stats():
|
||||
"""
|
||||
Return dashboard KPI counts for the authenticated user.
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"today_inspections": 3,
|
||||
"completed_today": 2,
|
||||
"open_issues": 7,
|
||||
"avg_score_30d": 84.5,
|
||||
"pending_followups": 1,
|
||||
"sla_breached": 2,
|
||||
"sla_at_risk": 1
|
||||
}
|
||||
}
|
||||
"""
|
||||
user = g.api_user
|
||||
|
||||
if user.role not in _ALLOWED_ROLES:
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
now = now_eastern()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_end = today_start + timedelta(days=1)
|
||||
thirty_days_ago = now - timedelta(days=30)
|
||||
|
||||
is_inspector = user.role == 'inspector'
|
||||
fids = get_inspector_scope(user) if is_inspector else None # None = no scoping
|
||||
|
||||
# ── Today's inspections ───────────────────────────────────────────────
|
||||
today_q = Inspection.query.filter(
|
||||
Inspection.inspection_date >= today_start,
|
||||
Inspection.inspection_date < today_end,
|
||||
)
|
||||
if is_inspector:
|
||||
if not fids:
|
||||
today_q = today_q.filter(False)
|
||||
else:
|
||||
today_q = today_q.filter(
|
||||
Inspection.facility_id.in_(fids),
|
||||
Inspection.inspector_id == user.id,
|
||||
)
|
||||
|
||||
today_inspections = today_q.count()
|
||||
|
||||
completed_today = today_q.filter(
|
||||
Inspection.status == 'completed'
|
||||
).count()
|
||||
|
||||
# ── Open issues ───────────────────────────────────────────────────────
|
||||
open_q = Issue.query.filter(Issue.status.in_(['open', 'in_progress']))
|
||||
if is_inspector:
|
||||
if not fids:
|
||||
open_q = open_q.filter(False)
|
||||
else:
|
||||
open_q = open_q.outerjoin(Area, Issue.area_id == Area.id).filter(
|
||||
db.or_(
|
||||
Issue.facility_id.in_(fids),
|
||||
db.and_(Issue.area_id.isnot(None), Area.facility_id.in_(fids)),
|
||||
)
|
||||
)
|
||||
|
||||
open_issues_all = open_q.all()
|
||||
open_issues = len(open_issues_all)
|
||||
|
||||
# ── Severity breakdown (derived from the same open_issues_all list) ───
|
||||
severity_breakdown = {
|
||||
'critical': sum(1 for i in open_issues_all if i.severity == 'critical'),
|
||||
'high': sum(1 for i in open_issues_all if i.severity == 'high'),
|
||||
'medium': sum(1 for i in open_issues_all if i.severity == 'medium'),
|
||||
'low': sum(1 for i in open_issues_all if i.severity == 'low'),
|
||||
}
|
||||
|
||||
# ── SLA counts (derived from the same open_issues_all list) ──────────
|
||||
sla_breached = sum(1 for i in open_issues_all if sla_status(i) == 'breached')
|
||||
sla_at_risk = sum(1 for i in open_issues_all if sla_status(i) == 'at_risk')
|
||||
|
||||
# ── Average score last 30 days ────────────────────────────────────────
|
||||
score_q = db.session.query(func.avg(Inspection.overall_score)).filter(
|
||||
Inspection.status == 'completed',
|
||||
Inspection.overall_score.isnot(None),
|
||||
Inspection.inspection_date >= thirty_days_ago,
|
||||
)
|
||||
if is_inspector:
|
||||
if not fids:
|
||||
score_q = score_q.filter(False)
|
||||
else:
|
||||
score_q = score_q.filter(
|
||||
Inspection.facility_id.in_(fids),
|
||||
Inspection.inspector_id == user.id,
|
||||
)
|
||||
raw_avg = score_q.scalar()
|
||||
avg_score = round(float(raw_avg), 1) if raw_avg is not None else None
|
||||
|
||||
# ── Pending follow-ups ────────────────────────────────────────────────
|
||||
# Completed inspections that still need a re-inspection and have none yet.
|
||||
followup_q = Inspection.query.filter_by(
|
||||
follow_up_required=True, status='completed'
|
||||
).filter(~Inspection.follow_ups.any())
|
||||
if is_inspector:
|
||||
if not fids:
|
||||
followup_q = followup_q.filter(False)
|
||||
else:
|
||||
followup_q = followup_q.filter(
|
||||
Inspection.facility_id.in_(fids),
|
||||
Inspection.inspector_id == user.id,
|
||||
)
|
||||
pending_followups = followup_q.count()
|
||||
|
||||
logger.info(
|
||||
'API STATS | dashboard | user=%s | role=%s | '
|
||||
'today=%d | open_issues=%d | avg=%.1f | followups=%d | sla_b=%d | sla_r=%d',
|
||||
user.username, user.role,
|
||||
today_inspections, open_issues,
|
||||
avg_score or 0.0,
|
||||
pending_followups, sla_breached, sla_at_risk,
|
||||
)
|
||||
|
||||
return api_ok({
|
||||
'today_inspections': today_inspections,
|
||||
'completed_today': completed_today,
|
||||
'open_issues': open_issues,
|
||||
'avg_score_30d': avg_score,
|
||||
'pending_followups': pending_followups,
|
||||
'sla_breached': sla_breached,
|
||||
'sla_at_risk': sla_at_risk,
|
||||
'severity_breakdown': severity_breakdown,
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
app/api/templates.py
|
||||
--------------------
|
||||
Mobile API endpoints for inspection templates.
|
||||
|
||||
GET /api/v1/templates
|
||||
Returns a lightweight list of all active inspection templates.
|
||||
Used by the iPad app to populate the template picker when starting an
|
||||
inspection.
|
||||
|
||||
GET /api/v1/templates/<template_id>
|
||||
Returns the full template including its form_schema JSON.
|
||||
The app caches this locally in SwiftData so inspections can be
|
||||
executed without a network connection.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, g
|
||||
from app import db
|
||||
from app.models.inspection import InspectionTemplate
|
||||
from app.api.errors import api_ok, api_error
|
||||
from app.api.decorators import jwt_required
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('api_templates', __name__)
|
||||
|
||||
# Customer role cannot access template data — inspectors and above only
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
||||
|
||||
|
||||
def _template_summary_payload(template: InspectionTemplate) -> dict:
|
||||
"""Serialize a template to the lightweight summary dict (no form_schema)."""
|
||||
return {
|
||||
'id': template.id,
|
||||
'name': template.name,
|
||||
'description': template.description or '',
|
||||
'frequency': template.frequency or '',
|
||||
'is_active': template.active,
|
||||
}
|
||||
|
||||
|
||||
def _template_full_payload(template: InspectionTemplate) -> dict:
|
||||
"""Serialize a template including its full form_schema."""
|
||||
return {
|
||||
'id': template.id,
|
||||
'name': template.name,
|
||||
'description': template.description or '',
|
||||
'frequency': template.frequency or '',
|
||||
'form_schema': template.get_form_schema(), # always returns a list
|
||||
}
|
||||
|
||||
|
||||
# ── Template List ─────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/templates', methods=['GET'])
|
||||
@jwt_required
|
||||
def list_templates():
|
||||
"""
|
||||
Return a lightweight list of all inspection templates.
|
||||
|
||||
Only internal staff roles (admin, director, inspector, project_manager)
|
||||
may access templates. Customer accounts are excluded.
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"templates": [
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Weekly Restroom Inspection",
|
||||
"description": "Standard weekly restroom checklist",
|
||||
"frequency": "weekly"
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
}
|
||||
"""
|
||||
user = g.api_user
|
||||
|
||||
if user.role not in _ALLOWED_ROLES:
|
||||
logger.warning('API TEMPLATES | access denied | user=%s | role=%s',
|
||||
user.username, user.role)
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
templates = (
|
||||
InspectionTemplate.query
|
||||
.filter_by(active=True)
|
||||
.order_by(InspectionTemplate.name)
|
||||
.all()
|
||||
)
|
||||
|
||||
payload = [_template_summary_payload(t) for t in templates]
|
||||
|
||||
logger.info('API TEMPLATES | list | user=%s | count=%d',
|
||||
user.username, len(payload))
|
||||
|
||||
return api_ok({'templates': payload, 'count': len(payload)})
|
||||
|
||||
|
||||
# ── Full Template (with form_schema) ─────────────────────────────────────────
|
||||
|
||||
@bp.route('/templates/<int:template_id>', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_template(template_id):
|
||||
"""
|
||||
Return a single template including its complete form_schema.
|
||||
|
||||
The iPad app calls this endpoint once per template and caches the
|
||||
result in SwiftData. Subsequent inspection executions use the cached
|
||||
schema without any network calls.
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"template": {
|
||||
"id": 3,
|
||||
"name": "Weekly Restroom Inspection",
|
||||
"description": "...",
|
||||
"frequency": "weekly",
|
||||
"form_schema": [
|
||||
{ "id": "f1", "type": "section", "label": "General Cleanliness" },
|
||||
{ "id": "f2", "type": "rating", "label": "Floor condition", "required": true }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
user = g.api_user
|
||||
|
||||
if user.role not in _ALLOWED_ROLES:
|
||||
logger.warning('API TEMPLATES | access denied | user=%s | role=%s',
|
||||
user.username, user.role)
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
template = db.session.get(InspectionTemplate, template_id)
|
||||
if template is None:
|
||||
return api_error('Template not found', 404)
|
||||
|
||||
logger.info('API TEMPLATES | detail | user=%s | template_id=%d | name=%s',
|
||||
user.username, template_id, template.name)
|
||||
|
||||
return api_ok({'template': _template_full_payload(template)})
|
||||
@@ -0,0 +1,8 @@
|
||||
from app.models.user import User
|
||||
from app.models.facility import Facility, Area
|
||||
from app.models.inspection import (InspectionTemplate, ChecklistItem,
|
||||
Inspection, InspectionResult)
|
||||
from app.models.issue import Issue
|
||||
from app.models.project import Project, CustomerAssignment
|
||||
from app.models.api_token import RefreshToken, DeviceToken
|
||||
from app.models.notification_matrix import NotificationMatrix
|
||||
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
app/models/api_token.py
|
||||
-----------------------
|
||||
Persistent storage for JWT refresh tokens and APNs device tokens.
|
||||
|
||||
RefreshToken
|
||||
One row per active mobile session. When the access token expires the
|
||||
app presents its refresh token here; a new access token is issued and
|
||||
the refresh token is rotated (old one deleted, new one inserted).
|
||||
Revocation is instant: delete the row.
|
||||
|
||||
DeviceToken
|
||||
One row per (user, device) pair. Stores the APNs token so the server
|
||||
can push notifications to the device. Updated on every app launch
|
||||
because APNs tokens can rotate.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from app import db
|
||||
from app.utils.time_utils import now_eastern
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
class RefreshToken(db.Model):
|
||||
"""
|
||||
Opaque refresh token stored server-side.
|
||||
|
||||
The token value itself is a 64-character hex string generated with
|
||||
secrets.token_hex(32). Only the SHA-256 hash is stored so that a DB
|
||||
breach does not expose live tokens.
|
||||
"""
|
||||
__tablename__ = 'api_refresh_tokens'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
# SHA-256 hex digest of the raw token — never store the raw value
|
||||
token_hash = db.Column(db.String(64), nullable=False, unique=True, index=True)
|
||||
# Device identifier supplied by the app (UIDevice.identifierForVendor)
|
||||
device_id = db.Column(db.String(64), nullable=True)
|
||||
device_name = db.Column(db.String(100), nullable=True) # e.g. "John's iPhone"
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=now_eastern)
|
||||
expires_at = db.Column(db.DateTime, nullable=False)
|
||||
revoked = db.Column(db.Boolean, nullable=False, default=False)
|
||||
|
||||
user = db.relationship('User', foreign_keys=[user_id],
|
||||
backref=db.backref('refresh_tokens', lazy='dynamic',
|
||||
cascade='all, delete-orphan'))
|
||||
|
||||
@classmethod
|
||||
def create_for(cls, user, device_id=None, device_name=None,
|
||||
lifetime_days=30):
|
||||
"""
|
||||
Generate a new refresh token, persist it, and return the raw token
|
||||
string (only time it is ever available in plaintext).
|
||||
"""
|
||||
import hashlib
|
||||
raw = secrets.token_hex(32) # 64-char hex, 256 bits entropy
|
||||
hashed = hashlib.sha256(raw.encode()).hexdigest()
|
||||
token = cls(
|
||||
user_id = user.id,
|
||||
token_hash = hashed,
|
||||
device_id = device_id,
|
||||
device_name = device_name,
|
||||
expires_at = now_eastern() + timedelta(days=lifetime_days),
|
||||
)
|
||||
db.session.add(token)
|
||||
return raw, token # caller must db.session.commit()
|
||||
|
||||
@classmethod
|
||||
def verify(cls, raw_token):
|
||||
"""
|
||||
Look up a refresh token by its raw value.
|
||||
Returns the RefreshToken row if valid and unexpired, else None.
|
||||
|
||||
Expired rows are not deleted here — passive cleanup runs in the
|
||||
login route (api/auth.py) each time a user authenticates, removing
|
||||
all expired/revoked tokens for that user. This keeps the table tidy
|
||||
without requiring a dedicated cron job.
|
||||
"""
|
||||
import hashlib
|
||||
hashed = hashlib.sha256(raw_token.encode()).hexdigest()
|
||||
row = cls.query.filter_by(token_hash=hashed, revoked=False).first()
|
||||
if row is None:
|
||||
return None
|
||||
if row.expires_at < now_eastern():
|
||||
return None
|
||||
return row
|
||||
|
||||
def revoke(self):
|
||||
self.revoked = True
|
||||
|
||||
def __repr__(self):
|
||||
return f'<RefreshToken user={self.user_id} device={self.device_id}>'
|
||||
|
||||
|
||||
class DeviceToken(db.Model):
|
||||
"""
|
||||
APNs device token for push notification delivery.
|
||||
|
||||
One row per (user, device_id) pair — upserted on every app launch.
|
||||
The apns_token is the hex string returned by the iOS SDK.
|
||||
"""
|
||||
__tablename__ = 'api_device_tokens'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
device_id = db.Column(db.String(64), nullable=False) # UIDevice.identifierForVendor
|
||||
apns_token = db.Column(db.String(200), nullable=False)
|
||||
device_name = db.Column(db.String(100), nullable=True)
|
||||
app_version = db.Column(db.String(20), nullable=True)
|
||||
registered_at = db.Column(db.DateTime, nullable=False, default=now_eastern)
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('user_id', 'device_id', name='uq_device_token_user_device'),
|
||||
)
|
||||
|
||||
user = db.relationship('User', foreign_keys=[user_id],
|
||||
backref=db.backref('device_tokens', lazy='dynamic',
|
||||
cascade='all, delete-orphan'))
|
||||
|
||||
def __repr__(self):
|
||||
return f'<DeviceToken user={self.user_id} device={self.device_id}>'
|
||||
@@ -0,0 +1,35 @@
|
||||
from app import db
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
|
||||
class AuditLog(db.Model):
|
||||
"""
|
||||
Persistent record of every create / edit / delete action performed by
|
||||
a user. Entries are immutable once written — never updated or deleted
|
||||
through the application.
|
||||
"""
|
||||
__tablename__ = 'audit_logs'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
# Who performed the action (NULL-safe: user may be deleted later)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||
username = db.Column(db.String(100), nullable=False) # snapshot at time of action
|
||||
user_role = db.Column(db.String(20), nullable=False) # snapshot at time of action
|
||||
# What happened
|
||||
action = db.Column(db.String(50), nullable=False, index=True) # CREATE / UPDATE / DELETE / LOGIN / LOGOUT / EXPORT
|
||||
entity_type = db.Column(db.String(50), nullable=False, index=True) # User / Facility / Area / Template / Inspection / Issue / …
|
||||
entity_id = db.Column(db.Integer, nullable=True) # PK of the affected record (NULL for bulk ops)
|
||||
entity_label = db.Column(db.String(255), nullable=True) # Human-readable identifier snapshot
|
||||
# Extra context stored as free-text (key=value pairs, comma-separated)
|
||||
details = db.Column(db.Text, nullable=True)
|
||||
# When
|
||||
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False, index=True)
|
||||
# Request context
|
||||
ip_address = db.Column(db.String(45), nullable=True) # supports IPv6
|
||||
|
||||
# Relationship — may be None if user was deleted
|
||||
user = db.relationship('User', foreign_keys=[user_id])
|
||||
|
||||
def __repr__(self):
|
||||
return (f'<AuditLog {self.id} {self.action} {self.entity_type}:{self.entity_id}'
|
||||
f' by {self.username}>')
|
||||
@@ -0,0 +1,28 @@
|
||||
# app/models/broadcast.py
|
||||
# -----------------------
|
||||
# Stores admin-sent broadcast notification records.
|
||||
# Each broadcast creates one Notification row per targeted user —
|
||||
# the iOS app receives them via its existing poll cycle
|
||||
# (GET /api/v1/notifications?since=...) with no new API endpoint required.
|
||||
|
||||
from app import db
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
|
||||
class Broadcast(db.Model):
|
||||
__tablename__ = 'broadcasts'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(255), nullable=False)
|
||||
body = db.Column(db.Text, nullable=False)
|
||||
# JSON-encoded list of role strings targeted, e.g. '["inspector","project_manager"]'
|
||||
target_roles = db.Column(db.JSON, nullable=False, default=list)
|
||||
sent_by_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||
sent_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
|
||||
# Number of Notification rows created (resolved at send time)
|
||||
recipient_count = db.Column(db.Integer, default=0, nullable=False)
|
||||
|
||||
sent_by = db.relationship('User', foreign_keys=[sent_by_id])
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Broadcast {self.id} "{self.title[:30]}" roles={self.target_roles}>'
|
||||
@@ -0,0 +1,39 @@
|
||||
from app import db
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
class Facility(db.Model):
|
||||
__tablename__ = 'facilities'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(255), nullable=False)
|
||||
address = db.Column(db.Text)
|
||||
contact_person = db.Column(db.String(100))
|
||||
contact_phone = db.Column(db.String(20))
|
||||
active = db.Column(db.Boolean, default=True)
|
||||
created_at = db.Column(db.DateTime, default=now_eastern, nullable=True)
|
||||
|
||||
# Phase 1: link facility to a project (nullable for backward compatibility)
|
||||
project_id = db.Column(db.Integer, db.ForeignKey('projects.id', ondelete='SET NULL'),
|
||||
nullable=True, index=True)
|
||||
|
||||
# Relationships
|
||||
areas = db.relationship('Area', backref='facility', lazy='dynamic')
|
||||
inspections = db.relationship('Inspection', backref='facility', lazy='dynamic')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Facility {self.name}>'
|
||||
|
||||
class Area(db.Model):
|
||||
__tablename__ = 'areas'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
facility_id = db.Column(db.Integer, db.ForeignKey('facilities.id'), nullable=False)
|
||||
name = db.Column(db.String(255), nullable=False)
|
||||
area_type = db.Column(db.String(50))
|
||||
|
||||
# Relationships
|
||||
inspections = db.relationship('Inspection', backref='area', lazy='dynamic')
|
||||
issues = db.relationship('Issue', backref='area', lazy='dynamic')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Area {self.name}>'
|
||||
@@ -0,0 +1,99 @@
|
||||
from app import db
|
||||
from app.utils.time_utils import now_eastern
|
||||
import json
|
||||
|
||||
|
||||
class InspectionTemplate(db.Model):
|
||||
__tablename__ = 'inspection_templates'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(255), nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
frequency = db.Column(db.Enum('daily', 'weekly', 'monthly', 'quarterly'))
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'))
|
||||
created_at = db.Column(db.DateTime, default=now_eastern)
|
||||
form_schema = db.Column(db.JSON, nullable=True)
|
||||
active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
|
||||
checklist_items = db.relationship('ChecklistItem', backref='template', lazy='dynamic', cascade='all, delete-orphan')
|
||||
inspections = db.relationship('Inspection', backref='template', lazy='dynamic')
|
||||
|
||||
def get_form_schema(self):
|
||||
if self.form_schema is None:
|
||||
return []
|
||||
if isinstance(self.form_schema, str):
|
||||
try:
|
||||
return json.loads(self.form_schema)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
return self.form_schema
|
||||
|
||||
def __repr__(self):
|
||||
return f'<InspectionTemplate {self.name}>'
|
||||
|
||||
|
||||
class ChecklistItem(db.Model):
|
||||
__tablename__ = 'checklist_items'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
template_id = db.Column(db.Integer, db.ForeignKey('inspection_templates.id'), nullable=False)
|
||||
category = db.Column(db.String(100))
|
||||
item_description = db.Column(db.Text, nullable=False)
|
||||
scoring_type = db.Column(db.Enum('pass_fail', 'rating_5', 'rating_10'))
|
||||
weight = db.Column(db.Numeric(3, 2), default=1.00)
|
||||
requires_photo = db.Column(db.Boolean, default=False)
|
||||
display_order = db.Column(db.Integer)
|
||||
|
||||
results = db.relationship('InspectionResult', backref='checklist_item', lazy='dynamic')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<ChecklistItem {self.item_description[:30]}>'
|
||||
|
||||
|
||||
class Inspection(db.Model):
|
||||
__tablename__ = 'inspections'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
template_id = db.Column(db.Integer, db.ForeignKey('inspection_templates.id'), nullable=False)
|
||||
facility_id = db.Column(db.Integer, db.ForeignKey('facilities.id'), nullable=False)
|
||||
area_id = db.Column(db.Integer, db.ForeignKey('areas.id'))
|
||||
inspector_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
inspection_date = db.Column(db.DateTime, nullable=False, default=now_eastern)
|
||||
overall_score = db.Column(db.Numeric(5, 2))
|
||||
status = db.Column(db.Enum('in_progress', 'completed', 'flagged'), default='in_progress')
|
||||
notes = db.Column(db.Text) # inspector free-text notes
|
||||
form_data = db.Column(db.JSON) # filled form field responses {field_id: value}
|
||||
completed_at = db.Column(db.DateTime)
|
||||
mobile_local_id = db.Column(db.String(64), nullable=True, index=True)
|
||||
submit_latitude = db.Column(db.Numeric(10, 7), nullable=True)
|
||||
submit_longitude = db.Column(db.Numeric(10, 7), nullable=True)
|
||||
|
||||
# ── Re-inspection / follow-up workflow ────────────────────────────────
|
||||
parent_inspection_id = db.Column(
|
||||
db.Integer, db.ForeignKey('inspections.id', ondelete='SET NULL'), nullable=True
|
||||
)
|
||||
follow_up_required = db.Column(db.Boolean, nullable=False, default=False)
|
||||
follow_up_note = db.Column(db.Text, nullable=True)
|
||||
|
||||
results = db.relationship('InspectionResult', backref='inspection', lazy='dynamic', cascade='all, delete-orphan')
|
||||
issues = db.relationship('Issue', backref='inspection', lazy='dynamic', cascade='all, delete-orphan')
|
||||
follow_ups = db.relationship('Inspection', backref=db.backref('parent', remote_side='Inspection.id'),
|
||||
lazy='dynamic', foreign_keys='Inspection.parent_inspection_id')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Inspection {self.id} - {self.inspection_date}>'
|
||||
|
||||
|
||||
class InspectionResult(db.Model):
|
||||
__tablename__ = 'inspection_results'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
inspection_id = db.Column(db.Integer, db.ForeignKey('inspections.id'), nullable=False)
|
||||
checklist_item_id = db.Column(db.Integer, db.ForeignKey('checklist_items.id'), nullable=False)
|
||||
score = db.Column(db.Numeric(5, 2))
|
||||
passed = db.Column(db.Boolean)
|
||||
comments = db.Column(db.Text)
|
||||
photo_path = db.Column(db.String(255))
|
||||
|
||||
def __repr__(self):
|
||||
return f'<InspectionResult {self.id}>'
|
||||
@@ -0,0 +1,25 @@
|
||||
from app import db
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
|
||||
class InspectorAssignment(db.Model):
|
||||
__tablename__ = 'inspector_assignments'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer,
|
||||
db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
project_id = db.Column(db.Integer,
|
||||
db.ForeignKey('projects.id', ondelete='CASCADE'),
|
||||
nullable=False)
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=now_eastern)
|
||||
|
||||
inspector = db.relationship('User', backref='inspector_assignments')
|
||||
project = db.relationship('Project', backref='inspector_assignments')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('user_id', 'project_id', name='uq_inspector_project'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<InspectorAssignment user={self.user_id} project={self.project_id}>'
|
||||
@@ -0,0 +1,118 @@
|
||||
from app import db
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
|
||||
class IssueComment(db.Model):
|
||||
__tablename__ = 'issue_comments'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
issue_id = db.Column(db.Integer, db.ForeignKey('issues.id'), nullable=False)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
status_at_time = db.Column(db.String(20)) # snapshot of issue status when comment was made
|
||||
body = db.Column(db.Text, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
|
||||
is_customer_visible = db.Column(db.Boolean, nullable=False, default=False)
|
||||
|
||||
# Relationships
|
||||
author = db.relationship('User', foreign_keys=[user_id])
|
||||
|
||||
def __repr__(self):
|
||||
return f'<IssueComment {self.id} issue={self.issue_id}>'
|
||||
|
||||
|
||||
# ── Issue Follower ─────────────────────────────────────────────────────────────
|
||||
# Association table linking users who opt in to receive notifications
|
||||
# for any updates on a specific issue.
|
||||
|
||||
class IssueFollower(db.Model):
|
||||
__tablename__ = 'issue_followers'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
issue_id = db.Column(db.Integer, db.ForeignKey('issues.id', ondelete='CASCADE'), nullable=False)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('issue_id', 'user_id', name='uq_issue_follower'),
|
||||
)
|
||||
|
||||
user = db.relationship('User', foreign_keys=[user_id])
|
||||
issue = db.relationship('Issue', foreign_keys=[issue_id], back_populates='followers')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<IssueFollower issue={self.issue_id} user={self.user_id}>'
|
||||
|
||||
|
||||
class Issue(db.Model):
|
||||
__tablename__ = 'issues'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
inspection_id = db.Column(db.Integer, db.ForeignKey('inspections.id'))
|
||||
area_id = db.Column(db.Integer, db.ForeignKey('areas.id'), nullable=True)
|
||||
facility_id = db.Column(db.Integer, db.ForeignKey('facilities.id'), nullable=True)
|
||||
severity = db.Column(db.Enum('low', 'medium', 'high', 'critical'), nullable=False)
|
||||
description = db.Column(db.Text, nullable=False)
|
||||
photo_path = db.Column(db.String(255))
|
||||
status = db.Column(db.Enum('open', 'in_progress', 'resolved', 'pending_verification'), default='open')
|
||||
assigned_to = db.Column(db.Integer, db.ForeignKey('users.id'))
|
||||
# Set at creation time to the user who filed the issue (inspector or admin).
|
||||
# Nullable for backward compatibility — pre-phase18 rows will be NULL.
|
||||
# Used by the mobile API to return issues the inspector created but hasn't
|
||||
# been assigned yet (assigned_to is NULL until a director assigns them).
|
||||
reported_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||
reported_at = db.Column(db.DateTime, default=now_eastern)
|
||||
resolved_at = db.Column(db.DateTime)
|
||||
result_notes = db.Column(db.Text)
|
||||
result_photos = db.Column(db.JSON) # list of relative paths e.g. ["uploads/issue_photos/abc.jpg"]
|
||||
# Extra evidence photos submitted from the iPad at issue-creation time.
|
||||
# Stored separately from result_photos (resolution photos added via web)
|
||||
# so they display under "Photo Evidence" rather than "Resolution Details".
|
||||
mobile_photo_paths = db.Column(db.JSON, nullable=True)
|
||||
|
||||
# ── Resolution verification ──────────────────────────────────────────
|
||||
verified_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||
verified_at = db.Column(db.DateTime, nullable=True)
|
||||
verification_note = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Tracks which SLA alert level has already been notified so cron runs
|
||||
# don't fire duplicate notifications. Values: None / 'at_risk' / 'breached'
|
||||
sla_notified = db.Column(db.String(10), nullable=True, default=None)
|
||||
mobile_local_id = db.Column(db.String(64), nullable=True, index=True) # idempotency key for mobile submissions
|
||||
|
||||
# External vendor / contractor assignment (phase26)
|
||||
vendor_name = db.Column(db.String(100), nullable=True)
|
||||
vendor_contact = db.Column(db.String(200), nullable=True) # phone or email
|
||||
vendor_notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Relationships
|
||||
# NOTE: Issue.area is provided by the backref on Area.issues (facility.py).
|
||||
# Do NOT add a second explicit db.relationship('Area') here — it conflicts
|
||||
# with that backref at mapper configuration time (CLAUDE.md rule 31 revised).
|
||||
facility = db.relationship('Facility', foreign_keys=[facility_id], backref='direct_issues')
|
||||
assigned_user = db.relationship('User', foreign_keys=[assigned_to], backref='assigned_issues')
|
||||
reporter = db.relationship('User', foreign_keys=[reported_by], backref='reported_issues')
|
||||
verifier = db.relationship('User', foreign_keys=[verified_by], backref='verified_issues')
|
||||
comments = db.relationship('IssueComment', backref='issue', lazy='dynamic',
|
||||
order_by='IssueComment.created_at',
|
||||
cascade='all, delete-orphan')
|
||||
followers = db.relationship('IssueFollower', back_populates='issue',
|
||||
cascade='all, delete-orphan', lazy='dynamic')
|
||||
|
||||
def is_followed_by(self, user):
|
||||
"""Return True if the given user is currently following this issue."""
|
||||
return self.followers.filter_by(user_id=user.id).first() is not None
|
||||
|
||||
@property
|
||||
def resolved_facility(self):
|
||||
"""Returns the Facility for this issue regardless of which path was used to create it.
|
||||
Issues created via the standalone form have facility_id set directly.
|
||||
Issues created via flag_issue (from an inspection) have area_id set.
|
||||
"""
|
||||
if self.facility:
|
||||
return self.facility
|
||||
if self.area:
|
||||
return self.area.facility
|
||||
return None
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Issue {self.id} - {self.severity}>'
|
||||
@@ -0,0 +1,108 @@
|
||||
from app import db
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
|
||||
# ── Event type constants ───────────────────────────────────────────────────────
|
||||
# These are the canonical keys used across the preference system.
|
||||
# Every call to notify() should pass one of these as event_type.
|
||||
|
||||
EVENT_ISSUE_ASSIGNED = 'issue_assigned'
|
||||
EVENT_ISSUE_STATUS = 'issue_status'
|
||||
EVENT_ISSUE_COMMENT = 'issue_comment'
|
||||
EVENT_ISSUE_FOLLOW = 'issue_follow_update'
|
||||
EVENT_INSPECTION_DONE = 'inspection_completed'
|
||||
EVENT_SLA_ALERT = 'sla_alert'
|
||||
# Fired when an issue is flagged during an inspection (web or mobile).
|
||||
# Listed here so users can configure email preferences for this event.
|
||||
EVENT_ISSUE_FLAGGED = 'issue_flagged'
|
||||
|
||||
# ── Customer portal events ─────────────────────────────────────────────────
|
||||
# Fired when an inspection completes or an issue is created/updated at a
|
||||
# facility the customer is assigned to. Separate constants allow customers
|
||||
# to manage these preferences independently from internal staff events.
|
||||
EVENT_CUSTOMER_INSPECTION_DONE = 'customer_inspection_completed'
|
||||
EVENT_CUSTOMER_ISSUE_UPDATED = 'customer_issue_updated'
|
||||
|
||||
# Fired by the score-trend cron when a facility's rolling avg drops by
|
||||
# more than the configured threshold vs. the prior period.
|
||||
EVENT_SCORE_ALERT = 'score_alert'
|
||||
|
||||
EVENT_ADMIN_BROADCAST = 'admin_broadcast' # bulk messages sent by admin to all apps
|
||||
|
||||
ALL_EVENT_TYPES = {
|
||||
EVENT_ISSUE_ASSIGNED: 'Issue assigned to me',
|
||||
EVENT_ISSUE_STATUS: 'Issue status changed',
|
||||
EVENT_ISSUE_COMMENT: 'New comment on issue',
|
||||
EVENT_ISSUE_FOLLOW: 'Updates on followed issues',
|
||||
EVENT_ISSUE_FLAGGED: 'Issue flagged (from inspection)',
|
||||
EVENT_INSPECTION_DONE: 'Inspection completed',
|
||||
EVENT_SLA_ALERT: 'SLA at-risk / breached alerts',
|
||||
EVENT_ADMIN_BROADCAST: 'Admin broadcast (system announcements)',
|
||||
# Customer-facing — only relevant for customer role accounts
|
||||
EVENT_CUSTOMER_INSPECTION_DONE: 'Inspection completed at my facility (portal)',
|
||||
EVENT_CUSTOMER_ISSUE_UPDATED: 'Issue created or updated at my facility (portal)',
|
||||
# Score trend alert — admin/director management use
|
||||
EVENT_SCORE_ALERT: 'Facility score trend alert (significant drop detected)',
|
||||
}
|
||||
|
||||
|
||||
class Notification(db.Model):
|
||||
"""Stores in-app notifications for users.
|
||||
|
||||
Each notification is tied to a single recipient and optionally linked to
|
||||
either an Issue or an Inspection so the UI can build a direct link.
|
||||
"""
|
||||
__tablename__ = 'notifications'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
|
||||
title = db.Column(db.String(255), nullable=False)
|
||||
body = db.Column(db.Text, nullable=False)
|
||||
link = db.Column(db.String(512))
|
||||
is_read = db.Column(db.Boolean, default=False, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
|
||||
|
||||
# Optional FK references — only one will be populated at a time
|
||||
issue_id = db.Column(db.Integer, db.ForeignKey('issues.id', ondelete='CASCADE'), nullable=True)
|
||||
inspection_id = db.Column(db.Integer, db.ForeignKey('inspections.id', ondelete='CASCADE'), nullable=True)
|
||||
|
||||
# Event type — stored for mobile API polling so the iPad can categorise alerts.
|
||||
# Added phase17; NULL for notifications created before the migration.
|
||||
event_type = db.Column(db.String(50), nullable=True)
|
||||
|
||||
# Digest tracking: set to True when created, cleared after digest email sent
|
||||
digest_pending = db.Column(db.Boolean, default=False, nullable=False, index=True)
|
||||
|
||||
recipient = db.relationship('User', foreign_keys=[user_id], backref='notifications')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Notification {self.id} user={self.user_id} read={self.is_read}>'
|
||||
|
||||
|
||||
class NotificationPreference(db.Model):
|
||||
"""Per-user, per-event notification preferences.
|
||||
|
||||
One row per (user_id, event_type) combination.
|
||||
If no row exists for a user+event, defaults apply (email on, no digest).
|
||||
"""
|
||||
__tablename__ = 'notification_preferences'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
event_type = db.Column(db.String(50), nullable=False)
|
||||
email_enabled = db.Column(db.Boolean, default=True, nullable=False)
|
||||
digest_mode = db.Column(db.Boolean, default=False, nullable=False)
|
||||
# digest_frequency: 'hourly' or 'daily' — only relevant when digest_mode is True
|
||||
digest_frequency = db.Column(db.String(10), default='daily', nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('user_id', 'event_type', name='uq_notif_pref_user_event'),
|
||||
)
|
||||
|
||||
user = db.relationship('User', foreign_keys=[user_id],
|
||||
backref=db.backref('notification_preferences', lazy='dynamic'))
|
||||
|
||||
def __repr__(self):
|
||||
return (f'<NotificationPreference user={self.user_id} '
|
||||
f'event={self.event_type} email={self.email_enabled} digest={self.digest_mode}>')
|
||||
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
app/models/notification_matrix.py
|
||||
----------------------------------
|
||||
Admin-controlled notification matrix.
|
||||
|
||||
One row per (event_type, role_key) pair.
|
||||
|
||||
role_key values
|
||||
---------------
|
||||
admin — all users with role='admin'
|
||||
director — all users with role='director'
|
||||
inspector — all users with role='inspector'
|
||||
project_manager — all users with role='project_manager'
|
||||
customer — all customer-portal users assigned to the relevant facility
|
||||
assignee — the specific user the issue/inspection is assigned to
|
||||
(implicit; always notified regardless of matrix)
|
||||
custom — free-form extra email addresses stored in custom_emails JSON
|
||||
|
||||
Default matrix (mirrors current hardcoded behaviour)
|
||||
-----------------------------------------------------
|
||||
inspection_completed : admin ✓ director ✓ inspector ✗ pm ✗ customer ✓
|
||||
issue_assigned : admin ✗ director ✗ inspector ✗ pm ✗ customer ✗ (assignee implicit)
|
||||
issue_status : admin ✗ director ✗ inspector ✗ pm ✗ customer ✗ (assignee implicit)
|
||||
issue_comment : admin ✗ director ✗ inspector ✗ pm ✗ customer ✗ (assignee implicit)
|
||||
issue_follow_update : admin ✗ director ✗ inspector ✗ pm ✗ customer ✗ (followers implicit)
|
||||
issue_flagged : admin ✓ director ✓ inspector ✗ pm ✗ customer ✓ (assignee implicit)
|
||||
issue_created : admin ✗ director ✗ inspector ✗ pm ✗ customer ✓ (assignee implicit)
|
||||
issue_updated_customer : admin ✗ director ✗ inspector ✗ pm ✗ customer ✓
|
||||
verification_requested : admin ✓ director ✓ inspector ✗ pm ✗ customer ✗
|
||||
sla_alert : admin ✓ director ✗ inspector ✗ pm ✗ customer ✗ (assignee + followers implicit)
|
||||
score_alert : admin ✓ director ✓ inspector ✗ pm ✗ customer ✗ (facility score drop cron)
|
||||
"""
|
||||
|
||||
import json
|
||||
from app import db
|
||||
|
||||
# Role keys available in the matrix UI
|
||||
MATRIX_ROLES = [
|
||||
('admin', 'Admin'),
|
||||
('director', 'Director'),
|
||||
('inspector', 'Inspector'),
|
||||
('project_manager', 'Project Manager'),
|
||||
('customer', 'Customer'),
|
||||
('custom', 'Custom Recipients'),
|
||||
]
|
||||
|
||||
# Events shown in the matrix — maps event_key → display label
|
||||
# event_key is used as the DB event_type value
|
||||
MATRIX_EVENTS = {
|
||||
'inspection_completed': 'Inspection completed',
|
||||
'issue_assigned': 'Issue assigned (new)',
|
||||
'issue_reassigned': 'Issue reassigned',
|
||||
'issue_unassigned': 'Issue unassigned',
|
||||
'issue_status': 'Issue status changed',
|
||||
'issue_comment': 'Issue comment added',
|
||||
'issue_follow_update': 'Issue follow update',
|
||||
'issue_flagged': 'Issue flagged (from inspection)',
|
||||
'issue_created': 'Issue created (standalone)',
|
||||
'issue_updated_customer': 'Issue updated (customer)',
|
||||
'verification_requested': 'Verification requested',
|
||||
'sla_alert': 'SLA at-risk / breached',
|
||||
'score_alert': 'Facility score trend alert (significant drop)',
|
||||
}
|
||||
|
||||
# Default enabled state: (event_key, role_key) → True/False
|
||||
# Mirrors the current hardcoded behaviour exactly.
|
||||
MATRIX_DEFAULTS = {
|
||||
# inspection_completed
|
||||
('inspection_completed', 'admin'): True,
|
||||
('inspection_completed', 'director'): True,
|
||||
('inspection_completed', 'inspector'): False,
|
||||
('inspection_completed', 'project_manager'): False,
|
||||
('inspection_completed', 'customer'): True,
|
||||
('inspection_completed', 'custom'): False,
|
||||
# issue_assigned (assignee is always notified implicitly)
|
||||
('issue_assigned', 'admin'): False,
|
||||
('issue_assigned', 'director'): False,
|
||||
('issue_assigned', 'inspector'): False,
|
||||
('issue_assigned', 'project_manager'): False,
|
||||
('issue_assigned', 'customer'): False,
|
||||
('issue_assigned', 'custom'): False,
|
||||
# issue_reassigned
|
||||
('issue_reassigned', 'admin'): False,
|
||||
('issue_reassigned', 'director'): False,
|
||||
('issue_reassigned', 'inspector'): False,
|
||||
('issue_reassigned', 'project_manager'): False,
|
||||
('issue_reassigned', 'customer'): False,
|
||||
('issue_reassigned', 'custom'): False,
|
||||
# issue_unassigned
|
||||
('issue_unassigned', 'admin'): False,
|
||||
('issue_unassigned', 'director'): False,
|
||||
('issue_unassigned', 'inspector'): False,
|
||||
('issue_unassigned', 'project_manager'): False,
|
||||
('issue_unassigned', 'customer'): False,
|
||||
('issue_unassigned', 'custom'): False,
|
||||
# issue_status
|
||||
('issue_status', 'admin'): False,
|
||||
('issue_status', 'director'): False,
|
||||
('issue_status', 'inspector'): False,
|
||||
('issue_status', 'project_manager'): False,
|
||||
('issue_status', 'customer'): False,
|
||||
('issue_status', 'custom'): False,
|
||||
# issue_comment
|
||||
('issue_comment', 'admin'): False,
|
||||
('issue_comment', 'director'): False,
|
||||
('issue_comment', 'inspector'): False,
|
||||
('issue_comment', 'project_manager'): False,
|
||||
('issue_comment', 'customer'): False,
|
||||
('issue_comment', 'custom'): False,
|
||||
# issue_follow_update (followers always notified implicitly)
|
||||
('issue_follow_update', 'admin'): False,
|
||||
('issue_follow_update', 'director'): False,
|
||||
('issue_follow_update', 'inspector'): False,
|
||||
('issue_follow_update', 'project_manager'): False,
|
||||
('issue_follow_update', 'customer'): False,
|
||||
('issue_follow_update', 'custom'): False,
|
||||
# issue_flagged (from inspection)
|
||||
('issue_flagged', 'admin'): True,
|
||||
('issue_flagged', 'director'): True,
|
||||
('issue_flagged', 'inspector'): False,
|
||||
('issue_flagged', 'project_manager'): False,
|
||||
('issue_flagged', 'customer'): True,
|
||||
('issue_flagged', 'custom'): False,
|
||||
# issue_created (standalone)
|
||||
('issue_created', 'admin'): True,
|
||||
('issue_created', 'director'): True,
|
||||
('issue_created', 'inspector'): False,
|
||||
('issue_created', 'project_manager'): False,
|
||||
('issue_created', 'customer'): True,
|
||||
('issue_created', 'custom'): False,
|
||||
# issue_updated_customer
|
||||
('issue_updated_customer', 'admin'): False,
|
||||
('issue_updated_customer', 'director'): False,
|
||||
('issue_updated_customer', 'inspector'): False,
|
||||
('issue_updated_customer', 'project_manager'): False,
|
||||
('issue_updated_customer', 'customer'): True,
|
||||
('issue_updated_customer', 'custom'): False,
|
||||
# verification_requested
|
||||
('verification_requested', 'admin'): True,
|
||||
('verification_requested', 'director'): True,
|
||||
('verification_requested', 'inspector'): False,
|
||||
('verification_requested', 'project_manager'): False,
|
||||
('verification_requested', 'customer'): False,
|
||||
('verification_requested', 'custom'): False,
|
||||
# sla_alert (assignee + followers always notified implicitly)
|
||||
('sla_alert', 'admin'): True,
|
||||
('sla_alert', 'director'): False,
|
||||
('sla_alert', 'inspector'): False,
|
||||
('sla_alert', 'project_manager'): False,
|
||||
('sla_alert', 'customer'): False,
|
||||
('sla_alert', 'custom'): False,
|
||||
# score_alert — facility rolling-avg score drop detected by cron
|
||||
('score_alert', 'admin'): True,
|
||||
('score_alert', 'director'): True,
|
||||
('score_alert', 'inspector'): False,
|
||||
('score_alert', 'project_manager'): False,
|
||||
('score_alert', 'customer'): False,
|
||||
('score_alert', 'custom'): False,
|
||||
}
|
||||
|
||||
|
||||
class NotificationMatrix(db.Model):
|
||||
"""Admin-controlled per-event notification routing."""
|
||||
|
||||
__tablename__ = 'notification_matrix'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
event_type = db.Column(db.String(50), nullable=False)
|
||||
role_key = db.Column(db.String(30), nullable=False)
|
||||
enabled = db.Column(db.Boolean, nullable=False, default=True)
|
||||
custom_emails = db.Column(db.Text, nullable=True) # JSON list, only used when role_key='custom'
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('event_type', 'role_key', name='uq_notif_matrix_event_role'),
|
||||
)
|
||||
|
||||
def get_custom_emails(self):
|
||||
"""Return custom_emails as a Python list."""
|
||||
if not self.custom_emails:
|
||||
return []
|
||||
try:
|
||||
result = json.loads(self.custom_emails)
|
||||
return [e.strip() for e in result if isinstance(e, str) and e.strip()]
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
|
||||
def __repr__(self):
|
||||
return f'<NotificationMatrix {self.event_type} / {self.role_key} enabled={self.enabled}>'
|
||||
|
||||
|
||||
def get_matrix_row(event_type: str, role_key: str) -> NotificationMatrix:
|
||||
"""
|
||||
Return the matrix row for (event_type, role_key), creating it from
|
||||
defaults if it doesn't exist yet. Safe to call without seeding.
|
||||
"""
|
||||
row = NotificationMatrix.query.filter_by(
|
||||
event_type=event_type, role_key=role_key
|
||||
).first()
|
||||
if row is None:
|
||||
default = MATRIX_DEFAULTS.get((event_type, role_key), False)
|
||||
row = NotificationMatrix(
|
||||
event_type=event_type,
|
||||
role_key=role_key,
|
||||
enabled=default,
|
||||
)
|
||||
db.session.add(row)
|
||||
db.session.flush()
|
||||
return row
|
||||
|
||||
|
||||
def is_enabled(event_type: str, role_key: str) -> bool:
|
||||
"""Return True if the matrix enables notifications for this event/role pair."""
|
||||
row = NotificationMatrix.query.filter_by(
|
||||
event_type=event_type, role_key=role_key
|
||||
).first()
|
||||
if row is None:
|
||||
return MATRIX_DEFAULTS.get((event_type, role_key), False)
|
||||
return row.enabled
|
||||
|
||||
|
||||
def get_custom_emails_for(event_type: str) -> list:
|
||||
"""Return the custom email list for this event type."""
|
||||
row = NotificationMatrix.query.filter_by(
|
||||
event_type=event_type, role_key='custom'
|
||||
).first()
|
||||
if row is None:
|
||||
return []
|
||||
return row.get_custom_emails()
|
||||
@@ -0,0 +1,63 @@
|
||||
from app import db
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
|
||||
class Project(db.Model):
|
||||
"""Top-level grouping that contains one or more Facilities.
|
||||
|
||||
Each project may have an assigned project_manager (User) and one or more
|
||||
customer users linked via CustomerAssignment.
|
||||
"""
|
||||
__tablename__ = 'projects'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(255), nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
project_manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
|
||||
|
||||
# Relationships
|
||||
project_manager = db.relationship('User', foreign_keys=[project_manager_id],
|
||||
backref='managed_projects')
|
||||
facilities = db.relationship('Facility', backref='project', lazy='dynamic')
|
||||
customer_assignments = db.relationship('CustomerAssignment', back_populates='project',
|
||||
cascade='all, delete-orphan', lazy='dynamic')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Project {self.name}>'
|
||||
|
||||
|
||||
class CustomerAssignment(db.Model):
|
||||
"""Links a customer-role User to a Project and/or a specific Facility.
|
||||
|
||||
- project_id only → customer can view all facilities within that project
|
||||
- project_id + facility_id → customer is scoped to that specific facility
|
||||
"""
|
||||
__tablename__ = 'customer_assignments'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
project_id = db.Column(db.Integer, db.ForeignKey('projects.id', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
facility_id = db.Column(db.Integer, db.ForeignKey('facilities.id', ondelete='CASCADE'),
|
||||
nullable=True, index=True)
|
||||
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('user_id', 'project_id', 'facility_id',
|
||||
name='uq_customer_assignment'),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
user = db.relationship('User', foreign_keys=[user_id],
|
||||
backref=db.backref('customer_assignments', lazy='dynamic'))
|
||||
project = db.relationship('Project', foreign_keys=[project_id],
|
||||
back_populates='customer_assignments')
|
||||
facility = db.relationship('Facility', foreign_keys=[facility_id],
|
||||
backref=db.backref('customer_assignments', lazy='dynamic'))
|
||||
|
||||
def __repr__(self):
|
||||
return (f'<CustomerAssignment user={self.user_id} '
|
||||
f'project={self.project_id} facility={self.facility_id}>')
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
app/models/scheduled_report.py
|
||||
-------------------------------
|
||||
Stores the configuration for automated scheduled report emails.
|
||||
"""
|
||||
|
||||
from app import db
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
|
||||
class ScheduledReport(db.Model):
|
||||
"""Configuration record for a recurring emailed report.
|
||||
|
||||
report_type options:
|
||||
summary — overall KPI digest (inspections + issues)
|
||||
facility — single-facility scorecard
|
||||
issues — open/in-progress issues list
|
||||
|
||||
frequency options: daily | weekly | monthly
|
||||
|
||||
recipients: JSON list of email address strings, e.g.
|
||||
["manager@acme.com", "client@acme.com"]
|
||||
|
||||
include_pdf / include_csv: attach respective exports to the email.
|
||||
"""
|
||||
__tablename__ = 'scheduled_reports'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(255), nullable=False)
|
||||
report_type = db.Column(
|
||||
db.Enum('summary', 'facility', 'issues'),
|
||||
nullable=False, default='summary'
|
||||
)
|
||||
frequency = db.Column(
|
||||
db.Enum('daily', 'weekly', 'monthly'),
|
||||
nullable=False
|
||||
)
|
||||
facility_id = db.Column(
|
||||
db.Integer, db.ForeignKey('facilities.id', ondelete='SET NULL'),
|
||||
nullable=True
|
||||
)
|
||||
recipients = db.Column(db.JSON, nullable=False, default=list)
|
||||
include_pdf = db.Column(db.Boolean, nullable=False, default=False)
|
||||
include_csv = db.Column(db.Boolean, nullable=False, default=False)
|
||||
active = db.Column(db.Boolean, nullable=False, default=True)
|
||||
created_by = db.Column(
|
||||
db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'),
|
||||
nullable=True
|
||||
)
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=now_eastern)
|
||||
last_sent_at = db.Column(db.DateTime, nullable=True)
|
||||
next_send_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
facility = db.relationship('Facility', foreign_keys=[facility_id])
|
||||
creator = db.relationship('User', foreign_keys=[created_by])
|
||||
|
||||
def recipient_list(self):
|
||||
"""Return recipients as a Python list (safe even if stored as string)."""
|
||||
if isinstance(self.recipients, list):
|
||||
return self.recipients
|
||||
import json
|
||||
try:
|
||||
return json.loads(self.recipients)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def __repr__(self):
|
||||
return f'<ScheduledReport {self.id} {self.name!r} {self.frequency}>'
|
||||
@@ -0,0 +1,24 @@
|
||||
from app import db
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
|
||||
class FacilityScoreAlert(db.Model):
|
||||
"""Records each score-trend alert sent for a facility.
|
||||
|
||||
Used by send_score_alerts() to deduplicate cron notifications:
|
||||
if an alert row exists for a facility within the last 24 hours,
|
||||
no new alert is sent even if the score is still below threshold.
|
||||
"""
|
||||
__tablename__ = 'facility_score_alerts'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
facility_id = db.Column(db.Integer, db.ForeignKey('facilities.id', ondelete='CASCADE'), nullable=False)
|
||||
sent_at = db.Column(db.DateTime, nullable=False, default=now_eastern)
|
||||
current_avg = db.Column(db.Numeric(5, 2), nullable=False)
|
||||
prior_avg = db.Column(db.Numeric(5, 2), nullable=False)
|
||||
delta = db.Column(db.Numeric(5, 2), nullable=False)
|
||||
|
||||
facility = db.relationship('Facility', foreign_keys=[facility_id])
|
||||
|
||||
def __repr__(self):
|
||||
return f'<FacilityScoreAlert facility={self.facility_id} delta={self.delta} sent={self.sent_at}>'
|
||||
@@ -0,0 +1,41 @@
|
||||
from app import db
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
|
||||
class SupportTicket(db.Model):
|
||||
__tablename__ = 'support_tickets'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
customer_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||
facility_id = db.Column(db.Integer, db.ForeignKey('facilities.id', ondelete='SET NULL'), nullable=True)
|
||||
subject = db.Column(db.String(200), nullable=False)
|
||||
body = db.Column(db.Text, nullable=False)
|
||||
status = db.Column(db.String(20), nullable=False, default='open') # open / answered / closed
|
||||
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
|
||||
|
||||
customer = db.relationship('User', foreign_keys=[customer_id], backref='support_tickets')
|
||||
facility = db.relationship('Facility', foreign_keys=[facility_id], backref='support_tickets')
|
||||
replies = db.relationship(
|
||||
'SupportTicketReply', backref='ticket',
|
||||
cascade='all, delete-orphan',
|
||||
order_by='SupportTicketReply.created_at',
|
||||
lazy='dynamic',
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<SupportTicket {self.id} [{self.status}]>'
|
||||
|
||||
|
||||
class SupportTicketReply(db.Model):
|
||||
__tablename__ = 'support_ticket_replies'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
ticket_id = db.Column(db.Integer, db.ForeignKey('support_tickets.id', ondelete='CASCADE'), nullable=False)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||
body = db.Column(db.Text, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
|
||||
|
||||
author = db.relationship('User', foreign_keys=[user_id])
|
||||
|
||||
def __repr__(self):
|
||||
return f'<SupportTicketReply {self.id} ticket={self.ticket_id}>'
|
||||
@@ -0,0 +1,100 @@
|
||||
from app import db, login_manager
|
||||
from flask_login import UserMixin
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
from app import db
|
||||
return db.session.get(User, int(user_id))
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
__tablename__ = 'users'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(100), unique=True, nullable=False, index=True)
|
||||
full_name = db.Column(db.String(150), nullable=True)
|
||||
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
||||
password_hash = db.Column(db.String(255), nullable=False)
|
||||
role = db.Column(
|
||||
# Phase 11 migration complete — 'supervisor' removed from both the DB
|
||||
# ENUM and this Python-side declaration. Director is the canonical role.
|
||||
db.Enum('admin', 'director', 'inspector', 'project_manager', 'customer'),
|
||||
nullable=False
|
||||
)
|
||||
created_at = db.Column(db.DateTime, default=now_eastern)
|
||||
active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
|
||||
# ── Customer password-setup workflow ──────────────────────────────────
|
||||
# password_set: False for newly created customer accounts until they
|
||||
# complete the set-password flow via emailed link.
|
||||
# Always True for internal users created via UserForm.
|
||||
password_set = db.Column(db.Boolean, nullable=False, default=True)
|
||||
set_password_token = db.Column(db.String(64), nullable=True, index=True)
|
||||
set_password_token_expires = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
inspections = db.relationship('Inspection', backref='inspector', lazy='dynamic')
|
||||
|
||||
# ── Flask-Login integration ────────────────────────────────────────────
|
||||
# Override UserMixin.is_active so that disabled accounts are rejected
|
||||
# automatically by login_required and login_user() without any extra code.
|
||||
@property
|
||||
def is_active(self):
|
||||
return self.active
|
||||
|
||||
def set_password(self, password):
|
||||
self.password_hash = generate_password_hash(password)
|
||||
|
||||
def check_password(self, password):
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
"""Return full name if set, otherwise fall back to username."""
|
||||
return self.full_name.strip() if self.full_name and self.full_name.strip() else self.username
|
||||
|
||||
def generate_set_password_token(self, expires_hours=72):
|
||||
"""Create a one-time set-password token valid for `expires_hours` hours."""
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
self.set_password_token = secrets.token_hex(32) # 64 hex chars
|
||||
self.set_password_token_expires = now_eastern() + timedelta(hours=expires_hours)
|
||||
return self.set_password_token
|
||||
|
||||
def clear_set_password_token(self):
|
||||
"""Invalidate the token after use."""
|
||||
self.set_password_token = None
|
||||
self.set_password_token_expires = None
|
||||
|
||||
@staticmethod
|
||||
def verify_set_password_token(token):
|
||||
"""Return the User whose token matches, or None if invalid/expired.
|
||||
|
||||
The final token comparison uses hmac.compare_digest so that the
|
||||
comparison runs in constant time regardless of how many characters
|
||||
match, preventing timing-based token enumeration attacks.
|
||||
"""
|
||||
import hmac
|
||||
if not token:
|
||||
return None
|
||||
# Primary lookup is via DB index — compare_digest is a defense-in-depth
|
||||
# guard applied after the row is retrieved to harden the string comparison.
|
||||
user = User.query.filter_by(set_password_token=token).first()
|
||||
if user is None:
|
||||
return None
|
||||
if user.set_password_token_expires is None:
|
||||
return None
|
||||
if now_eastern() > user.set_password_token_expires:
|
||||
return None
|
||||
# Constant-time comparison — prevents timing oracle on the stored token.
|
||||
# Wrapped in try/except to guard against unexpected type mismatches.
|
||||
try:
|
||||
if not hmac.compare_digest(user.set_password_token, token):
|
||||
return None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return user
|
||||
|
||||
def __repr__(self):
|
||||
return f'<User {self.username}>'
|
||||
@@ -0,0 +1,150 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, abort
|
||||
from flask_login import login_required
|
||||
from app import db
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.user import User
|
||||
from app.utils.decorators import admin_required
|
||||
from app.utils.audit import log_action, ACTION_DELETE
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
bp = Blueprint('audit', __name__, url_prefix='/audit')
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── List (paginated, filterable) ──────────────────────────────────────────────
|
||||
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
@admin_required
|
||||
def index():
|
||||
page = request.args.get('page', 1, type=int)
|
||||
|
||||
# ── Filter params ─────────────────────────────────────────────────────
|
||||
filter_user = request.args.get('user_id', '', type=str)
|
||||
filter_action = request.args.get('action', '', type=str)
|
||||
filter_entity_type = request.args.get('entity_type', '', type=str)
|
||||
filter_date_from = request.args.get('date_from', '', type=str)
|
||||
filter_date_to = request.args.get('date_to', '', type=str)
|
||||
|
||||
q = AuditLog.query.order_by(AuditLog.created_at.desc())
|
||||
|
||||
if filter_user.isdigit():
|
||||
q = q.filter(AuditLog.user_id == int(filter_user))
|
||||
if filter_action:
|
||||
q = q.filter(AuditLog.action == filter_action)
|
||||
if filter_entity_type:
|
||||
q = q.filter(AuditLog.entity_type == filter_entity_type)
|
||||
if filter_date_from:
|
||||
try:
|
||||
from datetime import datetime
|
||||
q = q.filter(AuditLog.created_at >= datetime.strptime(filter_date_from, '%Y-%m-%d'))
|
||||
except ValueError:
|
||||
pass
|
||||
if filter_date_to:
|
||||
try:
|
||||
from datetime import datetime, timedelta
|
||||
# Include the full day_to by shifting to midnight of next day
|
||||
q = q.filter(AuditLog.created_at < datetime.strptime(filter_date_to, '%Y-%m-%d') + timedelta(days=1))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
logs = q.paginate(page=page, per_page=50, error_out=False)
|
||||
users = User.query.order_by(User.username).all()
|
||||
|
||||
# Distinct action and entity_type values for the filter dropdowns
|
||||
distinct_actions = (
|
||||
AuditLog.query.with_entities(AuditLog.action)
|
||||
.distinct()
|
||||
.order_by(AuditLog.action)
|
||||
.all()
|
||||
)
|
||||
distinct_entity_types = (
|
||||
AuditLog.query.with_entities(AuditLog.entity_type)
|
||||
.distinct()
|
||||
.order_by(AuditLog.entity_type)
|
||||
.all()
|
||||
)
|
||||
|
||||
return render_template(
|
||||
'audit/index.html',
|
||||
logs=logs,
|
||||
users=users,
|
||||
distinct_actions=[r[0] for r in distinct_actions],
|
||||
distinct_entity_types=[r[0] for r in distinct_entity_types],
|
||||
filter_user=filter_user,
|
||||
filter_action=filter_action,
|
||||
filter_entity_type=filter_entity_type,
|
||||
filter_date_from=filter_date_from,
|
||||
filter_date_to=filter_date_to,
|
||||
)
|
||||
|
||||
|
||||
# ── Detail ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:log_id>')
|
||||
@login_required
|
||||
@admin_required
|
||||
def view(log_id):
|
||||
entry = db.session.get(AuditLog, log_id)
|
||||
if entry is None:
|
||||
abort(404)
|
||||
return render_template('audit/view.html', entry=entry)
|
||||
|
||||
|
||||
# ── Purge old logs ────────────────────────────────────────────────────────────
|
||||
|
||||
PURGE_OPTIONS = {
|
||||
7: '7 days',
|
||||
30: '30 days',
|
||||
60: '60 days',
|
||||
90: '90 days',
|
||||
180: '180 days',
|
||||
365: '1 year',
|
||||
}
|
||||
|
||||
@bp.route('/purge', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def purge():
|
||||
"""Delete audit log entries older than the selected threshold.
|
||||
|
||||
Accepts a POST form field `older_than` (integer days).
|
||||
The purge itself is recorded as a new audit log entry so there is
|
||||
always a traceable record of who purged what and when.
|
||||
"""
|
||||
try:
|
||||
older_than = int(request.form.get('older_than', 0))
|
||||
except (ValueError, TypeError):
|
||||
older_than = 0
|
||||
|
||||
if older_than not in PURGE_OPTIONS:
|
||||
flash('Invalid purge threshold selected.', 'danger')
|
||||
return redirect(url_for('audit.index'))
|
||||
|
||||
cutoff = now_eastern() - timedelta(days=older_than)
|
||||
deleted = AuditLog.query.filter(AuditLog.created_at < cutoff).delete()
|
||||
db.session.flush()
|
||||
|
||||
label = PURGE_OPTIONS[older_than]
|
||||
log_action(
|
||||
ACTION_DELETE, 'AuditLog', None,
|
||||
f'Purged {deleted} log entries older than {label}',
|
||||
f'cutoff={cutoff.strftime("%Y-%m-%d %H:%M:%S")} UTC; deleted={deleted}',
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
logger.info(
|
||||
'AUDIT PURGE | deleted=%s | older_than=%s days | by=%s',
|
||||
deleted, older_than, request.remote_addr,
|
||||
)
|
||||
|
||||
flash(
|
||||
f'{deleted} audit log entr{"ies" if deleted != 1 else "y"} '
|
||||
f'older than {label} have been permanently deleted.',
|
||||
'success' if deleted else 'info',
|
||||
)
|
||||
return redirect(url_for('audit.index'))
|
||||
@@ -0,0 +1,519 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
|
||||
from urllib.parse import urlparse
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from app import db, limiter
|
||||
from app.models.user import User
|
||||
from app.utils.forms import LoginForm, UserForm, ProfileForm, ForgotPasswordForm, ResetPasswordForm
|
||||
from app.utils.decorators import admin_required, supervisor_required, safe_redirect_url
|
||||
import logging
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_LOGIN, ACTION_LOGOUT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('auth', __name__, url_prefix='/auth')
|
||||
|
||||
|
||||
@bp.route('/login', methods=['GET', 'POST'])
|
||||
@limiter.limit('20 per minute; 5 per second')
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('dashboard.index'))
|
||||
|
||||
form = LoginForm()
|
||||
if form.validate_on_submit():
|
||||
user = User.query.filter_by(username=form.username.data).first()
|
||||
|
||||
if user and user.check_password(form.password.data):
|
||||
if not user.active:
|
||||
flash('Your account has been disabled. Please contact an administrator.', 'danger')
|
||||
return render_template('auth/login.html', form=form)
|
||||
if not user.password_set:
|
||||
flash(
|
||||
'Your account password has not been set yet. '
|
||||
'Please check your email for the account setup link.',
|
||||
'warning'
|
||||
)
|
||||
return render_template('auth/login.html', form=form)
|
||||
login_user(user, remember=form.remember_me.data)
|
||||
# Use validated next URL — never redirect blindly to request.args['next']
|
||||
next_page = safe_redirect_url(request.args.get('next'))
|
||||
log_action(ACTION_LOGIN, 'User', user.id, user.username)
|
||||
flash(f'Welcome back, {user.username}!', 'success')
|
||||
return redirect(next_page)
|
||||
else:
|
||||
# Generic message — don't reveal whether the username exists
|
||||
flash('Invalid credentials. Please try again.', 'danger')
|
||||
|
||||
return render_template('auth/login.html', form=form)
|
||||
|
||||
|
||||
@bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
log_action(ACTION_LOGOUT, 'User', current_user.id, current_user.username)
|
||||
logout_user()
|
||||
flash('Successfully logged out.', 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
|
||||
@bp.route('/profile', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def profile():
|
||||
"""User profile page — view stats and update email/password."""
|
||||
from app.models.inspection import Inspection
|
||||
from app.models.issue import Issue
|
||||
|
||||
form = ProfileForm(user=current_user, obj=current_user)
|
||||
|
||||
if form.validate_on_submit():
|
||||
current_user.full_name = form.full_name.data.strip() or None
|
||||
current_user.email = form.email.data
|
||||
|
||||
if form.new_password.data:
|
||||
current_user.set_password(form.new_password.data)
|
||||
logger.info('AUTH | profile_password_change | user_id=%s username=%s',
|
||||
current_user.id, current_user.username)
|
||||
|
||||
db.session.commit()
|
||||
logger.info('AUTH | profile_update | user_id=%s username=%s email=%s',
|
||||
current_user.id, current_user.username, current_user.email)
|
||||
log_action(ACTION_UPDATE, 'User', current_user.id, current_user.username,
|
||||
'self-service profile update')
|
||||
flash('Profile updated successfully.', 'success')
|
||||
return redirect(url_for('auth.profile'))
|
||||
|
||||
# ── Activity stats ────────────────────────────────────────────────────
|
||||
total_inspections = Inspection.query.filter_by(inspector_id=current_user.id).count()
|
||||
completed_inspections = Inspection.query.filter_by(
|
||||
inspector_id=current_user.id, status='completed'
|
||||
).count()
|
||||
|
||||
recent_inspections = (
|
||||
Inspection.query
|
||||
.filter_by(inspector_id=current_user.id)
|
||||
.order_by(Inspection.inspection_date.desc())
|
||||
.limit(5)
|
||||
.all()
|
||||
)
|
||||
|
||||
open_issues = Issue.query.filter_by(
|
||||
assigned_to=current_user.id, status='open'
|
||||
).count() if hasattr(Issue, 'assigned_to') else 0
|
||||
|
||||
return render_template(
|
||||
'auth/profile.html',
|
||||
form=form,
|
||||
total_inspections=total_inspections,
|
||||
completed_inspections=completed_inspections,
|
||||
recent_inspections=recent_inspections,
|
||||
open_issues=open_issues,
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/users')
|
||||
@login_required
|
||||
@admin_required
|
||||
def list_users():
|
||||
# Exclude customer accounts — those are managed exclusively via /customers
|
||||
users = (
|
||||
User.query
|
||||
.filter(User.role != 'customer')
|
||||
.order_by(User.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
# Build a map of inspector_id -> assignment count for the Contracts column
|
||||
from app.models.inspector_assignment import InspectorAssignment
|
||||
from sqlalchemy import func
|
||||
rows = (
|
||||
db.session.query(
|
||||
InspectorAssignment.user_id,
|
||||
func.count(InspectorAssignment.id).label('cnt'),
|
||||
)
|
||||
.group_by(InspectorAssignment.user_id)
|
||||
.all()
|
||||
)
|
||||
inspector_contract_counts = {r.user_id: r.cnt for r in rows}
|
||||
|
||||
logger.info('AUTH | list_users | admin=%s | internal_users_count=%s',
|
||||
current_user.username, len(users))
|
||||
return render_template('auth/users.html', users=users,
|
||||
inspector_contract_counts=inspector_contract_counts)
|
||||
|
||||
|
||||
@bp.route('/users/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def create_user():
|
||||
form = UserForm()
|
||||
|
||||
# Directors may not assign roles — new users created by a director default
|
||||
# to inspector. Only admins may set an arbitrary role at creation time.
|
||||
director_editing = current_user.role == 'director'
|
||||
|
||||
if form.validate_on_submit():
|
||||
role = 'inspector' if director_editing else form.role.data
|
||||
user = User(
|
||||
username=form.username.data,
|
||||
full_name=form.full_name.data.strip() or None,
|
||||
email=form.email.data,
|
||||
role=role
|
||||
)
|
||||
user.set_password(form.password.data)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
logger.info('AUTH | user_create | admin_id=%s admin=%s new_user=%s role=%s',
|
||||
current_user.id, current_user.username, user.username, user.role)
|
||||
log_action(ACTION_CREATE, 'User', user.id, user.username,
|
||||
f'role={user.role}; email={user.email}')
|
||||
flash(f'User {user.username} created successfully.', 'success')
|
||||
return redirect(url_for('auth.list_users'))
|
||||
|
||||
return render_template('auth/user_form.html', form=form, title='Create User',
|
||||
director_editing=director_editing)
|
||||
|
||||
|
||||
@bp.route('/users/<int:user_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def edit_user(user_id):
|
||||
user = db.session.get(User, user_id)
|
||||
if user is None:
|
||||
abort(404)
|
||||
form = UserForm(user=user, obj=user)
|
||||
|
||||
# Directors may not change another user's role — that privilege is admin-only.
|
||||
# The role field is removed from the form for directors so it cannot be
|
||||
# submitted at all, and the existing role value is preserved on save.
|
||||
director_editing = current_user.role == 'director'
|
||||
|
||||
if form.validate_on_submit():
|
||||
user.username = form.username.data
|
||||
user.full_name = form.full_name.data.strip() or None
|
||||
user.email = form.email.data
|
||||
|
||||
if not director_editing:
|
||||
user.role = form.role.data
|
||||
|
||||
if form.password.data:
|
||||
user.set_password(form.password.data)
|
||||
|
||||
db.session.commit()
|
||||
logger.info('AUTH | user_edit | admin_id=%s admin=%s target_user_id=%s target_user=%s',
|
||||
current_user.id, current_user.username, user.id, user.username)
|
||||
log_action(ACTION_UPDATE, 'User', user.id, user.username,
|
||||
f'role={user.role}; email={user.email}')
|
||||
flash(f'User {user.username} updated successfully.', 'success')
|
||||
return redirect(url_for('auth.list_users'))
|
||||
|
||||
return render_template('auth/user_form.html', form=form, user=user,
|
||||
title='Edit User', director_editing=director_editing)
|
||||
|
||||
|
||||
@bp.route('/users/<int:user_id>/assign-contracts', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def assign_inspector_contracts(user_id):
|
||||
user = db.session.get(User, user_id)
|
||||
if user is None or user.role != 'inspector':
|
||||
abort(404)
|
||||
|
||||
from app.models.project import Project
|
||||
from app.models.inspector_assignment import InspectorAssignment
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||
|
||||
if request.method == 'POST':
|
||||
selected_ids = set(request.form.getlist('project_ids', type=int))
|
||||
existing = InspectorAssignment.query.filter_by(user_id=user_id).all()
|
||||
existing_pids = {a.project_id for a in existing}
|
||||
|
||||
for a in existing:
|
||||
if a.project_id not in selected_ids:
|
||||
db.session.delete(a)
|
||||
for pid in selected_ids:
|
||||
if pid not in existing_pids:
|
||||
db.session.add(InspectorAssignment(
|
||||
user_id = user_id,
|
||||
project_id = pid,
|
||||
created_at = now_eastern(),
|
||||
))
|
||||
|
||||
db.session.commit()
|
||||
log_action(ACTION_UPDATE, 'User', user.id, user.username,
|
||||
f'inspector_assignments={sorted(selected_ids)}')
|
||||
flash(f'Contract assignments updated for {user.display_name}.', 'success')
|
||||
return redirect(url_for('auth.list_users'))
|
||||
|
||||
assigned_pids = {
|
||||
a.project_id
|
||||
for a in InspectorAssignment.query.filter_by(user_id=user_id).all()
|
||||
}
|
||||
return render_template('auth/inspector_assignments.html',
|
||||
user=user,
|
||||
projects=projects,
|
||||
assigned_pids=assigned_pids)
|
||||
|
||||
|
||||
@bp.route('/users/<int:user_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_user(user_id):
|
||||
user = db.session.get(User, user_id)
|
||||
if user is None:
|
||||
abort(404)
|
||||
|
||||
if user.id == current_user.id:
|
||||
flash('Cannot delete your own account.', 'danger')
|
||||
return redirect(url_for('auth.list_users'))
|
||||
|
||||
# Guard: block deletion if user has related records that would orphan data
|
||||
# or violate FK constraints. Issue.assigned_to and IssueComment.user_id carry
|
||||
# no ondelete clause, so MySQL defaults to RESTRICT — the DELETE would fail at
|
||||
# the DB level without these application-level checks and clear user-facing messages.
|
||||
if user.inspections.count() > 0:
|
||||
flash(
|
||||
f'Cannot delete "{user.username}" — they have existing inspection records. '
|
||||
'Deactivate the account instead.',
|
||||
'danger'
|
||||
)
|
||||
return redirect(url_for('auth.list_users'))
|
||||
|
||||
if user.assigned_issues.count() > 0:
|
||||
flash(
|
||||
f'Cannot delete "{user.username}" — they have issues assigned to them. '
|
||||
'Reassign or resolve those issues first, then deactivate the account.',
|
||||
'danger'
|
||||
)
|
||||
return redirect(url_for('auth.list_users'))
|
||||
|
||||
from app.models.issue import IssueComment
|
||||
if IssueComment.query.filter_by(user_id=user.id).count() > 0:
|
||||
flash(
|
||||
f'Cannot delete "{user.username}" — they have authored issue comments. '
|
||||
'Deactivate the account instead.',
|
||||
'danger'
|
||||
)
|
||||
return redirect(url_for('auth.list_users'))
|
||||
|
||||
from app.models.inspection import InspectionTemplate
|
||||
if InspectionTemplate.query.filter_by(created_by=user.id).count() > 0:
|
||||
flash(
|
||||
f'Cannot delete "{user.username}" — they have created inspection templates. '
|
||||
'Deactivate the account instead.',
|
||||
'danger'
|
||||
)
|
||||
return redirect(url_for('auth.list_users'))
|
||||
|
||||
username = user.username
|
||||
user_id = user.id
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
logger.info('AUTH | user_delete | admin_id=%s admin=%s deleted_user=%s',
|
||||
current_user.id, current_user.username, username)
|
||||
log_action(ACTION_DELETE, 'User', user_id, username)
|
||||
flash(f'User {username} deleted successfully.', 'success')
|
||||
return redirect(url_for('auth.list_users'))
|
||||
|
||||
@bp.route('/users/<int:user_id>/toggle-active', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def toggle_active(user_id):
|
||||
user = db.session.get(User, user_id)
|
||||
if user is None:
|
||||
abort(404)
|
||||
|
||||
if user.id == current_user.id:
|
||||
flash('You cannot disable your own account.', 'danger')
|
||||
return redirect(url_for('auth.list_users'))
|
||||
|
||||
user.active = not user.active
|
||||
db.session.commit()
|
||||
|
||||
action_label = 'enabled' if user.active else 'disabled'
|
||||
logger.info(
|
||||
'AUTH | user_%s | admin_id=%s admin=%s target_user=%s',
|
||||
action_label, current_user.id, current_user.username, user.username,
|
||||
)
|
||||
log_action(
|
||||
ACTION_UPDATE, 'User', user.id, user.username,
|
||||
f'account {action_label} by {current_user.username}',
|
||||
)
|
||||
flash(f'User {user.username} has been {action_label}.', 'success')
|
||||
return redirect(safe_redirect_url(request.referrer, fallback=url_for('auth.list_users')))
|
||||
|
||||
# ── Notification Matrix ───────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/notification-matrix', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def notification_matrix():
|
||||
"""Admin-only notification matrix — controls who receives each event type."""
|
||||
import json as _json
|
||||
from app.models.notification_matrix import (
|
||||
NotificationMatrix, MATRIX_EVENTS, MATRIX_ROLES, MATRIX_DEFAULTS,
|
||||
)
|
||||
|
||||
if request.method == 'POST':
|
||||
for event_key in MATRIX_EVENTS:
|
||||
for role_key, _ in MATRIX_ROLES:
|
||||
row = NotificationMatrix.query.filter_by(
|
||||
event_type=event_key, role_key=role_key
|
||||
).first()
|
||||
if row is None:
|
||||
row = NotificationMatrix(event_type=event_key, role_key=role_key)
|
||||
db.session.add(row)
|
||||
|
||||
if role_key == 'custom':
|
||||
raw = request.form.get(f'custom_{event_key}', '').strip()
|
||||
# Parse comma-separated emails into a JSON list
|
||||
emails = [e.strip() for e in raw.split(',') if e.strip()]
|
||||
row.custom_emails = _json.dumps(emails)
|
||||
row.enabled = bool(emails)
|
||||
else:
|
||||
row.enabled = bool(request.form.get(f'matrix_{event_key}_{role_key}'))
|
||||
|
||||
db.session.commit()
|
||||
log_action(ACTION_UPDATE, 'NotificationMatrix', None,
|
||||
'Notification Matrix', 'admin updated notification matrix')
|
||||
logger.info('NOTIFICATION MATRIX UPDATED | by=%s', current_user.username)
|
||||
flash('Notification matrix saved successfully.', 'success')
|
||||
return redirect(url_for('auth.notification_matrix'))
|
||||
|
||||
# Build current state dict: {event_key: {role_key: enabled/emails}}
|
||||
all_rows = NotificationMatrix.query.all()
|
||||
state = {} # event_key -> role_key -> row
|
||||
for row in all_rows:
|
||||
state.setdefault(row.event_type, {})[row.role_key] = row
|
||||
|
||||
return render_template(
|
||||
'auth/notification_matrix.html',
|
||||
matrix_events = MATRIX_EVENTS,
|
||||
matrix_roles = MATRIX_ROLES,
|
||||
defaults = MATRIX_DEFAULTS,
|
||||
state = state,
|
||||
)
|
||||
|
||||
|
||||
# ── Forgot / Reset Password (public) ─────────────────────────────────────────
|
||||
|
||||
def _send_password_reset_email(user, token, base_url=None):
|
||||
"""Send a password-reset link email. Mirrors _send_invite_email in customers.py."""
|
||||
from flask import current_app, render_template_string, url_for as _url_for
|
||||
from flask_mail import Message
|
||||
from app import mail
|
||||
from urllib.parse import urlparse
|
||||
import threading
|
||||
|
||||
if not current_app.config.get('MAIL_SERVER'):
|
||||
logger.warning('RESET EMAIL SKIPPED | no MAIL_SERVER | user=%s', user.username)
|
||||
return
|
||||
|
||||
effective_base = (base_url or current_app.config.get('APP_BASE_URL', '')).rstrip('/')
|
||||
reset_link = f'{effective_base}{_url_for("auth.reset_password", token=token)}'
|
||||
host = urlparse(effective_base).netloc or 'janitorialqc.local'
|
||||
sender = f'noreply@{host}'
|
||||
|
||||
html_body = render_template_string("""<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;">
|
||||
<h2 style="color:#0d6efd;">Password Reset Request</h2>
|
||||
<p>Hi {{ name }},</p>
|
||||
<p>We received a request to reset your password for the Janitorial QC portal.
|
||||
Click the button below to choose a new password. This link expires in
|
||||
<strong>1 hour</strong>.</p>
|
||||
<p>
|
||||
<a href="{{ link }}"
|
||||
style="background:#0d6efd;color:#fff;padding:12px 24px;
|
||||
text-decoration:none;border-radius:4px;display:inline-block;font-weight:bold;">
|
||||
Reset My Password
|
||||
</a>
|
||||
</p>
|
||||
<p style="font-size:13px;color:#666;">
|
||||
If you did not request a password reset, you can safely ignore this email.
|
||||
Your password will not change.
|
||||
</p>
|
||||
<p style="font-size:13px;color:#888;">
|
||||
Or copy this URL:<br>
|
||||
<a href="{{ link }}" style="color:#0d6efd;">{{ link }}</a>
|
||||
</p>
|
||||
<hr style="border:none;border-top:1px solid #eee;margin-top:32px;">
|
||||
<p style="font-size:12px;color:#888;">Janitorial QC System — do not reply.</p>
|
||||
</body>
|
||||
</html>""", name=user.display_name, link=reset_link)
|
||||
|
||||
text_body = (
|
||||
f'Hi {user.display_name},\n\n'
|
||||
f'We received a request to reset your JQC password.\n'
|
||||
f'Click the link below to reset it (expires in 1 hour):\n\n{reset_link}\n\n'
|
||||
f'If you did not request this, ignore this email.\n\nJanitorial QC System'
|
||||
)
|
||||
|
||||
msg = Message(
|
||||
subject = '[JQC] Password reset request',
|
||||
sender = sender,
|
||||
recipients = [user.email],
|
||||
body = text_body,
|
||||
html = html_body,
|
||||
)
|
||||
|
||||
app = current_app._get_current_object()
|
||||
|
||||
def _send():
|
||||
with app.app_context():
|
||||
try:
|
||||
mail.send(msg)
|
||||
logger.info('RESET EMAIL SENT | to=%s | user=%s', user.email, user.username)
|
||||
except Exception as exc:
|
||||
logger.error('RESET EMAIL FAILED | to=%s | error=%s', user.email, exc)
|
||||
|
||||
threading.Thread(target=_send, daemon=True).start()
|
||||
|
||||
|
||||
@bp.route('/forgot-password', methods=['GET', 'POST'])
|
||||
@limiter.limit('10 per hour')
|
||||
def forgot_password():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('dashboard.index'))
|
||||
|
||||
form = ForgotPasswordForm()
|
||||
if form.validate_on_submit():
|
||||
user = User.query.filter_by(email=form.email.data.strip().lower()).first()
|
||||
if user and user.active:
|
||||
token = user.generate_set_password_token(expires_hours=1)
|
||||
db.session.commit()
|
||||
_send_password_reset_email(user, token, base_url=request.host_url)
|
||||
logger.info('AUTH | forgot_password | user=%s | email=%s', user.username, user.email)
|
||||
# Always show the same message — never reveal whether the email exists
|
||||
flash(
|
||||
'If an account with that email address exists, a password reset link '
|
||||
'has been sent. Please check your inbox (and spam folder).',
|
||||
'info'
|
||||
)
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
return render_template('auth/forgot_password.html', form=form)
|
||||
|
||||
|
||||
@bp.route('/reset-password/<token>', methods=['GET', 'POST'])
|
||||
def reset_password(token):
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('dashboard.index'))
|
||||
|
||||
user = User.verify_set_password_token(token)
|
||||
if user is None:
|
||||
flash('This password reset link is invalid or has expired.', 'danger')
|
||||
return redirect(url_for('auth.forgot_password'))
|
||||
|
||||
form = ResetPasswordForm()
|
||||
if form.validate_on_submit():
|
||||
user.set_password(form.password.data)
|
||||
user.clear_set_password_token()
|
||||
db.session.commit()
|
||||
log_action(ACTION_UPDATE, 'User', user.id, user.username,
|
||||
'password reset via forgot-password link')
|
||||
flash('Your password has been reset successfully. Please log in.', 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
return render_template('auth/reset_password.html', form=form, user=user)
|
||||
@@ -0,0 +1,140 @@
|
||||
# app/routes/broadcast.py
|
||||
# -----------------------
|
||||
# Admin-only route for composing and sending push notifications to all
|
||||
# iOS apps. Uses the existing Notification model and notify() utility —
|
||||
# broadcasts arrive on the iPad via the standard 60-second poll cycle
|
||||
# (GET /api/v1/notifications?since=...) and trigger a local banner via
|
||||
# deliverLocalNotification(). No APNs/FCM required.
|
||||
|
||||
import logging
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from app import db
|
||||
from app.models.broadcast import Broadcast
|
||||
from app.models.user import User
|
||||
from app.models.notification import Notification
|
||||
from app.utils.decorators import admin_required
|
||||
from app.utils.audit import log_action, ACTION_CREATE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('broadcast', __name__, url_prefix='/admin/broadcast')
|
||||
|
||||
# All roles that can hold an active iOS session
|
||||
BROADCAST_ROLES = ['inspector', 'project_manager', 'director', 'admin']
|
||||
|
||||
ROLE_LABELS = {
|
||||
'inspector': 'Inspectors',
|
||||
'project_manager': 'Project Managers',
|
||||
'director': 'Directors',
|
||||
'admin': 'Admins',
|
||||
}
|
||||
|
||||
|
||||
@bp.route('/', methods=['GET'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def index():
|
||||
"""Show the compose form and recent broadcast history."""
|
||||
history = (
|
||||
Broadcast.query
|
||||
.order_by(Broadcast.sent_at.desc())
|
||||
.limit(50)
|
||||
.all()
|
||||
)
|
||||
return render_template(
|
||||
'admin/broadcast.html',
|
||||
history=history,
|
||||
roles=BROADCAST_ROLES,
|
||||
role_labels=ROLE_LABELS,
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/send', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def send():
|
||||
"""
|
||||
Compose and send a broadcast notification.
|
||||
|
||||
Form fields
|
||||
-----------
|
||||
title str Notification title (required, max 255)
|
||||
body str Notification body text (required)
|
||||
roles[] list One or more role keys to target (required)
|
||||
"""
|
||||
title = (request.form.get('title') or '').strip()
|
||||
body = (request.form.get('body') or '').strip()
|
||||
target_roles = request.form.getlist('roles')
|
||||
|
||||
# ── Validation ─────────────────────────────────────────────────────────
|
||||
errors = []
|
||||
if not title:
|
||||
errors.append('Title is required.')
|
||||
elif len(title) > 255:
|
||||
errors.append('Title must be 255 characters or fewer.')
|
||||
if not body:
|
||||
errors.append('Message body is required.')
|
||||
elif len(body) > 500:
|
||||
errors.append('Message must be 500 characters or fewer.')
|
||||
valid_roles = [r for r in target_roles if r in BROADCAST_ROLES]
|
||||
if not valid_roles:
|
||||
errors.append('Select at least one target role.')
|
||||
|
||||
if errors:
|
||||
for e in errors:
|
||||
flash(e, 'danger')
|
||||
return redirect(url_for('broadcast.index'))
|
||||
|
||||
# ── Find target users (active only) ────────────────────────────────────
|
||||
recipients = (
|
||||
User.query
|
||||
.filter(User.role.in_(valid_roles), User.active == True) # noqa: E712
|
||||
.all()
|
||||
)
|
||||
if not recipients:
|
||||
flash('No active users found for the selected roles.', 'warning')
|
||||
return redirect(url_for('broadcast.index'))
|
||||
|
||||
# ── Create Notification rows ────────────────────────────────────────────
|
||||
# One row per recipient — the iOS poll picks them up in the next 60s cycle.
|
||||
# Using the same Notification model as all other in-app notifications means
|
||||
# no iOS code changes are needed: existing deliverLocalNotification() fires
|
||||
# a banner, and unreadNotificationCount increments as usual.
|
||||
for user in recipients:
|
||||
notif = Notification(
|
||||
user_id = user.id,
|
||||
title = title,
|
||||
body = body,
|
||||
link = None, # broadcasts are informational — no destination page
|
||||
event_type = 'admin_broadcast',
|
||||
is_read = False,
|
||||
)
|
||||
db.session.add(notif)
|
||||
|
||||
# ── Record the broadcast ────────────────────────────────────────────────
|
||||
broadcast = Broadcast(
|
||||
title = title,
|
||||
body = body,
|
||||
target_roles = valid_roles,
|
||||
sent_by_id = current_user.id,
|
||||
recipient_count = len(recipients),
|
||||
)
|
||||
db.session.add(broadcast)
|
||||
db.session.commit()
|
||||
|
||||
log_action(ACTION_CREATE, 'Broadcast', broadcast.id,
|
||||
f'"{title}" → {", ".join(valid_roles)} ({len(recipients)} users)')
|
||||
|
||||
logger.info(
|
||||
'BROADCAST | id=%d | title=%r | roles=%s | recipients=%d | by=%s',
|
||||
broadcast.id, title, valid_roles, len(recipients), current_user.username,
|
||||
)
|
||||
|
||||
flash(
|
||||
f'Broadcast sent to {len(recipients)} user(s) across '
|
||||
f'{", ".join(ROLE_LABELS[r] for r in valid_roles)}.',
|
||||
'success',
|
||||
)
|
||||
return redirect(url_for('broadcast.index'))
|
||||
@@ -0,0 +1,785 @@
|
||||
"""
|
||||
app/routes/customers.py
|
||||
-----------------------
|
||||
Customer Management — admin-only consolidated view.
|
||||
|
||||
Provides a single screen to:
|
||||
- List all customer-role users with their assignment summary
|
||||
- Create a new customer account
|
||||
- Edit an existing customer (username / email / password / active)
|
||||
- Manage assignments for a customer (add / remove)
|
||||
- Quick-disable / enable a customer account
|
||||
- View a customer's scoped facility access at a glance
|
||||
"""
|
||||
|
||||
import logging
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.user import User
|
||||
from app.models.project import Project, CustomerAssignment
|
||||
from app.models.facility import Facility
|
||||
from app.utils.forms import CustomerUserForm, CustomerAssignmentForm, CustomerInviteForm, SetPasswordForm
|
||||
from app.utils.decorators import admin_required, supervisor_required, safe_redirect_url
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
|
||||
from app.utils.scope import get_customer_scope
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('customers', __name__, url_prefix='/customers')
|
||||
|
||||
|
||||
# ── List ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def index():
|
||||
"""Consolidated customer management dashboard."""
|
||||
customers = (
|
||||
User.query
|
||||
.filter_by(role='customer')
|
||||
.order_by(User.username)
|
||||
.all()
|
||||
)
|
||||
|
||||
customer_ids = [c.id for c in customers]
|
||||
|
||||
# ── Single bulk query for all assignments ─────────────────────────────
|
||||
# Replaces per-customer CustomerAssignment.query.filter_by(user_id=...) loop
|
||||
all_assignments = (
|
||||
CustomerAssignment.query
|
||||
.filter(CustomerAssignment.user_id.in_(customer_ids))
|
||||
.all()
|
||||
) if customer_ids else []
|
||||
|
||||
assignment_map = {c.id: [] for c in customers}
|
||||
for a in all_assignments:
|
||||
assignment_map[a.user_id].append(a)
|
||||
|
||||
# ── Single bulk query for all active facilities in assigned projects ──
|
||||
# Resolves facility scope for every customer without repeated DB round-trips.
|
||||
from collections import defaultdict
|
||||
assigned_project_ids = {a.project_id for a in all_assignments}
|
||||
|
||||
project_facilities_map = defaultdict(list) # project_id → [facility_id, ...]
|
||||
if assigned_project_ids:
|
||||
proj_facs = (
|
||||
Facility.query
|
||||
.filter(
|
||||
Facility.project_id.in_(assigned_project_ids),
|
||||
Facility.active == True,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for f in proj_facs:
|
||||
project_facilities_map[f.project_id].append(f.id)
|
||||
|
||||
scope_map = {} # user_id → sorted list[int] facility IDs
|
||||
for customer in customers:
|
||||
ids = set()
|
||||
for a in assignment_map[customer.id]:
|
||||
if a.facility_id:
|
||||
ids.add(a.facility_id)
|
||||
else:
|
||||
ids.update(project_facilities_map.get(a.project_id, []))
|
||||
scope_map[customer.id] = sorted(ids)
|
||||
|
||||
# All active projects for the assignment modal
|
||||
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||
|
||||
# ── Expired pending-setup invitations ─────────────────────────────────
|
||||
# Surface customer accounts whose invitation token has expired but
|
||||
# password_set is still False — they need a fresh invite to log in.
|
||||
from app.utils.time_utils import now_eastern
|
||||
expired_invitations = [
|
||||
c for c in customers
|
||||
if not c.password_set
|
||||
and c.set_password_token_expires is not None
|
||||
and c.set_password_token_expires < now_eastern()
|
||||
]
|
||||
|
||||
return render_template(
|
||||
'customers/index.html',
|
||||
customers = customers,
|
||||
assignment_map = assignment_map,
|
||||
scope_map = scope_map,
|
||||
projects = projects,
|
||||
expired_invitations = expired_invitations,
|
||||
)
|
||||
|
||||
|
||||
# ── Create customer (invitation flow) ────────────────────────────────────────
|
||||
|
||||
@bp.route('/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def create():
|
||||
"""Create a customer account via email invitation.
|
||||
|
||||
Admin enters Full Name and Email only. A temporary username is
|
||||
auto-generated from the email address. A one-time set-password link
|
||||
is emailed; the customer chooses their own username and password when
|
||||
they click it. The account is activated on completion.
|
||||
"""
|
||||
form = CustomerInviteForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
import re, secrets
|
||||
|
||||
full_name = form.full_name.data.strip()
|
||||
email = form.email.data.strip().lower()
|
||||
|
||||
# Auto-generate a temporary username from the email local part.
|
||||
# The customer replaces this with their preferred username when
|
||||
# they complete the set-password flow via the emailed link.
|
||||
base_uname = re.sub(r'[^a-z0-9._-]', '', email.split('@')[0])[:40] or 'customer'
|
||||
username = base_uname
|
||||
suffix = 1
|
||||
while User.query.filter_by(username=username).first():
|
||||
username = f'{base_uname}{suffix}'
|
||||
suffix += 1
|
||||
|
||||
# Create user with a random placeholder password (password_set=False
|
||||
# blocks login until the customer completes the set-password flow).
|
||||
user = User(
|
||||
username = username,
|
||||
full_name = full_name,
|
||||
email = email,
|
||||
role = 'customer',
|
||||
active = True,
|
||||
password_set = False,
|
||||
)
|
||||
user.set_password(secrets.token_hex(32))
|
||||
db.session.add(user)
|
||||
db.session.flush()
|
||||
|
||||
token = user.generate_set_password_token(expires_hours=72)
|
||||
db.session.commit()
|
||||
|
||||
logger.info('CUSTOMERS | invite | admin=%s new_customer=%s email=%s',
|
||||
current_user.username, user.username, user.email)
|
||||
log_action(ACTION_CREATE, 'User', user.id, user.username,
|
||||
f'role=customer; email={user.email}; invite_sent=True')
|
||||
|
||||
_send_invite_email(user, token, base_url=request.host_url)
|
||||
|
||||
flash(
|
||||
f'Customer account created for {full_name}. '
|
||||
f'An invitation email has been sent to {email} with a link to set their username and password.',
|
||||
'success'
|
||||
)
|
||||
return redirect(url_for('customers.manage', customer_id=user.id))
|
||||
|
||||
return render_template('customers/invite.html', form=form)
|
||||
|
||||
|
||||
def _send_invite_email(user, token, base_url=None):
|
||||
"""Send the account setup email to a newly created customer."""
|
||||
from flask import current_app, render_template_string
|
||||
from flask_mail import Message
|
||||
from app import mail
|
||||
from urllib.parse import urlparse
|
||||
import threading
|
||||
|
||||
if not current_app.config.get('MAIL_SERVER'):
|
||||
logger.warning('INVITE EMAIL SKIPPED | no MAIL_SERVER | user=%s', user.username)
|
||||
return
|
||||
|
||||
effective_base = (base_url or current_app.config.get('APP_BASE_URL', '')).rstrip('/')
|
||||
setup_link = f'{effective_base}{url_for("customers.set_password", token=token)}'
|
||||
|
||||
host = urlparse(effective_base).netloc or 'janitorialqc.local'
|
||||
sender = f'noreply@{host}'
|
||||
|
||||
html_body = render_template_string("""<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;">
|
||||
<h2 style="color:#0d6efd;">Welcome to the Janitorial QC System</h2>
|
||||
<p>Hi {{ name }},</p>
|
||||
<p>An account has been created for you on the Janitorial QC (JQC) portal.
|
||||
To get started, please set your password using the button below.</p>
|
||||
<p>
|
||||
<a href="{{ link }}"
|
||||
style="background:#0d6efd;color:#fff;padding:12px 24px;
|
||||
text-decoration:none;border-radius:4px;display:inline-block;font-weight:bold;">
|
||||
Set My Password
|
||||
</a>
|
||||
</p>
|
||||
<p style="font-size:13px;color:#666;">
|
||||
This link expires in <strong>72 hours</strong>. If you did not expect this email,
|
||||
you can safely ignore it.
|
||||
</p>
|
||||
<p style="font-size:13px;color:#888;">
|
||||
Or copy this URL:<br>
|
||||
<a href="{{ link }}" style="color:#0d6efd;">{{ link }}</a>
|
||||
</p>
|
||||
<hr style="border:none;border-top:1px solid #eee;margin-top:32px;">
|
||||
<p style="font-size:12px;color:#888;">Janitorial QC System — do not reply.</p>
|
||||
</body>
|
||||
</html>""", name=user.display_name, link=setup_link)
|
||||
|
||||
text_body = (
|
||||
f'Hi {user.display_name},\n\n'
|
||||
f'An account has been created for you on the Janitorial QC portal.\n'
|
||||
f'Set your password here:\n\n{setup_link}\n\n'
|
||||
f'This link expires in 72 hours.\n\nJanitorial QC System'
|
||||
)
|
||||
|
||||
msg = Message(
|
||||
subject = '[JQC] Your account is ready — please set your password',
|
||||
sender = sender,
|
||||
recipients = [user.email],
|
||||
body = text_body,
|
||||
html = html_body,
|
||||
)
|
||||
|
||||
app = current_app._get_current_object()
|
||||
|
||||
def _send():
|
||||
with app.app_context():
|
||||
try:
|
||||
mail.send(msg)
|
||||
logger.info('INVITE EMAIL SENT | to=%s | user=%s', user.email, user.username)
|
||||
except Exception as exc:
|
||||
logger.error('INVITE EMAIL FAILED | to=%s | error=%s', user.email, exc)
|
||||
|
||||
threading.Thread(target=_send, daemon=True).start()
|
||||
|
||||
|
||||
# ── Resend invitation email ───────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:customer_id>/resend-invite', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def resend_invite(customer_id):
|
||||
"""Generate a fresh token and resend the set-password invitation email."""
|
||||
customer = db.session.get(User, customer_id)
|
||||
if customer is None:
|
||||
abort(404)
|
||||
if customer.role != 'customer':
|
||||
flash('This action is only for customer accounts.', 'warning')
|
||||
return redirect(url_for('customers.index'))
|
||||
|
||||
token = customer.generate_set_password_token(expires_hours=72)
|
||||
customer.password_set = False
|
||||
db.session.commit()
|
||||
|
||||
logger.info('CUSTOMERS | resend_invite | admin=%s customer=%s',
|
||||
current_user.username, customer.username)
|
||||
log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
|
||||
f'invite resent by {current_user.username}')
|
||||
|
||||
_send_invite_email(customer, token, base_url=request.host_url)
|
||||
flash(f'Invitation email resent to {customer.email}.', 'success')
|
||||
return redirect(url_for('customers.manage', customer_id=customer_id))
|
||||
|
||||
|
||||
# ── Public: set password via token ────────────────────────────────────────────
|
||||
|
||||
@bp.route('/set-password/<token>', methods=['GET', 'POST'])
|
||||
def set_password(token):
|
||||
"""Public page — customer sets their password via the emailed link."""
|
||||
from app.utils.forms import SetPasswordForm
|
||||
user = User.verify_set_password_token(token)
|
||||
if user is None:
|
||||
flash(
|
||||
'This password setup link is invalid or has expired. '
|
||||
'Please contact your administrator to resend the invitation.',
|
||||
'danger'
|
||||
)
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
form = SetPasswordForm()
|
||||
if form.validate_on_submit():
|
||||
user.username = form.username.data.strip()
|
||||
user.password_set = True
|
||||
user.set_password(form.password.data)
|
||||
user.clear_set_password_token()
|
||||
db.session.commit()
|
||||
|
||||
logger.info('CUSTOMERS | password_set | user=%s', user.username)
|
||||
log_action(ACTION_UPDATE, 'User', user.id, user.username,
|
||||
'customer chose username and password via invite link')
|
||||
|
||||
flash('Your account is ready. You can now log in.', 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
return render_template('customers/set_password.html', form=form, user=user)
|
||||
|
||||
|
||||
# ── Edit customer ─────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Edit customer ─────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:customer_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def edit(customer_id):
|
||||
customer = db.session.get(User, customer_id)
|
||||
if customer is None:
|
||||
abort(404)
|
||||
if customer.role != 'customer':
|
||||
flash('This page is only for customer accounts.', 'warning')
|
||||
return redirect(url_for('customers.index'))
|
||||
|
||||
form = CustomerUserForm(user=customer, obj=customer)
|
||||
|
||||
if form.validate_on_submit():
|
||||
customer.username = form.username.data
|
||||
customer.full_name = form.full_name.data.strip() or None
|
||||
customer.email = form.email.data
|
||||
if form.password.data:
|
||||
customer.set_password(form.password.data)
|
||||
db.session.commit()
|
||||
logger.info('CUSTOMERS | edit | admin=%s customer_id=%s username=%s',
|
||||
current_user.username, customer.id, customer.username)
|
||||
log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
|
||||
f'email={customer.email}; updated_via=customer_mgmt')
|
||||
flash(f'Customer "{customer.username}" updated successfully.', 'success')
|
||||
return redirect(url_for('customers.manage', customer_id=customer.id))
|
||||
|
||||
return render_template('customers/form.html', form=form, customer=customer,
|
||||
title='Edit Customer Account')
|
||||
|
||||
|
||||
# ── Customer detail / assignment management ───────────────────────────────────
|
||||
|
||||
@bp.route('/<int:customer_id>')
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def manage(customer_id):
|
||||
"""Single-customer detail page: profile + all assignments."""
|
||||
customer = db.session.get(User, customer_id)
|
||||
if customer is None:
|
||||
abort(404)
|
||||
if customer.role != 'customer':
|
||||
flash('This page is only for customer accounts.', 'warning')
|
||||
return redirect(url_for('customers.index'))
|
||||
|
||||
assignments = CustomerAssignment.query.filter_by(user_id=customer_id).all()
|
||||
facility_ids = get_customer_scope(customer) or []
|
||||
facilities = (
|
||||
Facility.query
|
||||
.filter(Facility.id.in_(facility_ids), Facility.active == True)
|
||||
.order_by(Facility.name)
|
||||
.all()
|
||||
) if facility_ids else []
|
||||
|
||||
# Assignment form (populated here so it can be rendered inline)
|
||||
aform = CustomerAssignmentForm()
|
||||
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||
aform.user_id.choices = [(customer.id, customer.username)]
|
||||
aform.facility_id.choices = [(0, '— All facilities in contract —')]
|
||||
|
||||
return render_template(
|
||||
'customers/manage.html',
|
||||
customer = customer,
|
||||
assignments = assignments,
|
||||
facilities = facilities,
|
||||
aform = aform,
|
||||
projects = projects,
|
||||
)
|
||||
|
||||
|
||||
# ── Add assignment (from customer detail page) ────────────────────────────────
|
||||
|
||||
@bp.route('/<int:customer_id>/assignments/add', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def add_assignment(customer_id):
|
||||
customer = db.session.get(User, customer_id)
|
||||
if customer is None:
|
||||
abort(404)
|
||||
if customer.role != 'customer':
|
||||
flash('Assignments are only for customer accounts.', 'warning')
|
||||
return redirect(url_for('customers.index'))
|
||||
|
||||
project_id = request.form.get('project_id', type=int)
|
||||
facility_id = request.form.get('facility_id', type=int) or None
|
||||
|
||||
if not project_id:
|
||||
flash('Please select a contract.', 'warning')
|
||||
return redirect(url_for('customers.manage', customer_id=customer_id))
|
||||
|
||||
project = db.session.get(Project, project_id)
|
||||
if project is None:
|
||||
abort(404)
|
||||
|
||||
# Guard: duplicate assignment
|
||||
existing = CustomerAssignment.query.filter_by(
|
||||
user_id = customer_id,
|
||||
project_id = project_id,
|
||||
facility_id = facility_id,
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
flash('That assignment already exists.', 'warning')
|
||||
return redirect(url_for('customers.manage', customer_id=customer_id))
|
||||
|
||||
assignment = CustomerAssignment(
|
||||
user_id = customer_id,
|
||||
project_id = project_id,
|
||||
facility_id = facility_id,
|
||||
)
|
||||
db.session.add(assignment)
|
||||
db.session.commit()
|
||||
|
||||
scope_label = f'facility_id={facility_id}' if facility_id else 'all facilities'
|
||||
logger.info('CUSTOMERS | assignment_add | admin=%s customer=%s project_id=%s scope=%s',
|
||||
current_user.username, customer.username, project_id, scope_label)
|
||||
log_action(ACTION_CREATE, 'CustomerAssignment', assignment.id,
|
||||
f'{customer.username} → {project.name}',
|
||||
f'scope={scope_label}')
|
||||
flash(f'Assignment added: "{customer.username}" → "{project.name}".', 'success')
|
||||
return redirect(url_for('customers.manage', customer_id=customer_id))
|
||||
|
||||
|
||||
# ── Remove assignment ─────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/assignments/<int:assignment_id>/remove', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def remove_assignment(assignment_id):
|
||||
assignment = db.session.get(CustomerAssignment, assignment_id)
|
||||
if assignment is None:
|
||||
abort(404)
|
||||
customer_id = assignment.user_id
|
||||
customer = db.session.get(User, customer_id)
|
||||
project = db.session.get(Project, assignment.project_id)
|
||||
|
||||
username = customer.username if customer else f'user_id={customer_id}'
|
||||
project_name = project.name if project else f'project_id={assignment.project_id}'
|
||||
snap_id = assignment.id
|
||||
|
||||
db.session.delete(assignment)
|
||||
db.session.commit()
|
||||
logger.info('CUSTOMERS | assignment_remove | admin=%s customer=%s project=%s',
|
||||
current_user.username, username, project_name)
|
||||
log_action(ACTION_DELETE, 'CustomerAssignment', snap_id,
|
||||
f'{username} → {project_name}')
|
||||
flash(f'Assignment removed for "{username}".', 'success')
|
||||
return redirect(url_for('customers.manage', customer_id=customer_id))
|
||||
|
||||
|
||||
# ── Toggle active ─────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:customer_id>/toggle-active', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def toggle_active(customer_id):
|
||||
customer = db.session.get(User, customer_id)
|
||||
if customer is None:
|
||||
abort(404)
|
||||
if customer.role != 'customer':
|
||||
flash('This action is only for customer accounts.', 'warning')
|
||||
return redirect(url_for('customers.index'))
|
||||
|
||||
customer.active = not customer.active
|
||||
db.session.commit()
|
||||
|
||||
label = 'enabled' if customer.active else 'disabled'
|
||||
logger.info('CUSTOMERS | toggle_active | admin=%s customer=%s action=%s',
|
||||
current_user.username, customer.username, label)
|
||||
log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
|
||||
f'account {label} via customer_mgmt by {current_user.username}')
|
||||
flash(f'Customer "{customer.username}" has been {label}.', 'success')
|
||||
return redirect(safe_redirect_url(request.referrer, fallback=url_for('customers.index')))
|
||||
|
||||
|
||||
# ── AJAX: facilities for a project (used by add-assignment form) ──────────────
|
||||
|
||||
|
||||
# ── CSV template download ─────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/import/template')
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def import_template():
|
||||
"""Download a blank CSV template showing the expected import format."""
|
||||
import csv, io
|
||||
from flask import Response
|
||||
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow([
|
||||
'username', 'email', 'password',
|
||||
'project_name', 'facility_name',
|
||||
])
|
||||
writer.writerow([
|
||||
'jane.smith', 'jane@acme.com', 'SecurePass1!',
|
||||
'Acme Contract', 'Downtown Office',
|
||||
])
|
||||
writer.writerow([
|
||||
'bob.jones', 'bob@acme.com', 'SecurePass2!',
|
||||
'Acme Contract', '',
|
||||
])
|
||||
buf.seek(0)
|
||||
return Response(
|
||||
buf.getvalue(),
|
||||
mimetype='text/csv',
|
||||
headers={'Content-Disposition': 'attachment; filename="customer_import_template.csv"'},
|
||||
)
|
||||
|
||||
|
||||
# ── Bulk import (upload → preview → confirm) ──────────────────────────────────
|
||||
|
||||
@bp.route('/import', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def bulk_import():
|
||||
"""Two-phase CSV import for customer accounts.
|
||||
|
||||
Phase 1 (GET / POST with file):
|
||||
Parse and validate the CSV, return a preview of what will be created.
|
||||
No database writes occur here.
|
||||
|
||||
Phase 2 (POST with confirmed=1):
|
||||
Write all validated rows to the database.
|
||||
|
||||
CSV columns
|
||||
-----------
|
||||
username : required — must be unique across users
|
||||
email : required — must be unique across users
|
||||
password : required — min 8 characters
|
||||
project_name : optional — must match an existing active Project name exactly
|
||||
facility_name : optional — if given, must match an active Facility within the project
|
||||
|
||||
One row = one user. A user may have at most one assignment per import row;
|
||||
import the same username on multiple rows to assign them to multiple projects.
|
||||
Duplicate username rows after the first are treated as additional assignments.
|
||||
"""
|
||||
import csv, io
|
||||
from flask import session as _session
|
||||
|
||||
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||
proj_by_name = {p.name.strip().lower(): p for p in projects}
|
||||
|
||||
# ── Phase 2: commit confirmed rows ────────────────────────────────────
|
||||
if request.method == 'POST' and request.form.get('confirmed') == '1':
|
||||
import json
|
||||
rows_json = request.form.get('rows_json', '[]')
|
||||
try:
|
||||
rows = json.loads(rows_json)
|
||||
except Exception:
|
||||
flash('Import session expired. Please re-upload the file.', 'danger')
|
||||
return redirect(url_for('customers.bulk_import'))
|
||||
|
||||
created_users = 0
|
||||
created_assign = 0
|
||||
skipped = 0
|
||||
|
||||
# Track users created in this batch (username → User) so duplicate
|
||||
# rows for the same username add assignments rather than re-creating.
|
||||
batch_users = {}
|
||||
|
||||
for row in rows:
|
||||
uname = row['username']
|
||||
email = row['email']
|
||||
pw = row['password']
|
||||
proj_id = row.get('project_id')
|
||||
fac_id = row.get('facility_id')
|
||||
|
||||
# Get or create user
|
||||
user = (
|
||||
batch_users.get(uname)
|
||||
or User.query.filter_by(username=uname).first()
|
||||
)
|
||||
|
||||
if user is None:
|
||||
user = User(
|
||||
username = uname,
|
||||
email = email,
|
||||
role = 'customer',
|
||||
active = True,
|
||||
)
|
||||
user.set_password(pw)
|
||||
db.session.add(user)
|
||||
db.session.flush() # populate user.id before assignment
|
||||
batch_users[uname] = user
|
||||
created_users += 1
|
||||
log_action(ACTION_CREATE, 'User', user.id, user.username,
|
||||
f'role=customer; email={email}; source=bulk_import')
|
||||
logger.info('BULK IMPORT | user_created | username=%s email=%s by=%s',
|
||||
uname, email, current_user.username)
|
||||
|
||||
# Create assignment if a project was specified
|
||||
if proj_id:
|
||||
existing = CustomerAssignment.query.filter_by(
|
||||
user_id = user.id,
|
||||
project_id = proj_id,
|
||||
facility_id = fac_id or None,
|
||||
).first()
|
||||
if not existing:
|
||||
assign = CustomerAssignment(
|
||||
user_id = user.id,
|
||||
project_id = proj_id,
|
||||
facility_id = fac_id or None,
|
||||
)
|
||||
db.session.add(assign)
|
||||
db.session.flush() # populate assign.id before audit log
|
||||
created_assign += 1
|
||||
log_action(ACTION_CREATE, 'CustomerAssignment', assign.id,
|
||||
f'{uname} → project_id={proj_id}',
|
||||
f'facility_id={fac_id}; source=bulk_import')
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
db.session.commit()
|
||||
logger.info(
|
||||
'BULK IMPORT COMMITTED | by=%s | users=%s | assignments=%s | skipped=%s',
|
||||
current_user.username, created_users, created_assign, skipped,
|
||||
)
|
||||
flash(
|
||||
f'Import complete: {created_users} user(s) created, '
|
||||
f'{created_assign} assignment(s) added'
|
||||
+ (f', {skipped} duplicate assignment(s) skipped.' if skipped else '.'),
|
||||
'success',
|
||||
)
|
||||
return redirect(url_for('customers.index'))
|
||||
|
||||
# ── Phase 1: parse and validate ───────────────────────────────────────
|
||||
preview_rows = []
|
||||
errors = []
|
||||
raw_valid_rows = [] # serialisable dicts passed to phase 2 via hidden field
|
||||
|
||||
if request.method == 'POST':
|
||||
file = request.files.get('csv_file')
|
||||
|
||||
if not file or not file.filename:
|
||||
flash('Please select a CSV file to upload.', 'warning')
|
||||
return render_template('customers/import.html', projects=projects)
|
||||
|
||||
if not file.filename.lower().endswith('.csv'):
|
||||
flash('Only .csv files are accepted.', 'danger')
|
||||
return render_template('customers/import.html', projects=projects)
|
||||
|
||||
try:
|
||||
stream = io.StringIO(file.stream.read().decode('utf-8-sig'))
|
||||
reader = csv.DictReader(stream)
|
||||
raw_rows = list(reader)
|
||||
except Exception as exc:
|
||||
flash(f'Could not parse file: {exc}', 'danger')
|
||||
return render_template('customers/import.html', projects=projects)
|
||||
|
||||
required_cols = {'username', 'email', 'password'}
|
||||
if not required_cols.issubset(set(reader.fieldnames or [])):
|
||||
flash(
|
||||
f'CSV is missing required columns: {required_cols - set(reader.fieldnames or [])}. '
|
||||
'Download the template to see the expected format.',
|
||||
'danger',
|
||||
)
|
||||
return render_template('customers/import.html', projects=projects)
|
||||
|
||||
# Track usernames seen in this file to catch intra-file duplicates
|
||||
seen_usernames = {} # username → first row index (1-based)
|
||||
seen_emails = {}
|
||||
|
||||
for i, raw in enumerate(raw_rows, start=2): # row 1 = header
|
||||
row_errors = []
|
||||
|
||||
uname = (raw.get('username') or '').strip()
|
||||
email = (raw.get('email') or '').strip()
|
||||
pw = (raw.get('password') or '').strip()
|
||||
pname = (raw.get('project_name') or '').strip()
|
||||
fname = (raw.get('facility_name') or '').strip()
|
||||
|
||||
if not uname:
|
||||
row_errors.append('username is required')
|
||||
if not email:
|
||||
row_errors.append('email is required')
|
||||
if not pw:
|
||||
row_errors.append('password is required')
|
||||
elif len(pw) < 8:
|
||||
row_errors.append('password must be at least 8 characters')
|
||||
|
||||
# Duplicate username within file (first occurrence creates the user;
|
||||
# subsequent occurrences add assignments — that's intentional)
|
||||
if uname:
|
||||
if uname in seen_usernames:
|
||||
# Allowed only if it's an additional assignment row
|
||||
pass
|
||||
else:
|
||||
seen_usernames[uname] = i
|
||||
# Check DB uniqueness only for new usernames
|
||||
if User.query.filter_by(username=uname).first():
|
||||
row_errors.append(f'username "{uname}" already exists in the system')
|
||||
|
||||
if email:
|
||||
if email in seen_emails:
|
||||
row_errors.append(f'email "{email}" appears more than once in this file')
|
||||
else:
|
||||
seen_emails[email] = i
|
||||
if User.query.filter_by(email=email).first():
|
||||
row_errors.append(f'email "{email}" already exists in the system')
|
||||
|
||||
# Resolve project
|
||||
project = None
|
||||
facility = None
|
||||
proj_id = None
|
||||
fac_id = None
|
||||
|
||||
if pname:
|
||||
project = proj_by_name.get(pname.lower())
|
||||
if project is None:
|
||||
row_errors.append(f'contract "{pname}" not found or inactive')
|
||||
else:
|
||||
proj_id = project.id
|
||||
if fname:
|
||||
from app.models.facility import Facility
|
||||
facility = Facility.query.filter(
|
||||
Facility.project_id == project.id,
|
||||
Facility.active == True,
|
||||
db.func.lower(Facility.name) == fname.lower(),
|
||||
).first()
|
||||
if facility is None:
|
||||
row_errors.append(
|
||||
f'facility "{fname}" not found in contract "{pname}"'
|
||||
)
|
||||
else:
|
||||
fac_id = facility.id
|
||||
elif fname:
|
||||
row_errors.append('facility_name requires project_name to also be set')
|
||||
|
||||
status = 'error' if row_errors else 'ok'
|
||||
preview_rows.append({
|
||||
'row': i,
|
||||
'username': uname,
|
||||
'email': email,
|
||||
'project': project.name if project else '—',
|
||||
'facility': facility.name if facility else ('All' if project else '—'),
|
||||
'status': status,
|
||||
'errors': row_errors,
|
||||
})
|
||||
|
||||
if not row_errors:
|
||||
raw_valid_rows.append({
|
||||
'username': uname,
|
||||
'email': email,
|
||||
'password': pw,
|
||||
'project_id': proj_id,
|
||||
'facility_id': fac_id,
|
||||
})
|
||||
else:
|
||||
errors.extend(row_errors)
|
||||
|
||||
import json
|
||||
return render_template(
|
||||
'customers/import.html',
|
||||
projects = projects,
|
||||
preview_rows = preview_rows,
|
||||
has_errors = bool(errors),
|
||||
valid_count = len(raw_valid_rows),
|
||||
rows_json = json.dumps(raw_valid_rows),
|
||||
)
|
||||
|
||||
@bp.route('/facilities-for-project/<int:project_id>')
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def facilities_for_project(project_id):
|
||||
from flask import jsonify
|
||||
project = db.session.get(Project, project_id)
|
||||
if project is None:
|
||||
abort(404)
|
||||
facilities = project.facilities.filter_by(active=True).order_by(Facility.name).all()
|
||||
return jsonify([{'id': f.id, 'name': f.name} for f in facilities])
|
||||
@@ -0,0 +1,427 @@
|
||||
import logging
|
||||
from flask import Blueprint, render_template
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.inspection import Inspection, InspectionTemplate
|
||||
from app.models.facility import Facility
|
||||
from app.models.issue import Issue
|
||||
from app.models.user import User
|
||||
from app.utils.sla import sla_status, SLA_HOURS
|
||||
from app.utils.scope import get_customer_scope, get_inspector_scope
|
||||
from sqlalchemy import func
|
||||
from datetime import datetime, timedelta
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
bp = Blueprint('dashboard', __name__)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@bp.route('/')
|
||||
@bp.route('/dashboard')
|
||||
@login_required
|
||||
def index():
|
||||
now = now_eastern()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_end = today_start + timedelta(days=1)
|
||||
|
||||
is_inspector = current_user.role == 'inspector'
|
||||
is_privileged = current_user.role in ['admin', 'director']
|
||||
is_customer = current_user.role == 'customer'
|
||||
is_project_manager = current_user.role == 'project_manager'
|
||||
|
||||
# Resolve facility scope
|
||||
customer_facility_ids = get_customer_scope(current_user) # None for non-customers
|
||||
inspector_facility_ids = get_inspector_scope(current_user) # None for non-inspectors
|
||||
|
||||
# ── Today's stats (inspector: own work within contracted facilities) ───
|
||||
base_q = Inspection.query
|
||||
if is_inspector:
|
||||
if not inspector_facility_ids:
|
||||
base_q = base_q.filter(False)
|
||||
else:
|
||||
base_q = base_q.filter(
|
||||
Inspection.facility_id.in_(inspector_facility_ids),
|
||||
Inspection.inspector_id == current_user.id,
|
||||
)
|
||||
elif is_customer:
|
||||
if not customer_facility_ids:
|
||||
base_q = base_q.filter(False) # no access
|
||||
else:
|
||||
base_q = base_q.filter(Inspection.facility_id.in_(customer_facility_ids))
|
||||
|
||||
today_inspections = base_q.filter(
|
||||
Inspection.inspection_date >= today_start,
|
||||
Inspection.inspection_date < today_end,
|
||||
).count()
|
||||
|
||||
completed_today = base_q.filter(
|
||||
Inspection.status == 'completed',
|
||||
Inspection.inspection_date >= today_start,
|
||||
Inspection.inspection_date < today_end,
|
||||
).count()
|
||||
|
||||
# ── Open issues (inspector: all issues in contracted facilities) ───────
|
||||
open_issues_q = Issue.query.filter(Issue.status.in_(['open', 'in_progress']))
|
||||
if is_inspector:
|
||||
if not inspector_facility_ids:
|
||||
open_issues_q = open_issues_q.filter(False)
|
||||
else:
|
||||
from app.models.facility import Area
|
||||
open_issues_q = open_issues_q.outerjoin(
|
||||
Area, Issue.area_id == Area.id
|
||||
).filter(db.or_(
|
||||
Issue.facility_id.in_(inspector_facility_ids),
|
||||
Area.facility_id.in_(inspector_facility_ids)
|
||||
))
|
||||
elif is_customer:
|
||||
if not customer_facility_ids:
|
||||
open_issues_q = open_issues_q.filter(False)
|
||||
else:
|
||||
from app.models.facility import Area
|
||||
open_issues_q = open_issues_q.outerjoin(
|
||||
Area, Issue.area_id == Area.id
|
||||
).filter(db.or_(
|
||||
Issue.facility_id.in_(customer_facility_ids),
|
||||
Area.facility_id.in_(customer_facility_ids)
|
||||
))
|
||||
|
||||
# Single query — derive count from the list to avoid hitting the DB twice
|
||||
open_issues_all = open_issues_q.all()
|
||||
open_issues = len(open_issues_all)
|
||||
severity_breakdown = {
|
||||
'critical': sum(1 for i in open_issues_all if i.severity == 'critical'),
|
||||
'high': sum(1 for i in open_issues_all if i.severity == 'high'),
|
||||
'medium': sum(1 for i in open_issues_all if i.severity == 'medium'),
|
||||
'low': sum(1 for i in open_issues_all if i.severity == 'low'),
|
||||
}
|
||||
|
||||
# ── Issues resolved today ─────────────────────────────────────────────────
|
||||
resolved_today_q = Issue.query.filter(
|
||||
Issue.status == 'resolved',
|
||||
Issue.resolved_at >= today_start,
|
||||
Issue.resolved_at < today_end,
|
||||
)
|
||||
if is_inspector:
|
||||
if not inspector_facility_ids:
|
||||
resolved_today_q = resolved_today_q.filter(False)
|
||||
else:
|
||||
from app.models.facility import Area as _Area
|
||||
resolved_today_q = resolved_today_q.outerjoin(
|
||||
_Area, Issue.area_id == _Area.id
|
||||
).filter(db.or_(
|
||||
Issue.facility_id.in_(inspector_facility_ids),
|
||||
_Area.facility_id.in_(inspector_facility_ids),
|
||||
))
|
||||
elif is_customer:
|
||||
if not customer_facility_ids:
|
||||
resolved_today_q = resolved_today_q.filter(False)
|
||||
else:
|
||||
from app.models.facility import Area as _Area
|
||||
resolved_today_q = resolved_today_q.outerjoin(
|
||||
_Area, Issue.area_id == _Area.id
|
||||
).filter(db.or_(
|
||||
Issue.facility_id.in_(customer_facility_ids),
|
||||
_Area.facility_id.in_(customer_facility_ids),
|
||||
))
|
||||
resolved_today = resolved_today_q.count()
|
||||
|
||||
# ── Recent inspections ─────────────────────────────────────────────────
|
||||
recent_q = Inspection.query.order_by(Inspection.inspection_date.desc())
|
||||
if is_inspector:
|
||||
if not inspector_facility_ids:
|
||||
recent_q = recent_q.filter(False)
|
||||
else:
|
||||
recent_q = recent_q.filter(
|
||||
Inspection.facility_id.in_(inspector_facility_ids),
|
||||
Inspection.inspector_id == current_user.id,
|
||||
)
|
||||
elif is_customer:
|
||||
if customer_facility_ids:
|
||||
recent_q = recent_q.filter(Inspection.facility_id.in_(customer_facility_ids))
|
||||
else:
|
||||
recent_q = recent_q.filter(False)
|
||||
recent_inspections = recent_q.limit(5).all()
|
||||
|
||||
# ── Pending follow-up inspections ────────────────────────────────────
|
||||
# follow_ups is a lazy='dynamic' relationship — comparing it to None does
|
||||
# NOT produce a "has no rows" predicate for dynamic relationships. The
|
||||
# correct idiom is ~.any(), which generates EXISTS (SELECT 1 FROM inspections
|
||||
# WHERE parent_inspection_id = inspections.id). This matches the identical
|
||||
# filter used in routes/inspections.py:follow_up_filter.
|
||||
followup_q = Inspection.query.filter_by(
|
||||
follow_up_required=True, status='completed'
|
||||
).filter(~Inspection.follow_ups.any())
|
||||
if is_inspector:
|
||||
if not inspector_facility_ids:
|
||||
followup_q = followup_q.filter(False)
|
||||
else:
|
||||
followup_q = followup_q.filter(
|
||||
Inspection.facility_id.in_(inspector_facility_ids),
|
||||
Inspection.inspector_id == current_user.id,
|
||||
)
|
||||
elif is_customer:
|
||||
if customer_facility_ids:
|
||||
followup_q = followup_q.filter(Inspection.facility_id.in_(customer_facility_ids))
|
||||
else:
|
||||
followup_q = followup_q.filter(False)
|
||||
pending_followups = followup_q.count()
|
||||
|
||||
# ── System stats (admin/director) ────────────────────────────────────────
|
||||
total_facilities = Facility.query.filter_by(active=True).count() if is_privileged else 0
|
||||
total_templates = InspectionTemplate.query.count() if is_privileged else 0
|
||||
total_users = User.query.count() if current_user.role == 'admin' else 0
|
||||
|
||||
# ── Customer: scoped facilities summary ───────────────────────────────
|
||||
customer_facilities = []
|
||||
if is_customer and customer_facility_ids:
|
||||
customer_facilities = Facility.query.filter(
|
||||
Facility.id.in_(customer_facility_ids),
|
||||
Facility.active == True,
|
||||
).order_by(Facility.name).all()
|
||||
|
||||
# ── SLA summary (open + in_progress issues, scoped) ───────────────────
|
||||
sla_q = Issue.query.filter(Issue.status.in_(['open', 'in_progress']))
|
||||
if is_inspector and inspector_facility_ids:
|
||||
from app.models.facility import Area
|
||||
sla_q = sla_q.outerjoin(Area, Issue.area_id == Area.id).filter(
|
||||
db.or_(
|
||||
Issue.facility_id.in_(inspector_facility_ids),
|
||||
Area.facility_id.in_(inspector_facility_ids)
|
||||
)
|
||||
)
|
||||
elif is_customer and customer_facility_ids:
|
||||
from app.models.facility import Area
|
||||
sla_q = sla_q.outerjoin(Area, Issue.area_id == Area.id).filter(
|
||||
db.or_(
|
||||
Issue.facility_id.in_(customer_facility_ids),
|
||||
Area.facility_id.in_(customer_facility_ids)
|
||||
)
|
||||
)
|
||||
if is_inspector and not inspector_facility_ids:
|
||||
all_open_issues = []
|
||||
elif is_customer and not customer_facility_ids:
|
||||
all_open_issues = []
|
||||
else:
|
||||
all_open_issues = sla_q.all()
|
||||
sla_breached = sum(1 for i in all_open_issues if sla_status(i) == 'breached')
|
||||
sla_at_risk = sum(1 for i in all_open_issues if sla_status(i) == 'at_risk')
|
||||
|
||||
# ── Issues opened today ───────────────────────────────────────────────────
|
||||
from app.models.facility import Area as _AreaT
|
||||
opened_today_q = Issue.query.outerjoin(_AreaT, Issue.area_id == _AreaT.id).filter(
|
||||
Issue.reported_at >= today_start,
|
||||
Issue.reported_at < today_end,
|
||||
)
|
||||
if is_inspector:
|
||||
if not inspector_facility_ids:
|
||||
opened_today_q = opened_today_q.filter(False)
|
||||
else:
|
||||
opened_today_q = opened_today_q.filter(db.or_(
|
||||
Issue.facility_id.in_(inspector_facility_ids),
|
||||
_AreaT.facility_id.in_(inspector_facility_ids),
|
||||
))
|
||||
elif is_customer:
|
||||
if not customer_facility_ids:
|
||||
opened_today_q = opened_today_q.filter(False)
|
||||
else:
|
||||
opened_today_q = opened_today_q.filter(db.or_(
|
||||
Issue.facility_id.in_(customer_facility_ids),
|
||||
_AreaT.facility_id.in_(customer_facility_ids),
|
||||
))
|
||||
issues_opened_today = opened_today_q.count()
|
||||
|
||||
# ── Pending verification ──────────────────────────────────────────────────
|
||||
from app.models.facility import Area as _AreaV
|
||||
pv_q = Issue.query.outerjoin(_AreaV, Issue.area_id == _AreaV.id).filter(
|
||||
Issue.status == 'pending_verification',
|
||||
)
|
||||
if is_inspector:
|
||||
if not inspector_facility_ids:
|
||||
pv_q = pv_q.filter(False)
|
||||
else:
|
||||
pv_q = pv_q.filter(db.or_(
|
||||
Issue.facility_id.in_(inspector_facility_ids),
|
||||
_AreaV.facility_id.in_(inspector_facility_ids),
|
||||
))
|
||||
elif is_customer:
|
||||
if not customer_facility_ids:
|
||||
pv_q = pv_q.filter(False)
|
||||
else:
|
||||
pv_q = pv_q.filter(db.or_(
|
||||
Issue.facility_id.in_(customer_facility_ids),
|
||||
_AreaV.facility_id.in_(customer_facility_ids),
|
||||
))
|
||||
pending_verification = pv_q.count()
|
||||
|
||||
# ── Stale in-progress inspections (started > 24h ago, not yet submitted) ──
|
||||
stale_cutoff = now - timedelta(hours=24)
|
||||
stale_q = Inspection.query.filter(
|
||||
Inspection.status == 'in_progress',
|
||||
Inspection.inspection_date < stale_cutoff,
|
||||
)
|
||||
if is_inspector:
|
||||
if not inspector_facility_ids:
|
||||
stale_q = stale_q.filter(False)
|
||||
else:
|
||||
stale_q = stale_q.filter(
|
||||
Inspection.facility_id.in_(inspector_facility_ids),
|
||||
Inspection.inspector_id == current_user.id,
|
||||
)
|
||||
elif is_customer:
|
||||
if customer_facility_ids:
|
||||
stale_q = stale_q.filter(Inspection.facility_id.in_(customer_facility_ids))
|
||||
else:
|
||||
stale_q = stale_q.filter(False)
|
||||
stale_in_progress = stale_q.count()
|
||||
|
||||
# ── Unassigned open issues ────────────────────────────────────────────────
|
||||
from app.models.facility import Area as _AreaU
|
||||
unassigned_q = Issue.query.outerjoin(_AreaU, Issue.area_id == _AreaU.id).filter(
|
||||
Issue.status.in_(['open', 'in_progress']),
|
||||
Issue.assigned_to.is_(None),
|
||||
)
|
||||
if is_inspector:
|
||||
if not inspector_facility_ids:
|
||||
unassigned_q = unassigned_q.filter(False)
|
||||
else:
|
||||
unassigned_q = unassigned_q.filter(db.or_(
|
||||
Issue.facility_id.in_(inspector_facility_ids),
|
||||
_AreaU.facility_id.in_(inspector_facility_ids),
|
||||
))
|
||||
elif is_customer:
|
||||
unassigned_q = unassigned_q.filter(False) # not relevant for customers
|
||||
unassigned_open = unassigned_q.count()
|
||||
|
||||
# ── Inspector activity today (admin / director / PM only) ─────────────────
|
||||
inspector_activity = []
|
||||
if is_privileged or is_project_manager:
|
||||
active_inspectors = (
|
||||
User.query
|
||||
.filter_by(role='inspector', active=True)
|
||||
.order_by(User.full_name, User.username)
|
||||
.all()
|
||||
)
|
||||
today_counts = dict(
|
||||
db.session.query(
|
||||
Inspection.inspector_id,
|
||||
func.count(Inspection.id),
|
||||
)
|
||||
.filter(
|
||||
Inspection.status == 'completed',
|
||||
Inspection.inspection_date >= today_start,
|
||||
Inspection.inspection_date < today_end,
|
||||
)
|
||||
.group_by(Inspection.inspector_id)
|
||||
.all()
|
||||
)
|
||||
inspector_activity = sorted(
|
||||
[{'name': u.display_name, 'count': today_counts.get(u.id, 0)}
|
||||
for u in active_inspectors],
|
||||
key=lambda x: (-x['count'], x['name']),
|
||||
)
|
||||
|
||||
# ── My open issues (inspector dashboard widget) ───────────────────────────
|
||||
# Issues assigned to the current inspector that are not yet resolved,
|
||||
# ordered by SLA urgency (breached first, then at-risk, then ok).
|
||||
my_issues = []
|
||||
if is_inspector:
|
||||
my_issues = (
|
||||
Issue.query
|
||||
.filter(
|
||||
Issue.assigned_to == current_user.id,
|
||||
Issue.status.in_(['open', 'in_progress']),
|
||||
)
|
||||
.order_by(Issue.reported_at.asc())
|
||||
.limit(10)
|
||||
.all()
|
||||
)
|
||||
|
||||
return render_template(
|
||||
'dashboard.html',
|
||||
today_inspections = today_inspections,
|
||||
completed_today = completed_today,
|
||||
open_issues = open_issues,
|
||||
severity_breakdown = severity_breakdown,
|
||||
resolved_today = resolved_today,
|
||||
pending_followups = pending_followups,
|
||||
issues_opened_today = issues_opened_today,
|
||||
pending_verification = pending_verification,
|
||||
stale_in_progress = stale_in_progress,
|
||||
unassigned_open = unassigned_open,
|
||||
inspector_activity = inspector_activity,
|
||||
recent_inspections = recent_inspections,
|
||||
total_facilities = total_facilities,
|
||||
total_templates = total_templates,
|
||||
total_users = total_users,
|
||||
sla_breached = sla_breached,
|
||||
sla_at_risk = sla_at_risk,
|
||||
customer_facilities = customer_facilities,
|
||||
my_issues = my_issues,
|
||||
today_str = now.strftime('%Y-%m-%d'),
|
||||
)
|
||||
|
||||
|
||||
# ── AJAX: facility score trend ────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/facility-trend')
|
||||
@login_required
|
||||
def facility_trend():
|
||||
"""Return daily avg-score data for a single facility over N days.
|
||||
|
||||
Query params:
|
||||
facility_id (int, required)
|
||||
days (int, default 30 — allowed: 30, 60, 90)
|
||||
|
||||
Response JSON:
|
||||
{ labels: ['2026-03-01', ...], data: [85.2, ...], facility: 'Name' }
|
||||
"""
|
||||
from flask import jsonify, request as req
|
||||
|
||||
facility_id = req.args.get('facility_id', type=int)
|
||||
days = req.args.get('days', 30, type=int)
|
||||
if days not in (30, 60, 90):
|
||||
days = 30
|
||||
|
||||
if not facility_id:
|
||||
return jsonify({'labels': [], 'data': [], 'facility': ''})
|
||||
|
||||
# Scope check for customer users
|
||||
if current_user.role == 'customer':
|
||||
cids = get_customer_scope(current_user) or []
|
||||
if facility_id not in cids:
|
||||
return jsonify({'labels': [], 'data': [], 'facility': ''}), 403
|
||||
|
||||
facility = db.session.get(Facility, facility_id)
|
||||
if not facility:
|
||||
return jsonify({'labels': [], 'data': [], 'facility': ''})
|
||||
|
||||
start = now_eastern() - timedelta(days=days)
|
||||
|
||||
rows = (
|
||||
db.session.query(
|
||||
func.date(Inspection.inspection_date).label('day'),
|
||||
func.avg(Inspection.overall_score).label('avg'),
|
||||
)
|
||||
.filter(
|
||||
Inspection.facility_id == facility_id,
|
||||
Inspection.status == 'completed',
|
||||
Inspection.overall_score.isnot(None),
|
||||
Inspection.inspection_date >= start,
|
||||
)
|
||||
.group_by(func.date(Inspection.inspection_date))
|
||||
.order_by(func.date(Inspection.inspection_date))
|
||||
.all()
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'labels': [str(r.day) for r in rows],
|
||||
'data': [round(float(r.avg), 2) for r in rows],
|
||||
'facility': facility.name,
|
||||
})
|
||||
|
||||
|
||||
@bp.route('/support')
|
||||
def support():
|
||||
"""Public support page — no login required. Used as App Store Connect Support URL."""
|
||||
return render_template('support.html', current_year=datetime.utcnow().year)
|
||||
@@ -0,0 +1,238 @@
|
||||
import logging
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.facility import Facility, Area
|
||||
from app.models.project import Project
|
||||
from app.utils.forms import FacilityForm, AreaForm
|
||||
from app.utils.decorators import supervisor_required, admin_required
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
|
||||
from app.utils.scope import get_customer_scope, get_inspector_scope
|
||||
|
||||
bp = Blueprint('facilities', __name__, url_prefix='/facilities')
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
def list_facilities():
|
||||
if current_user.role == 'customer':
|
||||
cids = get_customer_scope(current_user) or []
|
||||
facilities = Facility.query.filter(
|
||||
Facility.id.in_(cids), Facility.active == True
|
||||
).order_by(Facility.name).all()
|
||||
elif current_user.role == 'inspector':
|
||||
fids = get_inspector_scope(current_user) or []
|
||||
facilities = Facility.query.filter(
|
||||
Facility.id.in_(fids), Facility.active == True
|
||||
).order_by(Facility.name).all()
|
||||
else:
|
||||
facilities = Facility.query.order_by(Facility.name).all()
|
||||
|
||||
# Group facilities by Contract (Project) for the collapsible list view.
|
||||
# Facilities with no contract are collected under key '__none__' and
|
||||
# rendered last as "No Contract Assigned".
|
||||
from collections import OrderedDict
|
||||
grouped = OrderedDict()
|
||||
ungrouped = []
|
||||
for f in facilities:
|
||||
if f.project:
|
||||
key = f.project.name
|
||||
grouped.setdefault(key, {'project': f.project, 'facilities': []})
|
||||
grouped[key]['facilities'].append(f)
|
||||
else:
|
||||
ungrouped.append(f)
|
||||
|
||||
grouped = OrderedDict(sorted(grouped.items()))
|
||||
if ungrouped:
|
||||
grouped['__none__'] = {'project': None, 'facilities': ungrouped}
|
||||
|
||||
logger.info('FACILITIES | list | user=%s | total=%s | groups=%s',
|
||||
current_user.username, len(facilities), len(grouped))
|
||||
return render_template('facilities/list.html', facilities=facilities, grouped=grouped)
|
||||
|
||||
@bp.route('/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def create_facility():
|
||||
form = FacilityForm()
|
||||
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||
form.project_id.choices = [(0, '— None —')] + [(p.id, p.name) for p in projects]
|
||||
|
||||
if form.validate_on_submit():
|
||||
project_id = form.project_id.data if form.project_id.data else None
|
||||
facility = Facility(
|
||||
name=form.name.data,
|
||||
address=form.address.data,
|
||||
contact_person=form.contact_person.data,
|
||||
contact_phone=form.contact_phone.data,
|
||||
project_id=project_id if project_id else None,
|
||||
active=form.active.data
|
||||
)
|
||||
|
||||
db.session.add(facility)
|
||||
db.session.commit()
|
||||
logger.info('FACILITIES | create | user=%s | facility_id=%s name=%r',
|
||||
current_user.username, facility.id, facility.name)
|
||||
log_action(ACTION_CREATE, 'Facility', facility.id, facility.name,
|
||||
f'contact={facility.contact_person or ""}; project_id={facility.project_id}; active={facility.active}')
|
||||
flash(f'Facility "{facility.name}" created successfully.', 'success')
|
||||
return redirect(url_for('facilities.view_facility', facility_id=facility.id))
|
||||
|
||||
return render_template('facilities/form.html', form=form, title='Create Facility')
|
||||
|
||||
@bp.route('/<int:facility_id>')
|
||||
@login_required
|
||||
def view_facility(facility_id):
|
||||
facility = db.session.get(Facility, facility_id)
|
||||
if facility is None:
|
||||
abort(404)
|
||||
if current_user.role == 'customer':
|
||||
cids = get_customer_scope(current_user) or []
|
||||
if facility_id not in cids:
|
||||
flash('Access denied.', 'danger')
|
||||
return redirect(url_for('facilities.list_facilities'))
|
||||
areas = facility.areas.order_by(Area.name).all()
|
||||
return render_template('facilities/view.html', facility=facility, areas=areas)
|
||||
|
||||
@bp.route('/<int:facility_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def edit_facility(facility_id):
|
||||
facility = db.session.get(Facility, facility_id)
|
||||
if facility is None:
|
||||
abort(404)
|
||||
form = FacilityForm(obj=facility)
|
||||
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||
form.project_id.choices = [(0, '— None —')] + [(p.id, p.name) for p in projects]
|
||||
|
||||
if form.validate_on_submit():
|
||||
project_id = form.project_id.data if form.project_id.data else None
|
||||
facility.name = form.name.data
|
||||
facility.address = form.address.data
|
||||
facility.contact_person = form.contact_person.data
|
||||
facility.contact_phone = form.contact_phone.data
|
||||
facility.project_id = project_id if project_id else None
|
||||
facility.active = form.active.data
|
||||
|
||||
db.session.commit()
|
||||
logger.info('FACILITIES | edit | user=%s | facility_id=%s name=%r',
|
||||
current_user.username, facility.id, facility.name)
|
||||
log_action(ACTION_UPDATE, 'Facility', facility.id, facility.name,
|
||||
f'project_id={facility.project_id}; active={facility.active}')
|
||||
flash(f'Facility "{facility.name}" updated successfully.', 'success')
|
||||
return redirect(url_for('facilities.view_facility', facility_id=facility.id))
|
||||
|
||||
return render_template('facilities/form.html', form=form, facility=facility, title='Edit Facility')
|
||||
|
||||
@bp.route('/<int:facility_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_facility(facility_id):
|
||||
facility = db.session.get(Facility, facility_id)
|
||||
if facility is None:
|
||||
abort(404)
|
||||
|
||||
if facility.inspections.count() > 0:
|
||||
flash(f'Cannot delete "{facility.name}" — it has existing inspection records.', 'danger')
|
||||
return redirect(url_for('facilities.view_facility', facility_id=facility.id))
|
||||
|
||||
facility_name = facility.name
|
||||
facility_id_snap = facility.id
|
||||
db.session.delete(facility)
|
||||
db.session.commit()
|
||||
logger.info('FACILITIES | delete | user=%s | facility_id=%s name=%r',
|
||||
current_user.username, facility_id_snap, facility_name)
|
||||
log_action(ACTION_DELETE, 'Facility', facility_id_snap, facility_name)
|
||||
flash(f'Facility "{facility_name}" has been permanently deleted.', 'success')
|
||||
return redirect(url_for('facilities.list_facilities'))
|
||||
|
||||
# Area Management Routes
|
||||
|
||||
@bp.route('/<int:facility_id>/areas/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def create_area(facility_id):
|
||||
facility = db.session.get(Facility, facility_id)
|
||||
if facility is None:
|
||||
abort(404)
|
||||
form = AreaForm()
|
||||
form.facility_id.choices = [(facility.id, facility.name)]
|
||||
form.facility_id.data = facility.id
|
||||
|
||||
if form.validate_on_submit():
|
||||
area = Area(
|
||||
name=form.name.data,
|
||||
area_type=form.area_type.data,
|
||||
facility_id=facility.id
|
||||
)
|
||||
|
||||
db.session.add(area)
|
||||
db.session.commit()
|
||||
logger.info('FACILITIES | create_area | user=%s | area_id=%s name=%r facility=%r',
|
||||
current_user.username, area.id, area.name, facility.name)
|
||||
log_action(ACTION_CREATE, 'Area', area.id, area.name,
|
||||
f'facility={facility.name}; type={area.area_type or ""}')
|
||||
flash(f'Area "{area.name}" created successfully.', 'success')
|
||||
return redirect(url_for('facilities.view_facility', facility_id=facility.id))
|
||||
|
||||
return render_template('facilities/area_form.html', form=form, facility=facility, title='Create Area')
|
||||
|
||||
@bp.route('/areas/<int:area_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def edit_area(area_id):
|
||||
area = db.session.get(Area, area_id)
|
||||
if area is None:
|
||||
abort(404)
|
||||
form = AreaForm(obj=area)
|
||||
|
||||
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
|
||||
form.facility_id.choices = [(f.id, f.name) for f in facilities]
|
||||
|
||||
if form.validate_on_submit():
|
||||
area.name = form.name.data
|
||||
area.area_type = form.area_type.data
|
||||
area.facility_id = form.facility_id.data
|
||||
|
||||
db.session.commit()
|
||||
logger.info('FACILITIES | edit_area | user=%s | area_id=%s name=%r',
|
||||
current_user.username, area.id, area.name)
|
||||
log_action(ACTION_UPDATE, 'Area', area.id, area.name,
|
||||
f'facility_id={area.facility_id}; type={area.area_type or ""}')
|
||||
flash(f'Area "{area.name}" updated successfully.', 'success')
|
||||
return redirect(url_for('facilities.view_facility', facility_id=area.facility_id))
|
||||
|
||||
return render_template('facilities/area_form.html', form=form, area=area, facility=area.facility, title='Edit Area')
|
||||
|
||||
@bp.route('/areas/<int:area_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def delete_area(area_id):
|
||||
area = db.session.get(Area, area_id)
|
||||
if area is None:
|
||||
abort(404)
|
||||
facility_id = area.facility_id
|
||||
|
||||
if area.inspections.count() > 0:
|
||||
flash('Cannot delete area with existing inspections.', 'danger')
|
||||
return redirect(url_for('facilities.view_facility', facility_id=facility_id))
|
||||
|
||||
issue_count = area.issues.count()
|
||||
if issue_count > 0:
|
||||
flash(
|
||||
f'Cannot delete area "{area.name}" — it has {issue_count} issue record(s) on file. '
|
||||
f'Resolve or reassign those issues first.',
|
||||
'danger'
|
||||
)
|
||||
return redirect(url_for('facilities.view_facility', facility_id=facility_id))
|
||||
|
||||
area_name = area.name
|
||||
area_id_snap = area.id
|
||||
db.session.delete(area)
|
||||
db.session.commit()
|
||||
logger.info('FACILITIES | delete_area | user=%s | area_id=%s name=%r',
|
||||
current_user.username, area_id_snap, area_name)
|
||||
log_action(ACTION_DELETE, 'Area', area_id_snap, area_name)
|
||||
flash(f'Area "{area_name}" deleted successfully.', 'success')
|
||||
return redirect(url_for('facilities.view_facility', facility_id=facility_id))
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,308 @@
|
||||
# app/routes/notifications.py
|
||||
import logging
|
||||
from flask import (Blueprint, jsonify, request, abort,
|
||||
render_template, redirect, url_for, flash, current_app)
|
||||
from flask_login import login_required, current_user
|
||||
from app import db, csrf
|
||||
from app.models.notification import (
|
||||
Notification, NotificationPreference, ALL_EVENT_TYPES
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('notifications', __name__, url_prefix='/notifications')
|
||||
|
||||
|
||||
# ── Bell feed (navbar dropdown) ───────────────────────────────────────────────
|
||||
|
||||
@bp.route('/feed')
|
||||
@login_required
|
||||
def feed():
|
||||
"""Return the 20 most recent notifications for the current user as JSON."""
|
||||
notifs = (
|
||||
Notification.query
|
||||
.filter_by(user_id=current_user.id)
|
||||
.order_by(Notification.created_at.desc())
|
||||
.limit(20)
|
||||
.all()
|
||||
)
|
||||
unread_count = Notification.query.filter_by(
|
||||
user_id=current_user.id, is_read=False
|
||||
).count()
|
||||
|
||||
items = []
|
||||
for n in notifs:
|
||||
items.append({
|
||||
'id': n.id,
|
||||
'title': n.title,
|
||||
'body': n.body,
|
||||
'link': n.link,
|
||||
'is_read': n.is_read,
|
||||
'created_at': n.created_at.strftime('%b %d, %Y %I:%M %p'),
|
||||
})
|
||||
|
||||
return jsonify({'notifications': items, 'unread_count': unread_count})
|
||||
|
||||
|
||||
# ── Full notification history page ────────────────────────────────────────────
|
||||
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
"""Full paginated notification history with read/unread filter."""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
filter_read = request.args.get('filter', 'all') # 'all' | 'unread' | 'read'
|
||||
|
||||
q = Notification.query.filter_by(user_id=current_user.id)
|
||||
|
||||
if filter_read == 'unread':
|
||||
q = q.filter_by(is_read=False)
|
||||
elif filter_read == 'read':
|
||||
q = q.filter_by(is_read=True)
|
||||
|
||||
notifications = q.order_by(Notification.created_at.desc()).paginate(
|
||||
page=page, per_page=25, error_out=False
|
||||
)
|
||||
unread_count = Notification.query.filter_by(
|
||||
user_id=current_user.id, is_read=False
|
||||
).count()
|
||||
|
||||
return render_template(
|
||||
'notifications/index.html',
|
||||
notifications=notifications,
|
||||
filter_read=filter_read,
|
||||
unread_count=unread_count,
|
||||
)
|
||||
|
||||
|
||||
# ── Mark single notification read ─────────────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:notif_id>/mark-read', methods=['POST'])
|
||||
@login_required
|
||||
def mark_read(notif_id):
|
||||
notif = db.session.get(Notification, notif_id)
|
||||
if notif is None:
|
||||
abort(404)
|
||||
if notif.user_id != current_user.id:
|
||||
abort(403)
|
||||
notif.is_read = True
|
||||
db.session.commit()
|
||||
logger.info(
|
||||
'NOTIFICATION READ | id=%s | user=%s',
|
||||
notif_id, current_user.username,
|
||||
)
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ── Mark all read ─────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/mark-all-read', methods=['POST'])
|
||||
@login_required
|
||||
def mark_all_read():
|
||||
updated = (
|
||||
Notification.query
|
||||
.filter_by(user_id=current_user.id, is_read=False)
|
||||
.update({'is_read': True})
|
||||
)
|
||||
db.session.commit()
|
||||
logger.info(
|
||||
'NOTIFICATIONS ALL READ | user=%s | count=%s',
|
||||
current_user.username, updated,
|
||||
)
|
||||
|
||||
# Support both AJAX (returns JSON) and form POST (redirects to index)
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or \
|
||||
request.content_type == 'application/json':
|
||||
return jsonify({'ok': True, 'marked': updated})
|
||||
return redirect(url_for('notifications.index'))
|
||||
|
||||
|
||||
# ── Notification preferences ──────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/preferences', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def preferences():
|
||||
"""Display and save per-event notification preferences."""
|
||||
if request.method == 'POST':
|
||||
for event_type in ALL_EVENT_TYPES:
|
||||
pref = NotificationPreference.query.filter_by(
|
||||
user_id=current_user.id,
|
||||
event_type=event_type,
|
||||
).first()
|
||||
|
||||
if pref is None:
|
||||
pref = NotificationPreference(
|
||||
user_id=current_user.id,
|
||||
event_type=event_type,
|
||||
)
|
||||
db.session.add(pref)
|
||||
|
||||
pref.email_enabled = bool(request.form.get(f'email_{event_type}'))
|
||||
pref.digest_mode = bool(request.form.get(f'digest_{event_type}'))
|
||||
pref.digest_frequency = request.form.get(f'freq_{event_type}', 'daily')
|
||||
|
||||
# Guard: digest_mode only meaningful when email is enabled
|
||||
if not pref.email_enabled:
|
||||
pref.digest_mode = False
|
||||
|
||||
db.session.commit()
|
||||
logger.info(
|
||||
'NOTIFICATION PREFERENCES SAVED | user=%s',
|
||||
current_user.username,
|
||||
)
|
||||
flash('Notification preferences saved.', 'success')
|
||||
return redirect(url_for('notifications.preferences'))
|
||||
|
||||
# Build a dict keyed by event_type for easy template access
|
||||
prefs_map = {}
|
||||
for pref in NotificationPreference.query.filter_by(user_id=current_user.id).all():
|
||||
prefs_map[pref.event_type] = pref
|
||||
|
||||
return render_template(
|
||||
'notifications/preferences.html',
|
||||
event_types=ALL_EVENT_TYPES,
|
||||
prefs_map=prefs_map,
|
||||
)
|
||||
|
||||
|
||||
# ── Digest trigger (called by cron) ───────────────────────────────────────────
|
||||
|
||||
@bp.route('/send-digest', methods=['POST'])
|
||||
@csrf.exempt
|
||||
def send_digest():
|
||||
"""Trigger digest email delivery. Protected by a shared secret token.
|
||||
|
||||
Called by a cron job, e.g.:
|
||||
# Hourly digest
|
||||
0 * * * * curl -s -X POST https://yourdomain.com/notifications/send-digest \
|
||||
-d "token=YOUR_DIGEST_SECRET&frequency=hourly"
|
||||
|
||||
# Daily digest at 07:00
|
||||
0 7 * * * curl -s -X POST https://yourdomain.com/notifications/send-digest \
|
||||
-d "token=YOUR_DIGEST_SECRET&frequency=daily"
|
||||
"""
|
||||
token = request.form.get('token') or request.args.get('token')
|
||||
frequency = request.form.get('frequency', 'daily')
|
||||
|
||||
expected = current_app.config.get('DIGEST_SECRET')
|
||||
if not expected or token != expected:
|
||||
logger.warning('DIGEST TRIGGER REJECTED | bad or missing token')
|
||||
abort(403)
|
||||
|
||||
if frequency not in ('hourly', 'daily'):
|
||||
abort(400)
|
||||
|
||||
from app.utils.notifications import send_pending_digests
|
||||
sent = send_pending_digests(frequency=frequency)
|
||||
|
||||
logger.info('DIGEST TRIGGERED | frequency=%s | sent=%s', frequency, sent)
|
||||
return jsonify({'ok': True, 'sent': sent, 'frequency': frequency})
|
||||
|
||||
# ── SLA alert trigger (called by cron) ────────────────────────────────────────
|
||||
|
||||
@bp.route('/check-sla', methods=['POST'])
|
||||
@csrf.exempt
|
||||
def check_sla():
|
||||
"""Scan all open issues for SLA breaches and dispatch alerts.
|
||||
|
||||
Protected by the same DIGEST_SECRET token used for digest delivery.
|
||||
Recommended cron schedule — every 30 minutes is sufficient for most
|
||||
deployments; adjust based on your shortest SLA threshold (critical = 4h):
|
||||
|
||||
*/30 * * * * curl -s -X POST https://yourdomain.com/notifications/check-sla \\
|
||||
-d "token=YOUR_DIGEST_SECRET"
|
||||
"""
|
||||
token = request.form.get('token') or request.args.get('token')
|
||||
expected = current_app.config.get('DIGEST_SECRET')
|
||||
|
||||
if not expected or token != expected:
|
||||
logger.warning('SLA CHECK REJECTED | bad or missing token')
|
||||
abort(403)
|
||||
|
||||
from app.utils.sla import send_sla_alerts
|
||||
sent = send_sla_alerts()
|
||||
|
||||
logger.info('SLA CHECK TRIGGERED | notifications_sent=%s', sent)
|
||||
return jsonify({'ok': True, 'notifications_sent': sent})
|
||||
|
||||
|
||||
# ── Expired token cleanup (called by cron) ────────────────────────────────────
|
||||
|
||||
@bp.route('/cleanup-tokens', methods=['POST'])
|
||||
@csrf.exempt
|
||||
def cleanup_tokens():
|
||||
"""Purge expired and revoked refresh tokens from api_refresh_tokens.
|
||||
|
||||
Safe to run frequently — only deletes rows where expires_at has passed
|
||||
OR revoked=True. Keeps the table lean without touching live sessions.
|
||||
|
||||
Recommended cron schedule — nightly is sufficient:
|
||||
|
||||
0 3 * * * curl -s -X POST https://yourdomain.com/notifications/cleanup-tokens \\
|
||||
-d "token=YOUR_DIGEST_SECRET"
|
||||
"""
|
||||
token = request.form.get('token') or request.args.get('token')
|
||||
expected = current_app.config.get('DIGEST_SECRET')
|
||||
|
||||
if not expected or token != expected:
|
||||
logger.warning('TOKEN CLEANUP REJECTED | bad or missing token')
|
||||
abort(403)
|
||||
|
||||
from app.models.api_token import RefreshToken
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
now = now_eastern()
|
||||
deleted = (
|
||||
RefreshToken.query
|
||||
.filter(
|
||||
db.or_(
|
||||
RefreshToken.expires_at < now,
|
||||
RefreshToken.revoked == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
logger.info('TOKEN CLEANUP | deleted=%s expired/revoked rows', deleted)
|
||||
return jsonify({'ok': True, 'deleted': deleted})
|
||||
|
||||
|
||||
# ── Score trend alert trigger (called by cron) ────────────────────────────────
|
||||
|
||||
@bp.route('/check-score-trends', methods=['POST'])
|
||||
@csrf.exempt
|
||||
def check_score_trends():
|
||||
"""Scan facility score trends and dispatch alerts for significant drops.
|
||||
|
||||
Compares each active facility's avg inspection score for the last 30 days
|
||||
against the prior 30-day period. Alerts fire when the drop exceeds the
|
||||
configured threshold (default: 5 percentage points).
|
||||
|
||||
Protected by the same DIGEST_SECRET token used by the other cron endpoints.
|
||||
|
||||
Recommended cron schedule — once per day is sufficient:
|
||||
|
||||
0 8 * * * curl -s -X POST https://yourdomain.com/notifications/check-score-trends \\
|
||||
-d "token=YOUR_DIGEST_SECRET"
|
||||
|
||||
Optional param:
|
||||
threshold=<float> Override the default 5.0-point drop threshold.
|
||||
"""
|
||||
token = request.form.get('token') or request.args.get('token')
|
||||
expected = current_app.config.get('DIGEST_SECRET')
|
||||
|
||||
if not expected or token != expected:
|
||||
logger.warning('SCORE TREND CHECK REJECTED | bad or missing token')
|
||||
abort(403)
|
||||
|
||||
threshold = request.form.get('threshold', type=float) or None
|
||||
|
||||
from app.utils.sla import send_score_alerts
|
||||
kwargs = {}
|
||||
if threshold is not None:
|
||||
kwargs['threshold'] = threshold
|
||||
sent = send_score_alerts(**kwargs)
|
||||
|
||||
logger.info('SCORE TREND CHECK TRIGGERED | alerts_sent=%s', sent)
|
||||
return jsonify({'ok': True, 'alerts_sent': sent})
|
||||
@@ -0,0 +1,668 @@
|
||||
"""
|
||||
app/routes/projects.py
|
||||
----------------------
|
||||
Project management routes.
|
||||
|
||||
Access matrix:
|
||||
- List / view : admin, supervisor, project_manager
|
||||
- Create / edit / delete : admin, supervisor
|
||||
- Customer assignment management : admin
|
||||
"""
|
||||
|
||||
import logging
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort, Response
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.project import Project, CustomerAssignment
|
||||
from app.models.facility import Facility
|
||||
from app.models.user import User
|
||||
from app.utils.forms import ProjectForm, CustomerAssignmentForm
|
||||
from app.utils.decorators import admin_required, supervisor_required, project_manager_required
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('projects', __name__, url_prefix='/projects')
|
||||
|
||||
|
||||
# ── List ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
@project_manager_required
|
||||
def index():
|
||||
projects = Project.query.order_by(Project.name).all()
|
||||
return render_template('projects/list.html', projects=projects)
|
||||
|
||||
|
||||
# ── Create ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def create():
|
||||
form = ProjectForm()
|
||||
# Populate project_manager choices: users with role project_manager
|
||||
pm_users = User.query.filter_by(role='project_manager', active=True).order_by(User.username).all()
|
||||
form.project_manager_id.choices = [(0, '— None —')] + [(u.id, u.username) for u in pm_users]
|
||||
|
||||
if form.validate_on_submit():
|
||||
pm_id = form.project_manager_id.data or None
|
||||
project = Project(
|
||||
name=form.name.data,
|
||||
description=form.description.data,
|
||||
project_manager_id=pm_id if pm_id else None,
|
||||
active=form.active.data,
|
||||
)
|
||||
db.session.add(project)
|
||||
db.session.commit()
|
||||
logger.info('PROJECTS | create | user=%s project_id=%s name=%s',
|
||||
current_user.username, project.id, project.name)
|
||||
log_action(ACTION_CREATE, 'Project', project.id, project.name,
|
||||
f'pm_id={pm_id}; active={project.active}')
|
||||
flash(f'Contract "{project.name}" created successfully.', 'success')
|
||||
return redirect(url_for('projects.view', project_id=project.id))
|
||||
|
||||
return render_template('projects/form.html', form=form, title='Create Contract')
|
||||
|
||||
|
||||
# ── View ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:project_id>')
|
||||
@login_required
|
||||
@project_manager_required
|
||||
def view(project_id):
|
||||
project = db.session.get(Project, project_id)
|
||||
if project is None:
|
||||
abort(404)
|
||||
facilities = project.facilities.order_by(Facility.name).all()
|
||||
assignments = (
|
||||
CustomerAssignment.query
|
||||
.filter_by(project_id=project_id)
|
||||
.join(User, CustomerAssignment.user_id == User.id)
|
||||
.order_by(User.username)
|
||||
.all()
|
||||
)
|
||||
return render_template(
|
||||
'projects/view.html',
|
||||
project=project,
|
||||
facilities=facilities,
|
||||
assignments=assignments,
|
||||
)
|
||||
|
||||
|
||||
# ── Edit ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:project_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def edit(project_id):
|
||||
project = db.session.get(Project, project_id)
|
||||
if project is None:
|
||||
abort(404)
|
||||
form = ProjectForm(obj=project)
|
||||
pm_users = User.query.filter_by(role='project_manager', active=True).order_by(User.username).all()
|
||||
form.project_manager_id.choices = [(0, '— None —')] + [(u.id, u.username) for u in pm_users]
|
||||
|
||||
if form.validate_on_submit():
|
||||
pm_id = form.project_manager_id.data or None
|
||||
project.name = form.name.data
|
||||
project.description = form.description.data
|
||||
project.project_manager_id = pm_id if pm_id else None
|
||||
project.active = form.active.data
|
||||
db.session.commit()
|
||||
logger.info('PROJECTS | edit | user=%s project_id=%s name=%s',
|
||||
current_user.username, project.id, project.name)
|
||||
log_action(ACTION_UPDATE, 'Project', project.id, project.name,
|
||||
f'pm_id={project.project_manager_id}; active={project.active}')
|
||||
flash(f'Contract "{project.name}" updated successfully.', 'success')
|
||||
return redirect(url_for('projects.view', project_id=project.id))
|
||||
|
||||
return render_template('projects/form.html', form=form, project=project, title='Edit Contract')
|
||||
|
||||
|
||||
# ── Delete ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:project_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete(project_id):
|
||||
project = db.session.get(Project, project_id)
|
||||
if project is None:
|
||||
abort(404)
|
||||
|
||||
if project.facilities.count() > 0:
|
||||
flash(f'Cannot delete "{project.name}" — it has linked facilities. '
|
||||
'Reassign or remove those facilities first.', 'danger')
|
||||
return redirect(url_for('projects.view', project_id=project_id))
|
||||
|
||||
project_name = project.name
|
||||
project_id_snap = project.id
|
||||
db.session.delete(project)
|
||||
db.session.commit()
|
||||
logger.info('PROJECTS | delete | user=%s project_id=%s name=%s',
|
||||
current_user.username, project_id_snap, project_name)
|
||||
log_action(ACTION_DELETE, 'Project', project_id_snap, project_name)
|
||||
flash(f'Contract "{project_name}" deleted successfully.', 'success')
|
||||
return redirect(url_for('projects.index'))
|
||||
|
||||
|
||||
# ── Customer Assignment — Add ─────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:project_id>/assignments/add', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def add_assignment(project_id):
|
||||
project = db.session.get(Project, project_id)
|
||||
if project is None:
|
||||
abort(404)
|
||||
form = CustomerAssignmentForm()
|
||||
|
||||
# Customer users only
|
||||
customers = User.query.filter_by(role='customer', active=True).order_by(User.username).all()
|
||||
form.user_id.choices = [(u.id, f'{u.username} ({u.email})') for u in customers]
|
||||
|
||||
# Facilities belonging to this project
|
||||
project_facilities = project.facilities.order_by(Facility.name).all()
|
||||
form.facility_id.choices = [(0, '— All facilities in contract —')] + \
|
||||
[(f.id, f.name) for f in project_facilities]
|
||||
|
||||
if form.validate_on_submit():
|
||||
facility_id = form.facility_id.data if form.facility_id.data else None
|
||||
|
||||
# Guard against duplicate assignments
|
||||
existing = CustomerAssignment.query.filter_by(
|
||||
user_id=form.user_id.data,
|
||||
project_id=project_id,
|
||||
facility_id=facility_id,
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
flash('This customer assignment already exists.', 'warning')
|
||||
return redirect(url_for('projects.view', project_id=project_id))
|
||||
|
||||
assignment = CustomerAssignment(
|
||||
user_id=form.user_id.data,
|
||||
project_id=project_id,
|
||||
facility_id=facility_id,
|
||||
)
|
||||
db.session.add(assignment)
|
||||
db.session.commit()
|
||||
|
||||
user = db.session.get(User, form.user_id.data)
|
||||
scope_label = f'facility_id={facility_id}' if facility_id else 'all facilities'
|
||||
logger.info('PROJECTS | assignment_add | admin=%s customer=%s project_id=%s scope=%s',
|
||||
current_user.username, user.username, project_id, scope_label)
|
||||
log_action(ACTION_CREATE, 'CustomerAssignment', assignment.id,
|
||||
f'{user.username} → {project.name}',
|
||||
f'scope={scope_label}')
|
||||
flash(f'Customer "{user.username}" assigned to contract "{project.name}".', 'success')
|
||||
return redirect(url_for('projects.view', project_id=project_id))
|
||||
|
||||
return render_template(
|
||||
'projects/assignment_form.html',
|
||||
form=form,
|
||||
project=project,
|
||||
title='Add Customer Assignment',
|
||||
)
|
||||
|
||||
|
||||
# ── Customer Assignment — Remove ──────────────────────────────────────────────
|
||||
|
||||
@bp.route('/assignments/<int:assignment_id>/remove', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def remove_assignment(assignment_id):
|
||||
assignment = db.session.get(CustomerAssignment, assignment_id)
|
||||
if assignment is None:
|
||||
abort(404)
|
||||
project_id = assignment.project_id
|
||||
project = db.session.get(Project, project_id)
|
||||
if project is None:
|
||||
abort(404)
|
||||
user = db.session.get(User, assignment.user_id)
|
||||
|
||||
username = user.username if user else f'user_id={assignment.user_id}'
|
||||
assignment_id_snap = assignment.id
|
||||
|
||||
db.session.delete(assignment)
|
||||
db.session.commit()
|
||||
|
||||
logger.info('PROJECTS | assignment_remove | admin=%s customer=%s project_id=%s',
|
||||
current_user.username, username, project_id)
|
||||
log_action(ACTION_DELETE, 'CustomerAssignment', assignment_id_snap,
|
||||
f'{username} → {project.name}')
|
||||
flash(f'Assignment for "{username}" removed.', 'success')
|
||||
return redirect(url_for('projects.view', project_id=project_id))
|
||||
|
||||
|
||||
# ── Bulk Import — Excel template download ─────────────────────────────────────
|
||||
|
||||
@bp.route('/import/template')
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def import_template():
|
||||
"""Download a blank .xlsx showing the expected import format."""
|
||||
import io
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment
|
||||
|
||||
wb = Workbook()
|
||||
|
||||
# ── Sheet 1: Contracts ────────────────────────────────────────────────
|
||||
ws_c = wb.active
|
||||
ws_c.title = 'Contracts'
|
||||
hdr_fill = PatternFill('solid', start_color='1F4E79')
|
||||
hdr_font = Font(bold=True, color='FFFFFF')
|
||||
hdr_align = Alignment(horizontal='center', vertical='center')
|
||||
|
||||
contract_headers = ['contract_name', 'description', 'active']
|
||||
for col, h in enumerate(contract_headers, 1):
|
||||
cell = ws_c.cell(row=1, column=col, value=h)
|
||||
cell.font = hdr_font
|
||||
cell.fill = hdr_fill
|
||||
cell.alignment = hdr_align
|
||||
|
||||
# Example rows
|
||||
ws_c.append(['Acme Corp - Downtown', 'Main office complex cleaning contract', 'yes'])
|
||||
ws_c.append(['Acme Corp - Warehouse', '', 'yes'])
|
||||
|
||||
ws_c.column_dimensions['A'].width = 30
|
||||
ws_c.column_dimensions['B'].width = 40
|
||||
ws_c.column_dimensions['C'].width = 10
|
||||
|
||||
# ── Sheet 2: Facilities ───────────────────────────────────────────────
|
||||
ws_f = wb.create_sheet('Facilities')
|
||||
facility_headers = [
|
||||
'contract_name', 'facility_name', 'address',
|
||||
'contact_person', 'contact_phone', 'active',
|
||||
]
|
||||
for col, h in enumerate(facility_headers, 1):
|
||||
cell = ws_f.cell(row=1, column=col, value=h)
|
||||
cell.font = hdr_font
|
||||
cell.fill = hdr_fill
|
||||
cell.alignment = hdr_align
|
||||
|
||||
ws_f.append(['Acme Corp - Downtown', 'Tower A', '123 Main St, Suite 100', 'Jane Smith', '555-0101', 'yes'])
|
||||
ws_f.append(['Acme Corp - Downtown', 'Parking Garage', '123 Main St, Level B1', '', '', 'yes'])
|
||||
ws_f.append(['Acme Corp - Warehouse', 'Bay 1', '456 Industrial Blvd', 'Bob Jones', '555-0202', 'yes'])
|
||||
|
||||
ws_f.column_dimensions['A'].width = 30
|
||||
ws_f.column_dimensions['B'].width = 25
|
||||
ws_f.column_dimensions['C'].width = 35
|
||||
ws_f.column_dimensions['D'].width = 20
|
||||
ws_f.column_dimensions['E'].width = 15
|
||||
ws_f.column_dimensions['F'].width = 10
|
||||
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
buf.seek(0)
|
||||
return Response(
|
||||
buf.getvalue(),
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
headers={'Content-Disposition': 'attachment; filename="contract_facility_import_template.xlsx"'},
|
||||
)
|
||||
|
||||
|
||||
# ── Bulk Import — upload → preview → confirm ──────────────────────────────────
|
||||
|
||||
@bp.route('/import', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def bulk_import():
|
||||
"""Two-phase Excel import for Contracts (Projects) and Facilities.
|
||||
|
||||
Phase 1 (GET / POST with file):
|
||||
Parse and validate the workbook, return a preview. No DB writes.
|
||||
|
||||
Phase 2 (POST with confirmed=1):
|
||||
Write all validated rows to the database.
|
||||
|
||||
Excel format (two sheets)
|
||||
--------------------------
|
||||
Sheet "Contracts": contract_name*, description, active
|
||||
Sheet "Facilities": contract_name*, facility_name*, address,
|
||||
contact_person, contact_phone, active
|
||||
|
||||
* required columns.
|
||||
- "active" column: any of yes/true/1 → True; blank defaults to True.
|
||||
- Contracts that already exist (by name, case-insensitive) are reused,
|
||||
not duplicated.
|
||||
- Facilities that already exist (same name within same contract) are
|
||||
skipped and reported.
|
||||
"""
|
||||
import io, json
|
||||
from flask import session as _session
|
||||
|
||||
# ── Phase 2: commit ───────────────────────────────────────────────────
|
||||
if request.method == 'POST' and request.form.get('confirmed') == '1':
|
||||
rows_json = request.form.get('rows_json', '[]')
|
||||
try:
|
||||
rows = json.loads(rows_json)
|
||||
except Exception:
|
||||
flash('Import session expired. Please re-upload the file.', 'danger')
|
||||
return redirect(url_for('projects.bulk_import'))
|
||||
|
||||
created_contracts = 0
|
||||
reused_contracts = 0
|
||||
created_facilities = 0
|
||||
skipped_facilities = 0
|
||||
|
||||
# Cache contracts created/found in this batch
|
||||
contract_cache = {} # lower-name → Project
|
||||
|
||||
for row in rows:
|
||||
cname = row['contract_name']
|
||||
cdesc = row.get('description') or None
|
||||
c_active = row.get('contract_active', True)
|
||||
fname = row.get('facility_name') or None
|
||||
|
||||
# Get or create contract
|
||||
cache_key = cname.strip().lower()
|
||||
project = contract_cache.get(cache_key)
|
||||
if project is None:
|
||||
project = Project.query.filter(
|
||||
db.func.lower(Project.name) == cache_key
|
||||
).first()
|
||||
|
||||
if project is None:
|
||||
project = Project(
|
||||
name = cname.strip(),
|
||||
description = cdesc,
|
||||
active = c_active,
|
||||
)
|
||||
db.session.add(project)
|
||||
db.session.flush()
|
||||
contract_cache[cache_key] = project
|
||||
created_contracts += 1
|
||||
log_action(ACTION_CREATE, 'Project', project.id, project.name,
|
||||
f'active={c_active}; source=bulk_import')
|
||||
logger.info('BULK IMPORT | contract_created | name=%s by=%s',
|
||||
project.name, current_user.username)
|
||||
else:
|
||||
contract_cache[cache_key] = project
|
||||
reused_contracts += 1
|
||||
|
||||
# Create facility if present in this row
|
||||
if fname:
|
||||
faddr = row.get('address') or None
|
||||
fcp = row.get('contact_person') or None
|
||||
fphone = row.get('contact_phone') or None
|
||||
f_active = row.get('facility_active', True)
|
||||
|
||||
existing_fac = Facility.query.filter(
|
||||
Facility.project_id == project.id,
|
||||
db.func.lower(Facility.name) == fname.strip().lower(),
|
||||
).first()
|
||||
|
||||
if existing_fac:
|
||||
skipped_facilities += 1
|
||||
else:
|
||||
facility = Facility(
|
||||
name = fname.strip(),
|
||||
address = faddr,
|
||||
contact_person = fcp,
|
||||
contact_phone = fphone,
|
||||
active = f_active,
|
||||
project_id = project.id,
|
||||
)
|
||||
db.session.add(facility)
|
||||
db.session.flush()
|
||||
created_facilities += 1
|
||||
log_action(ACTION_CREATE, 'Facility', facility.id, facility.name,
|
||||
f'project_id={project.id}; source=bulk_import')
|
||||
logger.info('BULK IMPORT | facility_created | name=%s project_id=%s by=%s',
|
||||
facility.name, project.id, current_user.username)
|
||||
|
||||
db.session.commit()
|
||||
logger.info(
|
||||
'BULK IMPORT COMMITTED | by=%s | contracts_new=%s contracts_reused=%s '
|
||||
'facilities_new=%s facilities_skipped=%s',
|
||||
current_user.username, created_contracts, reused_contracts,
|
||||
created_facilities, skipped_facilities,
|
||||
)
|
||||
|
||||
parts = []
|
||||
if created_contracts:
|
||||
parts.append(f'{created_contracts} contract(s) created')
|
||||
if reused_contracts:
|
||||
parts.append(f'{reused_contracts} existing contract(s) reused')
|
||||
if created_facilities:
|
||||
parts.append(f'{created_facilities} facilit{"y" if created_facilities == 1 else "ies"} created')
|
||||
if skipped_facilities:
|
||||
parts.append(f'{skipped_facilities} duplicate facilit{"y" if skipped_facilities == 1 else "ies"} skipped')
|
||||
flash('Import complete: ' + ', '.join(parts) + '.', 'success')
|
||||
return redirect(url_for('projects.index'))
|
||||
|
||||
# ── Phase 1: parse and validate ───────────────────────────────────────
|
||||
preview_rows = []
|
||||
errors = []
|
||||
raw_valid_rows = []
|
||||
|
||||
if request.method == 'POST':
|
||||
file = request.files.get('xlsx_file')
|
||||
|
||||
if not file or not file.filename:
|
||||
flash('Please select an Excel file to upload.', 'warning')
|
||||
return render_template('projects/import.html')
|
||||
|
||||
if not file.filename.lower().endswith(('.xlsx', '.xlsm')):
|
||||
flash('Only .xlsx / .xlsm files are accepted.', 'danger')
|
||||
return render_template('projects/import.html')
|
||||
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
wb = load_workbook(filename=io.BytesIO(file.stream.read()), data_only=True)
|
||||
except Exception as exc:
|
||||
flash(f'Could not open workbook: {exc}', 'danger')
|
||||
return render_template('projects/import.html')
|
||||
|
||||
# ── Parse Contracts sheet ─────────────────────────────────────────
|
||||
if 'Contracts' not in wb.sheetnames:
|
||||
flash('Workbook is missing the "Contracts" sheet. Download the template and try again.', 'danger')
|
||||
return render_template('projects/import.html')
|
||||
|
||||
ws_c = wb['Contracts']
|
||||
c_rows = list(ws_c.iter_rows(values_only=True))
|
||||
if not c_rows:
|
||||
flash('"Contracts" sheet is empty.', 'danger')
|
||||
return render_template('projects/import.html')
|
||||
|
||||
c_headers = [str(h).strip().lower() if h else '' for h in c_rows[0]]
|
||||
if 'contract_name' not in c_headers:
|
||||
flash('"Contracts" sheet is missing required column "contract_name".', 'danger')
|
||||
return render_template('projects/import.html')
|
||||
|
||||
def col(headers, name):
|
||||
try:
|
||||
return headers.index(name)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
c_name_idx = col(c_headers, 'contract_name')
|
||||
c_desc_idx = col(c_headers, 'description')
|
||||
c_active_idx = col(c_headers, 'active')
|
||||
|
||||
# name → {description, active} for valid contracts found in sheet
|
||||
contract_sheet = {} # lower-name → dict
|
||||
contract_errors = []
|
||||
|
||||
for i, row in enumerate(c_rows[1:], start=2):
|
||||
cname = str(row[c_name_idx]).strip() if row[c_name_idx] is not None else ''
|
||||
if not cname or cname.lower() == 'none':
|
||||
contract_errors.append(f'Row {i}: contract_name is required')
|
||||
continue
|
||||
raw_active = row[c_active_idx] if c_active_idx is not None else None
|
||||
c_active = _parse_bool(raw_active, default=True)
|
||||
cdesc = str(row[c_desc_idx]).strip() if (c_desc_idx is not None and row[c_desc_idx] is not None) else ''
|
||||
contract_sheet[cname.lower()] = {
|
||||
'contract_name': cname,
|
||||
'description': cdesc or None,
|
||||
'contract_active': c_active,
|
||||
}
|
||||
|
||||
# ── Parse Facilities sheet ────────────────────────────────────────
|
||||
facility_sheet_rows = []
|
||||
if 'Facilities' in wb.sheetnames:
|
||||
ws_f = wb['Facilities']
|
||||
f_rows = list(ws_f.iter_rows(values_only=True))
|
||||
if f_rows:
|
||||
f_headers = [str(h).strip().lower() if h else '' for h in f_rows[0]]
|
||||
f_cname_idx = col(f_headers, 'contract_name')
|
||||
f_fname_idx = col(f_headers, 'facility_name')
|
||||
f_addr_idx = col(f_headers, 'address')
|
||||
f_cp_idx = col(f_headers, 'contact_person')
|
||||
f_phone_idx = col(f_headers, 'contact_phone')
|
||||
f_active_idx = col(f_headers, 'active')
|
||||
|
||||
if f_cname_idx is None or f_fname_idx is None:
|
||||
flash('"Facilities" sheet is missing required columns "contract_name" or "facility_name".', 'danger')
|
||||
return render_template('projects/import.html')
|
||||
|
||||
for i, row in enumerate(f_rows[1:], start=2):
|
||||
cname = str(row[f_cname_idx]).strip() if row[f_cname_idx] is not None else ''
|
||||
fname = str(row[f_fname_idx]).strip() if row[f_fname_idx] is not None else ''
|
||||
if not cname or cname.lower() == 'none':
|
||||
continue # skip blank rows silently
|
||||
if not fname or fname.lower() == 'none':
|
||||
continue
|
||||
raw_active = row[f_active_idx] if f_active_idx is not None else None
|
||||
facility_sheet_rows.append({
|
||||
'sheet_row': i,
|
||||
'contract_name': cname,
|
||||
'facility_name': fname,
|
||||
'address': str(row[f_addr_idx]).strip() if (f_addr_idx is not None and row[f_addr_idx]) else '',
|
||||
'contact_person': str(row[f_cp_idx]).strip() if (f_cp_idx is not None and row[f_cp_idx]) else '',
|
||||
'contact_phone': str(row[f_phone_idx]).strip() if (f_phone_idx is not None and row[f_phone_idx]) else '',
|
||||
'facility_active': _parse_bool(raw_active, default=True),
|
||||
})
|
||||
|
||||
# ── Build preview rows ────────────────────────────────────────────
|
||||
#
|
||||
# Strategy: one preview row per (contract) from Contracts sheet,
|
||||
# then one preview row per facility from Facilities sheet.
|
||||
# Validation: facility's contract_name must appear in Contracts sheet.
|
||||
|
||||
existing_projects = {
|
||||
p.name.strip().lower(): p
|
||||
for p in Project.query.all()
|
||||
}
|
||||
|
||||
row_num = 0
|
||||
|
||||
# Contract rows
|
||||
for lower_name, cdata in contract_sheet.items():
|
||||
row_num += 1
|
||||
row_errors = []
|
||||
if lower_name in existing_projects:
|
||||
status = 'exists'
|
||||
note = 'Contract already exists — will be reused'
|
||||
else:
|
||||
status = 'ok'
|
||||
note = ''
|
||||
preview_rows.append({
|
||||
'row': row_num,
|
||||
'sheet': 'Contracts',
|
||||
'contract_name': cdata['contract_name'],
|
||||
'facility_name': '',
|
||||
'note': note,
|
||||
'status': status,
|
||||
'errors': row_errors,
|
||||
})
|
||||
if status != 'error':
|
||||
raw_valid_rows.append({
|
||||
'contract_name': cdata['contract_name'],
|
||||
'description': cdata['description'],
|
||||
'contract_active': cdata['contract_active'],
|
||||
'facility_name': None,
|
||||
})
|
||||
|
||||
# Facility rows
|
||||
seen_facilities = set() # (lower_contract, lower_facility) within file
|
||||
for frow in facility_sheet_rows:
|
||||
row_num += 1
|
||||
row_errors = []
|
||||
lower_c = frow['contract_name'].lower()
|
||||
lower_f = frow['facility_name'].lower()
|
||||
|
||||
if lower_c not in contract_sheet:
|
||||
row_errors.append(
|
||||
f'Contract "{frow["contract_name"]}" not found in the Contracts sheet'
|
||||
)
|
||||
|
||||
dup_key = (lower_c, lower_f)
|
||||
if dup_key in seen_facilities:
|
||||
row_errors.append('Duplicate facility name within this contract in the file')
|
||||
else:
|
||||
seen_facilities.add(dup_key)
|
||||
|
||||
# Check DB for existing facility with same name in same contract
|
||||
db_conflict = False
|
||||
if not row_errors:
|
||||
proj = existing_projects.get(lower_c)
|
||||
if proj:
|
||||
db_conflict = Facility.query.filter(
|
||||
Facility.project_id == proj.id,
|
||||
db.func.lower(Facility.name) == lower_f,
|
||||
).first() is not None
|
||||
|
||||
if db_conflict:
|
||||
status = 'exists'
|
||||
note = 'Facility already exists in this contract — will be skipped'
|
||||
elif row_errors:
|
||||
status = 'error'
|
||||
note = ''
|
||||
else:
|
||||
status = 'ok'
|
||||
note = ''
|
||||
|
||||
preview_rows.append({
|
||||
'row': frow['sheet_row'],
|
||||
'sheet': 'Facilities',
|
||||
'contract_name': frow['contract_name'],
|
||||
'facility_name': frow['facility_name'],
|
||||
'note': note,
|
||||
'status': status,
|
||||
'errors': row_errors,
|
||||
})
|
||||
if status == 'ok':
|
||||
raw_valid_rows.append({
|
||||
'contract_name': frow['contract_name'],
|
||||
'description': None,
|
||||
'contract_active': True,
|
||||
'facility_name': frow['facility_name'],
|
||||
'address': frow['address'] or None,
|
||||
'contact_person': frow['contact_person'] or None,
|
||||
'contact_phone': frow['contact_phone'] or None,
|
||||
'facility_active': frow['facility_active'],
|
||||
})
|
||||
|
||||
valid_count = sum(1 for r in preview_rows if r['status'] == 'ok')
|
||||
has_errors = any(r['status'] == 'error' for r in preview_rows)
|
||||
rows_json = json.dumps(raw_valid_rows)
|
||||
|
||||
# Contract-sheet parse errors shown as flash
|
||||
for ce in contract_errors:
|
||||
flash(ce, 'warning')
|
||||
|
||||
return render_template(
|
||||
'projects/import.html',
|
||||
preview_rows = preview_rows,
|
||||
valid_count = valid_count,
|
||||
has_errors = has_errors,
|
||||
rows_json = rows_json,
|
||||
)
|
||||
|
||||
return render_template('projects/import.html')
|
||||
|
||||
|
||||
def _parse_bool(value, default=True):
|
||||
"""Convert Excel cell value to Python bool for 'active' columns."""
|
||||
if value is None:
|
||||
return default
|
||||
s = str(value).strip().lower()
|
||||
if s in ('yes', 'true', '1', 'y'):
|
||||
return True
|
||||
if s in ('no', 'false', '0', 'n'):
|
||||
return False
|
||||
return default
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,524 @@
|
||||
"""
|
||||
app/routes/scheduled_reports.py
|
||||
--------------------------------
|
||||
CRUD management for ScheduledReport configs + cron-triggered send endpoint.
|
||||
|
||||
Admin/supervisor access for management.
|
||||
The /send route is token-protected (same DIGEST_SECRET) for cron use.
|
||||
|
||||
Cron examples
|
||||
-------------
|
||||
# Daily at 07:00
|
||||
0 7 * * * curl -s -X POST https://yourdomain.com/scheduled-reports/send \
|
||||
-d "token=YOUR_DIGEST_SECRET&frequency=daily"
|
||||
|
||||
# Weekly on Monday 07:00
|
||||
0 7 * * 1 curl -s -X POST https://yourdomain.com/scheduled-reports/send \
|
||||
-d "token=YOUR_DIGEST_SECRET&frequency=weekly"
|
||||
|
||||
# Monthly on the 1st at 07:00
|
||||
0 7 1 * * curl -s -X POST https://yourdomain.com/scheduled-reports/send \
|
||||
-d "token=YOUR_DIGEST_SECRET&frequency=monthly"
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from flask import (Blueprint, render_template, redirect, url_for, flash,
|
||||
request, jsonify, current_app, abort)
|
||||
from flask_login import login_required, current_user
|
||||
from flask_mail import Message
|
||||
from sqlalchemy import func
|
||||
|
||||
from app import db, mail, csrf
|
||||
from app.models.scheduled_report import ScheduledReport
|
||||
from app.models.inspection import Inspection, InspectionTemplate
|
||||
from app.models.facility import Facility, Area
|
||||
from app.models.issue import Issue
|
||||
from app.models.user import User
|
||||
from app.utils.decorators import supervisor_required, admin_required
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
|
||||
from app.utils.time_utils import now_eastern
|
||||
from app.utils.sla import sla_status
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('scheduled_reports', __name__, url_prefix='/scheduled-reports')
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _compute_next_send(frequency: str, from_dt: datetime = None) -> datetime:
|
||||
"""Return the next send datetime for a given frequency.
|
||||
|
||||
Monthly cadence always targets the 1st of the next month at 07:00.
|
||||
The December branch is required because datetime.replace(month=13)
|
||||
raises ValueError — incrementing the month directly is not safe in
|
||||
general, but targeting day=1 avoids the separate last-day-of-month
|
||||
(28/29/30/31) edge case that would affect mid-month scheduling.
|
||||
"""
|
||||
now = from_dt or now_eastern()
|
||||
if frequency == 'daily':
|
||||
return (now + timedelta(days=1)).replace(hour=7, minute=0, second=0, microsecond=0)
|
||||
if frequency == 'weekly':
|
||||
return (now + timedelta(weeks=1)).replace(hour=7, minute=0, second=0, microsecond=0)
|
||||
# monthly: first of next month — December rolls over to Jan of next year.
|
||||
if now.month == 12:
|
||||
return now.replace(year=now.year + 1, month=1, day=1, hour=7, minute=0, second=0, microsecond=0)
|
||||
return now.replace(month=now.month + 1, day=1, hour=7, minute=0, second=0, microsecond=0)
|
||||
|
||||
|
||||
def _date_window(frequency: str):
|
||||
"""Return (start, end) covering the period just elapsed for this frequency."""
|
||||
end = now_eastern()
|
||||
if frequency == 'daily':
|
||||
start = end - timedelta(days=1)
|
||||
elif frequency == 'weekly':
|
||||
start = end - timedelta(weeks=1)
|
||||
else:
|
||||
start = end - timedelta(days=30)
|
||||
return start, end
|
||||
|
||||
|
||||
def _build_report_data(report: ScheduledReport, start: datetime, end: datetime) -> dict:
|
||||
"""Assemble the data dict passed to the email template."""
|
||||
data = {
|
||||
'report': report,
|
||||
'start': start,
|
||||
'end': end,
|
||||
'facility': report.facility,
|
||||
}
|
||||
|
||||
fid_filter = [report.facility_id] if report.facility_id else None
|
||||
|
||||
def _si(q):
|
||||
if fid_filter:
|
||||
return q.filter(Inspection.facility_id.in_(fid_filter))
|
||||
return q
|
||||
|
||||
def _iq(q):
|
||||
if fid_filter:
|
||||
return q.outerjoin(Area, Issue.area_id == Area.id).filter(
|
||||
db.or_(
|
||||
Issue.facility_id.in_(fid_filter),
|
||||
Area.facility_id.in_(fid_filter)
|
||||
)
|
||||
)
|
||||
return q
|
||||
|
||||
if report.report_type in ('summary', 'facility'):
|
||||
base = _si(Inspection.query.filter(
|
||||
Inspection.inspection_date >= start,
|
||||
Inspection.inspection_date <= end,
|
||||
))
|
||||
data['total_inspections'] = base.count()
|
||||
data['completed'] = base.filter(Inspection.status == 'completed').count()
|
||||
|
||||
avg = db.session.query(func.avg(Inspection.overall_score)).filter(
|
||||
Inspection.inspection_date >= start,
|
||||
Inspection.inspection_date <= end,
|
||||
Inspection.status == 'completed',
|
||||
Inspection.overall_score.isnot(None),
|
||||
)
|
||||
avg_val = _si(avg).scalar()
|
||||
data['avg_score'] = round(float(avg_val), 2) if avg_val else None
|
||||
|
||||
data['open_issues'] = _iq(Issue.query.filter(
|
||||
Issue.status.in_(['open', 'in_progress'])
|
||||
)).count()
|
||||
|
||||
data['critical_issues'] = _iq(Issue.query.filter(
|
||||
Issue.severity.in_(['critical', 'high']),
|
||||
Issue.status != 'resolved',
|
||||
)).order_by(Issue.reported_at.desc()).limit(10).all()
|
||||
|
||||
data['facility_scores'] = db.session.query(
|
||||
Facility.name,
|
||||
func.avg(Inspection.overall_score).label('avg'),
|
||||
func.count(Inspection.id).label('count'),
|
||||
).join(Inspection, Facility.id == Inspection.facility_id).filter(
|
||||
Inspection.inspection_date >= start,
|
||||
Inspection.inspection_date <= end,
|
||||
Inspection.status == 'completed',
|
||||
Inspection.overall_score.isnot(None),
|
||||
)
|
||||
if fid_filter:
|
||||
data['facility_scores'] = data['facility_scores'].filter(Facility.id.in_(fid_filter))
|
||||
data['facility_scores'] = data['facility_scores'].group_by(Facility.id, Facility.name)\
|
||||
.order_by(func.avg(Inspection.overall_score).desc()).all()
|
||||
|
||||
if report.report_type == 'issues':
|
||||
all_issues = _iq(Issue.query.filter(
|
||||
Issue.status != 'resolved',
|
||||
)).order_by(Issue.severity.desc(), Issue.reported_at.asc()).all()
|
||||
|
||||
data['issues'] = all_issues
|
||||
|
||||
# Group by facility with per-issue SLA status for the enhanced email template
|
||||
fac_map = {}
|
||||
sla_breached = sla_at_risk = 0
|
||||
for issue in all_issues:
|
||||
fac = issue.resolved_facility
|
||||
fname = fac.name if fac else '(No Facility)'
|
||||
s = sla_status(issue)
|
||||
if s == 'breached':
|
||||
sla_breached += 1
|
||||
elif s == 'at_risk':
|
||||
sla_at_risk += 1
|
||||
fac_map.setdefault(fname, []).append((issue, s))
|
||||
|
||||
data['issues_by_facility'] = sorted(fac_map.items())
|
||||
data['sla_breached'] = sla_breached
|
||||
data['sla_at_risk'] = sla_at_risk
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _build_csv(report: ScheduledReport, start: datetime, end: datetime) -> bytes:
|
||||
"""Return CSV bytes appropriate for the report type."""
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
|
||||
fid_filter = [report.facility_id] if report.facility_id else None
|
||||
|
||||
if report.report_type == 'issues':
|
||||
writer.writerow(['ID', 'Reported At', 'Facility', 'Area', 'Severity',
|
||||
'Description', 'Status', 'Assigned To'])
|
||||
q = Issue.query
|
||||
if fid_filter:
|
||||
q = q.outerjoin(Area, Issue.area_id == Area.id).filter(
|
||||
db.or_(
|
||||
Issue.facility_id.in_(fid_filter),
|
||||
Area.facility_id.in_(fid_filter)
|
||||
)
|
||||
)
|
||||
for i in q.filter(Issue.status != 'resolved').order_by(Issue.reported_at.desc()).all():
|
||||
writer.writerow([
|
||||
i.id,
|
||||
i.reported_at.strftime('%Y-%m-%d %H:%M'),
|
||||
i.resolved_facility.name if i.resolved_facility else '—',
|
||||
i.area.name if i.area else '—',
|
||||
i.severity,
|
||||
i.description.replace('\n', ' '),
|
||||
i.status,
|
||||
i.assigned_user.username if i.assigned_user else '',
|
||||
])
|
||||
else:
|
||||
writer.writerow(['ID', 'Date', 'Facility', 'Area', 'Inspector',
|
||||
'Template', 'Score', 'Status'])
|
||||
q = Inspection.query.filter(
|
||||
Inspection.inspection_date >= start,
|
||||
Inspection.inspection_date <= end,
|
||||
)
|
||||
if fid_filter:
|
||||
q = q.filter(Inspection.facility_id.in_(fid_filter))
|
||||
for i in q.order_by(Inspection.inspection_date.desc()).all():
|
||||
writer.writerow([
|
||||
i.id,
|
||||
i.inspection_date.strftime('%Y-%m-%d %H:%M'),
|
||||
i.facility.name,
|
||||
i.area.name if i.area else '',
|
||||
i.inspector.username,
|
||||
i.template.name,
|
||||
i.overall_score or '',
|
||||
i.status,
|
||||
])
|
||||
|
||||
return buf.getvalue().encode('utf-8')
|
||||
|
||||
|
||||
def _send_report(report: ScheduledReport):
|
||||
"""Build and dispatch the email for a single ScheduledReport."""
|
||||
if not current_app.config.get('MAIL_SERVER'):
|
||||
logger.warning('SCHEDULED REPORT SKIPPED | id=%s | no MAIL_SERVER', report.id)
|
||||
return False
|
||||
|
||||
recipients = report.recipient_list()
|
||||
if not recipients:
|
||||
logger.warning('SCHEDULED REPORT SKIPPED | id=%s | no recipients', report.id)
|
||||
return False
|
||||
|
||||
frequency = report.frequency
|
||||
start, end = _date_window(frequency)
|
||||
data = _build_report_data(report, start, end)
|
||||
base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/')
|
||||
|
||||
html_body = render_template('scheduled_reports/email.html',
|
||||
base_url=base_url, **data)
|
||||
text_body = render_template('scheduled_reports/email.txt',
|
||||
base_url=base_url, **data)
|
||||
|
||||
subject = (f'[JQC] {report.frequency.title()} Report — {report.name} '
|
||||
f'({start.strftime("%b %d")}–{end.strftime("%b %d, %Y")})')
|
||||
|
||||
sender = current_app.config.get(
|
||||
'MAIL_DEFAULT_SENDER',
|
||||
current_app.config.get('MAIL_USERNAME', 'noreply@janitorialqc.local'),
|
||||
)
|
||||
|
||||
msg = Message(subject=subject, sender=sender, recipients=recipients,
|
||||
body=text_body, html=html_body)
|
||||
|
||||
if report.include_csv:
|
||||
csv_bytes = _build_csv(report, start, end)
|
||||
fname = f'jqc_report_{report.frequency}_{start.strftime("%Y%m%d")}.csv'
|
||||
msg.attach(fname, 'text/csv', csv_bytes)
|
||||
|
||||
if report.include_pdf:
|
||||
try:
|
||||
from app.utils.pdf_export import generate_scheduled_report_pdf
|
||||
pdf_bytes = generate_scheduled_report_pdf(
|
||||
report_name = report.name,
|
||||
frequency = report.frequency,
|
||||
start = start,
|
||||
end = end,
|
||||
facility_name = report.facility.name if report.facility else None,
|
||||
data = data,
|
||||
)
|
||||
pdf_fname = f'jqc_report_{report.frequency}_{start.strftime("%Y%m%d")}.pdf'
|
||||
msg.attach(pdf_fname, 'application/pdf', pdf_bytes)
|
||||
except Exception as exc:
|
||||
logger.error('SCHEDULED REPORT PDF FAILED | id=%s | error=%s', report.id, exc)
|
||||
|
||||
try:
|
||||
mail.send(msg)
|
||||
logger.info('SCHEDULED REPORT SENT | id=%s | name=%r | recipients=%s',
|
||||
report.id, report.name, recipients)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error('SCHEDULED REPORT FAILED | id=%s | error=%s', report.id, exc)
|
||||
return False
|
||||
|
||||
|
||||
# ── CRUD ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
@admin_required
|
||||
def index():
|
||||
reports = ScheduledReport.query.order_by(ScheduledReport.name).all()
|
||||
return render_template('scheduled_reports/index.html', reports=reports)
|
||||
|
||||
|
||||
@bp.route('/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def create():
|
||||
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
|
||||
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name', '').strip()
|
||||
report_type = request.form.get('report_type', 'summary')
|
||||
frequency = request.form.get('frequency', 'weekly')
|
||||
facility_id = request.form.get('facility_id', type=int) or None
|
||||
recipients = [e.strip() for e in request.form.get('recipients', '').split(',') if e.strip()]
|
||||
include_pdf = bool(request.form.get('include_pdf'))
|
||||
include_csv = bool(request.form.get('include_csv'))
|
||||
|
||||
if not name:
|
||||
flash('Report name is required.', 'warning')
|
||||
return render_template('scheduled_reports/form.html',
|
||||
facilities=facilities, title='New Scheduled Report')
|
||||
if not recipients:
|
||||
flash('At least one recipient email is required.', 'warning')
|
||||
return render_template('scheduled_reports/form.html',
|
||||
facilities=facilities, title='New Scheduled Report')
|
||||
|
||||
report = ScheduledReport(
|
||||
name = name,
|
||||
report_type = report_type,
|
||||
frequency = frequency,
|
||||
facility_id = facility_id,
|
||||
recipients = recipients,
|
||||
include_pdf = include_pdf,
|
||||
include_csv = include_csv,
|
||||
active = True,
|
||||
created_by = current_user.id,
|
||||
created_at = now_eastern(),
|
||||
next_send_at = _compute_next_send(frequency),
|
||||
)
|
||||
db.session.add(report)
|
||||
db.session.commit()
|
||||
log_action(ACTION_CREATE, 'ScheduledReport', report.id, report.name,
|
||||
f'frequency={frequency}; recipients={len(recipients)}')
|
||||
flash(f'Scheduled report "{report.name}" created.', 'success')
|
||||
return redirect(url_for('scheduled_reports.index'))
|
||||
|
||||
return render_template('scheduled_reports/form.html',
|
||||
facilities=facilities, title='New Scheduled Report')
|
||||
|
||||
|
||||
@bp.route('/<int:report_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def edit(report_id):
|
||||
report = db.session.get(ScheduledReport, report_id)
|
||||
if report is None:
|
||||
abort(404)
|
||||
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
|
||||
|
||||
if request.method == 'POST':
|
||||
report.name = request.form.get('name', '').strip() or report.name
|
||||
report.report_type = request.form.get('report_type', report.report_type)
|
||||
report.frequency = request.form.get('frequency', report.frequency)
|
||||
report.facility_id = request.form.get('facility_id', type=int) or None
|
||||
report.recipients = [e.strip() for e in request.form.get('recipients', '').split(',') if e.strip()]
|
||||
report.include_pdf = bool(request.form.get('include_pdf'))
|
||||
report.include_csv = bool(request.form.get('include_csv'))
|
||||
report.active = bool(request.form.get('active'))
|
||||
report.next_send_at = _compute_next_send(report.frequency)
|
||||
|
||||
db.session.commit()
|
||||
log_action(ACTION_UPDATE, 'ScheduledReport', report.id, report.name,
|
||||
f'frequency={report.frequency}; active={report.active}')
|
||||
flash(f'Scheduled report "{report.name}" updated.', 'success')
|
||||
return redirect(url_for('scheduled_reports.index'))
|
||||
|
||||
return render_template('scheduled_reports/form.html', report=report,
|
||||
facilities=facilities, title='Edit Scheduled Report')
|
||||
|
||||
|
||||
@bp.route('/<int:report_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete(report_id):
|
||||
report = db.session.get(ScheduledReport, report_id)
|
||||
if report is None:
|
||||
abort(404)
|
||||
name = report.name
|
||||
rid = report.id
|
||||
db.session.delete(report)
|
||||
db.session.commit()
|
||||
log_action(ACTION_DELETE, 'ScheduledReport', rid, name)
|
||||
flash(f'Scheduled report "{name}" deleted.', 'success')
|
||||
return redirect(url_for('scheduled_reports.index'))
|
||||
|
||||
|
||||
@bp.route('/<int:report_id>/preview')
|
||||
@login_required
|
||||
@admin_required
|
||||
def preview(report_id):
|
||||
"""Render the scheduled report email in-browser for review."""
|
||||
report = db.session.get(ScheduledReport, report_id)
|
||||
if report is None:
|
||||
abort(404)
|
||||
start, end = _date_window(report.frequency)
|
||||
data = _build_report_data(report, start, end)
|
||||
base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/')
|
||||
|
||||
current_app.logger.info(
|
||||
'SCHEDULED REPORT PREVIEW | id=%s | name=%r | by=%s',
|
||||
report.id, report.name, current_user.username,
|
||||
)
|
||||
|
||||
return render_template('scheduled_reports/email.html',
|
||||
base_url=base_url, **data)
|
||||
|
||||
|
||||
@bp.route('/<int:report_id>/preview-pdf')
|
||||
@login_required
|
||||
@admin_required
|
||||
def preview_pdf(report_id):
|
||||
"""Generate and stream the PDF attachment for in-browser review."""
|
||||
from flask import Response
|
||||
from app.utils.pdf_export import generate_scheduled_report_pdf
|
||||
|
||||
report = db.session.get(ScheduledReport, report_id)
|
||||
if report is None:
|
||||
abort(404)
|
||||
start, end = _date_window(report.frequency)
|
||||
data = _build_report_data(report, start, end)
|
||||
|
||||
pdf_bytes = generate_scheduled_report_pdf(
|
||||
report_name = report.name,
|
||||
frequency = report.frequency,
|
||||
start = start,
|
||||
end = end,
|
||||
facility_name = report.facility.name if report.facility else None,
|
||||
data = data,
|
||||
)
|
||||
|
||||
filename = f'jqc_report_{report.frequency}_{start.strftime("%Y%m%d")}.pdf'
|
||||
|
||||
current_app.logger.info(
|
||||
'SCHEDULED REPORT PDF PREVIEW | id=%s | name=%r | by=%s',
|
||||
report.id, report.name, current_user.username,
|
||||
)
|
||||
|
||||
return Response(
|
||||
pdf_bytes,
|
||||
mimetype='application/pdf',
|
||||
headers={'Content-Disposition': f'inline; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/<int:report_id>/send-now', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def send_now(report_id):
|
||||
"""Manually trigger a single report — useful for testing."""
|
||||
report = db.session.get(ScheduledReport, report_id)
|
||||
if report is None:
|
||||
abort(404)
|
||||
ok = _send_report(report)
|
||||
if ok:
|
||||
report.last_sent_at = now_eastern()
|
||||
db.session.commit()
|
||||
log_action(ACTION_UPDATE, 'ScheduledReport', report.id, report.name,
|
||||
f'manual send_now by {current_user.username}; '
|
||||
f'frequency={report.frequency}; recipients={len(report.recipient_list())}')
|
||||
flash(f'Report "{report.name}" sent successfully.', 'success')
|
||||
else:
|
||||
flash(f'Failed to send report "{report.name}". Check application logs.', 'danger')
|
||||
return redirect(url_for('scheduled_reports.index'))
|
||||
|
||||
|
||||
# ── Cron endpoint ─────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/send', methods=['POST'])
|
||||
@csrf.exempt
|
||||
def send():
|
||||
"""Token-protected endpoint called by cron to dispatch due reports.
|
||||
|
||||
POST body: token=<DIGEST_SECRET>&frequency=daily|weekly|monthly
|
||||
"""
|
||||
token = request.form.get('token') or request.args.get('token')
|
||||
frequency = request.form.get('frequency', 'daily')
|
||||
expected = current_app.config.get('DIGEST_SECRET')
|
||||
|
||||
if not expected or token != expected:
|
||||
logger.warning('SCHEDULED REPORTS SEND REJECTED | bad/missing token')
|
||||
return jsonify({'ok': False, 'error': 'unauthorized'}), 403
|
||||
|
||||
if frequency not in ('daily', 'weekly', 'monthly'):
|
||||
return jsonify({'ok': False, 'error': 'invalid frequency'}), 400
|
||||
|
||||
now = now_eastern()
|
||||
reports = ScheduledReport.query.filter_by(active=True, frequency=frequency).all()
|
||||
# Only send reports whose next_send_at is due (or not yet set)
|
||||
due = [r for r in reports if r.next_send_at is None or r.next_send_at <= now]
|
||||
|
||||
sent, failed = 0, 0
|
||||
for report in due:
|
||||
ok = _send_report(report)
|
||||
# Always advance next_send_at so a failed report does not get
|
||||
# retried on every subsequent cron run. last_sent_at is only
|
||||
# updated on a successful delivery so the UI accurately reflects
|
||||
# when the last good email was dispatched.
|
||||
report.next_send_at = _compute_next_send(frequency, now)
|
||||
if ok:
|
||||
report.last_sent_at = now
|
||||
sent += 1
|
||||
else:
|
||||
failed += 1
|
||||
logger.error(
|
||||
'SCHEDULED REPORT FAILED | id=%s | name=%r | frequency=%s',
|
||||
report.id, report.name, frequency,
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
logger.info('SCHEDULED REPORTS CRON | frequency=%s | due=%s | sent=%s | failed=%s',
|
||||
frequency, len(due), sent, failed)
|
||||
return jsonify({'ok': True, 'frequency': frequency, 'sent': sent, 'failed': failed})
|
||||
@@ -0,0 +1,371 @@
|
||||
import os
|
||||
import logging
|
||||
|
||||
from flask import (Blueprint, render_template, redirect, url_for,
|
||||
flash, request, current_app, jsonify, abort)
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from app import db
|
||||
from app.models.support import SupportTicket, SupportTicketReply
|
||||
from app.models.user import User
|
||||
from app.models.facility import Facility
|
||||
from app.utils.decorators import supervisor_required
|
||||
from app.utils.scope import get_customer_scope
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE
|
||||
from app.utils.time_utils import now_eastern
|
||||
from app.utils.notifications import notify
|
||||
|
||||
bp = Blueprint('support', __name__, url_prefix='/support')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Groq system prompt ────────────────────────────────────────────────────────
|
||||
|
||||
_SYSTEM_PROMPT = """\
|
||||
You are JQC Support, a friendly assistant for customers of JQC (Janitorial Quality Control), \
|
||||
a commercial cleaning quality management platform.
|
||||
|
||||
Help customers with:
|
||||
- Navigating the portal: Dashboard, Inspections, Issues, Reports pages
|
||||
- Inspection scores: 90%+ = Excellent, 70-89% = Satisfactory, below 70% = Needs Improvement
|
||||
- SLA timelines: Critical issues = 4 h, High = 24 h, Medium = 72 h, Low = 168 h
|
||||
- Issue statuses: Open → In Progress → Pending Verification → Resolved
|
||||
- Following issues to receive email/in-app update notifications
|
||||
- Reporting new cleaning concerns via the Issues > Log Issue page
|
||||
- Understanding facility scorecards and trend charts in Reports
|
||||
|
||||
Rules:
|
||||
- Keep answers concise (3-5 sentences max) and friendly.
|
||||
- Never invent specific staff names, contract prices, schedules, or contact numbers.
|
||||
- If the customer has an access problem, billing question, or a concern you genuinely \
|
||||
cannot resolve through guidance, say so clearly and suggest they click \
|
||||
"Submit to Support" to reach the admin team directly.\
|
||||
"""
|
||||
|
||||
# Preset FAQ questions shown as quick-reply chips on first load
|
||||
FAQS = [
|
||||
{'icon': 'bi-clipboard-check', 'text': 'How do I view my inspection reports?'},
|
||||
{'icon': 'bi-graph-up', 'text': 'What do inspection scores mean?'},
|
||||
{'icon': 'bi-exclamation-circle','text': 'How do I track an open issue?'},
|
||||
{'icon': 'bi-megaphone', 'text': 'How do I report a cleaning concern?'},
|
||||
{'icon': 'bi-alarm', 'text': 'What is SLA and how does it work?'},
|
||||
{'icon': 'bi-bell', 'text': 'How do I get notified on issue updates?'},
|
||||
]
|
||||
|
||||
|
||||
# ── Customer chat page ────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/chat')
|
||||
@login_required
|
||||
def chat():
|
||||
if current_user.role != 'customer':
|
||||
return redirect(url_for('support.admin_tickets'))
|
||||
|
||||
cids = get_customer_scope(current_user) or []
|
||||
facilities = (Facility.query
|
||||
.filter(Facility.id.in_(cids), Facility.active == True)
|
||||
.order_by(Facility.name).all()) if cids else []
|
||||
|
||||
groq_ready = bool(os.environ.get('GROQ_API_KEY'))
|
||||
return render_template('support/chat.html',
|
||||
faqs=FAQS,
|
||||
facilities=facilities,
|
||||
groq_ready=groq_ready)
|
||||
|
||||
|
||||
# ── Groq chat AJAX endpoint ───────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/chat/message', methods=['POST'])
|
||||
@login_required
|
||||
def chat_message():
|
||||
if current_user.role != 'customer':
|
||||
return jsonify({'error': 'Forbidden'}), 403
|
||||
|
||||
api_key = os.environ.get('GROQ_API_KEY')
|
||||
if not api_key:
|
||||
return jsonify({'reply': (
|
||||
"I'm sorry, the AI assistant isn't configured right now. "
|
||||
"Please use the **Submit to Support** form to reach our team directly."
|
||||
)})
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
history = data.get('messages', []) # list of {role, content} dicts
|
||||
user_message = data.get('message', '').strip()
|
||||
|
||||
if not user_message:
|
||||
return jsonify({'error': 'Empty message'}), 400
|
||||
|
||||
try:
|
||||
from groq import Groq
|
||||
client = Groq(api_key=api_key)
|
||||
|
||||
messages = [{'role': 'system', 'content': _SYSTEM_PROMPT}]
|
||||
# Append prior conversation (cap at last 20 turns to control token usage)
|
||||
for m in history[-20:]:
|
||||
if m.get('role') in ('user', 'assistant') and m.get('content'):
|
||||
messages.append({'role': m['role'], 'content': m['content']})
|
||||
messages.append({'role': 'user', 'content': user_message})
|
||||
|
||||
model = os.environ.get('GROQ_MODEL', 'llama-3.3-70b-versatile')
|
||||
completion = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
max_tokens=512,
|
||||
temperature=0.5,
|
||||
)
|
||||
reply = completion.choices[0].message.content.strip()
|
||||
return jsonify({'reply': reply})
|
||||
|
||||
except Exception as exc:
|
||||
logger.error('SUPPORT | Groq error: %s', exc)
|
||||
return jsonify({'reply': (
|
||||
"I ran into a problem reaching the AI assistant. "
|
||||
"Please try again, or use **Submit to Support** to contact our team."
|
||||
)})
|
||||
|
||||
|
||||
# ── Submit support ticket ─────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/tickets', methods=['POST'])
|
||||
@login_required
|
||||
def submit_ticket():
|
||||
if current_user.role != 'customer':
|
||||
abort(403)
|
||||
|
||||
subject = request.form.get('subject', '').strip()
|
||||
body = request.form.get('body', '').strip()
|
||||
facility_id = request.form.get('facility_id', type=int)
|
||||
|
||||
if not subject or not body:
|
||||
flash('Please fill in both subject and description.', 'warning')
|
||||
return redirect(url_for('support.chat'))
|
||||
|
||||
# Validate facility belongs to this customer
|
||||
cids = get_customer_scope(current_user) or []
|
||||
if facility_id and facility_id not in cids:
|
||||
facility_id = None
|
||||
|
||||
ticket = SupportTicket(
|
||||
customer_id = current_user.id,
|
||||
facility_id = facility_id,
|
||||
subject = subject,
|
||||
body = body,
|
||||
status = 'open',
|
||||
created_at = now_eastern(),
|
||||
)
|
||||
db.session.add(ticket)
|
||||
db.session.commit()
|
||||
|
||||
log_action(ACTION_CREATE, 'SupportTicket', ticket.id,
|
||||
f'#{ticket.id}: {subject[:60]}',
|
||||
f'customer={current_user.username}')
|
||||
|
||||
_notify_admins_new_ticket(ticket)
|
||||
|
||||
flash('Your message has been submitted. Our team will get back to you soon.', 'success')
|
||||
return redirect(url_for('support.my_tickets'))
|
||||
|
||||
|
||||
# ── Customer: my tickets list ─────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/my-tickets')
|
||||
@login_required
|
||||
def my_tickets():
|
||||
if current_user.role != 'customer':
|
||||
abort(403)
|
||||
|
||||
tickets = (SupportTicket.query
|
||||
.filter_by(customer_id=current_user.id)
|
||||
.order_by(SupportTicket.created_at.desc())
|
||||
.all())
|
||||
return render_template('support/my_tickets.html', tickets=tickets)
|
||||
|
||||
|
||||
# ── Customer: ticket detail ───────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/my-tickets/<int:ticket_id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def my_ticket_detail(ticket_id):
|
||||
if current_user.role != 'customer':
|
||||
abort(403)
|
||||
|
||||
ticket = db.session.get(SupportTicket, ticket_id)
|
||||
if ticket is None or ticket.customer_id != current_user.id:
|
||||
abort(404)
|
||||
|
||||
if request.method == 'POST':
|
||||
if ticket.status == 'closed':
|
||||
flash('This ticket is closed and cannot receive new replies.', 'warning')
|
||||
return redirect(url_for('support.my_ticket_detail', ticket_id=ticket_id))
|
||||
|
||||
body = request.form.get('body', '').strip()
|
||||
if not body:
|
||||
flash('Reply cannot be empty.', 'warning')
|
||||
return redirect(url_for('support.my_ticket_detail', ticket_id=ticket_id))
|
||||
|
||||
reply = SupportTicketReply(
|
||||
ticket_id = ticket.id,
|
||||
user_id = current_user.id,
|
||||
body = body,
|
||||
created_at = now_eastern(),
|
||||
)
|
||||
db.session.add(reply)
|
||||
# Reopen if it was answered so admin sees there's a follow-up
|
||||
if ticket.status == 'answered':
|
||||
ticket.status = 'open'
|
||||
db.session.commit()
|
||||
|
||||
log_action(ACTION_CREATE, 'SupportTicketReply', reply.id,
|
||||
f'ticket #{ticket.id}',
|
||||
f'customer reply by {current_user.username}')
|
||||
|
||||
_notify_admins_customer_reply(ticket, reply)
|
||||
flash('Your reply has been sent.', 'success')
|
||||
return redirect(url_for('support.my_ticket_detail', ticket_id=ticket_id))
|
||||
|
||||
replies = ticket.replies.order_by(SupportTicketReply.created_at.asc()).all()
|
||||
return render_template('support/my_ticket_detail.html',
|
||||
ticket=ticket, replies=replies)
|
||||
|
||||
|
||||
def _notify_admins_new_ticket(ticket):
|
||||
"""Create in-app notifications and send emails to all active admin users."""
|
||||
admins = User.query.filter_by(role='admin', active=True).all()
|
||||
if not admins:
|
||||
return
|
||||
|
||||
customer_label = ticket.customer.display_name if ticket.customer else 'Unknown'
|
||||
facility_label = ticket.facility.name if ticket.facility else 'N/A'
|
||||
link = url_for('support.admin_ticket_detail', ticket_id=ticket.id)
|
||||
title = f'New support ticket #{ticket.id} from {customer_label}'
|
||||
body = (f'Subject: {ticket.subject}\n'
|
||||
f'Facility: {facility_label}\n\n'
|
||||
f'{ticket.body[:300]}{"…" if len(ticket.body) > 300 else ""}')
|
||||
|
||||
for admin in admins:
|
||||
notify(
|
||||
recipient = admin,
|
||||
title = title,
|
||||
body = body,
|
||||
link = link,
|
||||
send_email = True,
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
# ── Admin: ticket list ────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/admin/tickets')
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def admin_tickets():
|
||||
status_filter = request.args.get('status', '')
|
||||
page = request.args.get('page', 1, type=int)
|
||||
|
||||
q = SupportTicket.query.order_by(SupportTicket.created_at.desc())
|
||||
if status_filter:
|
||||
q = q.filter(SupportTicket.status == status_filter)
|
||||
|
||||
tickets = q.paginate(page=page, per_page=25, error_out=False)
|
||||
return render_template('support/admin_tickets.html',
|
||||
tickets=tickets,
|
||||
status_filter=status_filter)
|
||||
|
||||
|
||||
# ── Admin: ticket detail + reply ──────────────────────────────────────────────
|
||||
|
||||
@bp.route('/admin/tickets/<int:ticket_id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def admin_ticket_detail(ticket_id):
|
||||
ticket = db.session.get(SupportTicket, ticket_id)
|
||||
if ticket is None:
|
||||
abort(404)
|
||||
|
||||
if request.method == 'POST':
|
||||
action = request.form.get('action')
|
||||
|
||||
if action == 'reply':
|
||||
body = request.form.get('body', '').strip()
|
||||
if not body:
|
||||
flash('Reply cannot be empty.', 'warning')
|
||||
return redirect(url_for('support.admin_ticket_detail', ticket_id=ticket_id))
|
||||
|
||||
reply = SupportTicketReply(
|
||||
ticket_id = ticket.id,
|
||||
user_id = current_user.id,
|
||||
body = body,
|
||||
created_at = now_eastern(),
|
||||
)
|
||||
db.session.add(reply)
|
||||
# Auto-advance status to answered if still open
|
||||
if ticket.status == 'open':
|
||||
ticket.status = 'answered'
|
||||
db.session.commit()
|
||||
|
||||
log_action(ACTION_UPDATE, 'SupportTicket', ticket.id,
|
||||
f'#{ticket.id}: {ticket.subject[:60]}',
|
||||
f'reply added by {current_user.username}')
|
||||
|
||||
_notify_customer_reply(ticket, reply)
|
||||
flash('Reply sent.', 'success')
|
||||
|
||||
elif action == 'status':
|
||||
new_status = request.form.get('status', '')
|
||||
if new_status in ('open', 'answered', 'closed'):
|
||||
ticket.status = new_status
|
||||
db.session.commit()
|
||||
log_action(ACTION_UPDATE, 'SupportTicket', ticket.id,
|
||||
f'#{ticket.id}: {ticket.subject[:60]}',
|
||||
f'status={new_status}')
|
||||
flash(f'Ticket marked as {new_status}.', 'success')
|
||||
|
||||
return redirect(url_for('support.admin_ticket_detail', ticket_id=ticket_id))
|
||||
|
||||
replies = ticket.replies.order_by(SupportTicketReply.created_at.asc()).all()
|
||||
return render_template('support/admin_ticket_detail.html',
|
||||
ticket=ticket,
|
||||
replies=replies)
|
||||
|
||||
|
||||
def _notify_customer_reply(ticket, reply):
|
||||
"""Create an in-app notification and send an email to the customer."""
|
||||
if not ticket.customer:
|
||||
return
|
||||
|
||||
admin_name = reply.author.display_name if reply.author else 'Support Team'
|
||||
title = f'Reply to your support request #{ticket.id}'
|
||||
body = (f'{admin_name} replied to your ticket "{ticket.subject}":\n\n'
|
||||
f'{reply.body[:400]}{"…" if len(reply.body) > 400 else ""}')
|
||||
link = url_for('support.my_ticket_detail', ticket_id=ticket.id)
|
||||
|
||||
notify(
|
||||
recipient = ticket.customer,
|
||||
title = title,
|
||||
body = body,
|
||||
link = link,
|
||||
send_email = True,
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _notify_admins_customer_reply(ticket, reply):
|
||||
"""Notify admins when a customer adds a follow-up reply to their ticket."""
|
||||
admins = User.query.filter_by(role='admin', active=True).all()
|
||||
if not admins:
|
||||
return
|
||||
|
||||
customer_label = ticket.customer.display_name if ticket.customer else 'Unknown'
|
||||
link = url_for('support.admin_ticket_detail', ticket_id=ticket.id)
|
||||
title = f'Customer reply on ticket #{ticket.id} from {customer_label}'
|
||||
body = (f'Re: {ticket.subject}\n\n'
|
||||
f'{reply.body[:400]}{"…" if len(reply.body) > 400 else ""}')
|
||||
|
||||
for admin in admins:
|
||||
notify(
|
||||
recipient = admin,
|
||||
title = title,
|
||||
body = body,
|
||||
link = link,
|
||||
send_email = True,
|
||||
)
|
||||
db.session.commit()
|
||||
@@ -0,0 +1,443 @@
|
||||
import logging
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, abort
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.inspection import InspectionTemplate, ChecklistItem
|
||||
from app.utils.forms import InspectionTemplateForm, ChecklistItemForm
|
||||
from app.utils.decorators import supervisor_required
|
||||
import json
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
|
||||
|
||||
bp = Blueprint('templates', __name__, url_prefix='/templates')
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Template CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
templates = InspectionTemplate.query.order_by(InspectionTemplate.name).all()
|
||||
return render_template('templates/list.html', templates=templates)
|
||||
|
||||
|
||||
@bp.route('/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def create_template():
|
||||
form = InspectionTemplateForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
template = InspectionTemplate(
|
||||
name=form.name.data,
|
||||
description=form.description.data,
|
||||
frequency=form.frequency.data,
|
||||
created_by=current_user.id
|
||||
)
|
||||
db.session.add(template)
|
||||
db.session.commit()
|
||||
logger.info('TEMPLATES | create | user=%s | template_id=%s name=%r',
|
||||
current_user.username, template.id, template.name)
|
||||
log_action(ACTION_CREATE, 'Template', template.id, template.name,
|
||||
f'frequency={template.frequency}')
|
||||
|
||||
flash(f'Template "{template.name}" created successfully.', 'success')
|
||||
return redirect(url_for('templates.form_editor', template_id=template.id))
|
||||
|
||||
return render_template('templates/form.html', form=form, title='Create Inspection Template')
|
||||
|
||||
|
||||
@bp.route('/<int:template_id>')
|
||||
@login_required
|
||||
def view_template(template_id):
|
||||
template = db.session.get(InspectionTemplate, template_id)
|
||||
if template is None:
|
||||
abort(404)
|
||||
form_fields = template.get_form_schema()
|
||||
return render_template(
|
||||
'templates/view.html',
|
||||
template=template,
|
||||
form_fields=form_fields
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/<int:template_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def edit_template(template_id):
|
||||
template = db.session.get(InspectionTemplate, template_id)
|
||||
if template is None:
|
||||
abort(404)
|
||||
form = InspectionTemplateForm(obj=template)
|
||||
|
||||
if form.validate_on_submit():
|
||||
template.name = form.name.data
|
||||
template.description = form.description.data
|
||||
template.frequency = form.frequency.data
|
||||
db.session.commit()
|
||||
logger.info('TEMPLATES | edit | user=%s | template_id=%s name=%r',
|
||||
current_user.username, template.id, template.name)
|
||||
log_action(ACTION_UPDATE, 'Template', template.id, template.name,
|
||||
f'frequency={template.frequency}')
|
||||
flash(f'Template "{template.name}" updated successfully.', 'success')
|
||||
return redirect(url_for('templates.view_template', template_id=template.id))
|
||||
|
||||
form_fields = template.get_form_schema()
|
||||
return render_template(
|
||||
'templates/edit.html',
|
||||
form=form,
|
||||
template=template,
|
||||
form_fields=form_fields
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/<int:template_id>/rename', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def rename_template(template_id):
|
||||
template = db.session.get(InspectionTemplate, template_id)
|
||||
if template is None:
|
||||
abort(404)
|
||||
|
||||
new_name = request.form.get('name', '').strip()
|
||||
if not new_name:
|
||||
flash('Template name cannot be empty.', 'danger')
|
||||
return redirect(url_for('templates.index'))
|
||||
if len(new_name) > 255:
|
||||
flash('Template name is too long (max 255 characters).', 'danger')
|
||||
return redirect(url_for('templates.index'))
|
||||
|
||||
valid_frequencies = {'daily', 'weekly', 'monthly', 'quarterly'}
|
||||
new_frequency = request.form.get('frequency', '').strip()
|
||||
if new_frequency not in valid_frequencies:
|
||||
flash('Invalid frequency value.', 'danger')
|
||||
return redirect(url_for('templates.index'))
|
||||
|
||||
template.name = new_name
|
||||
template.description = request.form.get('description', '').strip() or None
|
||||
template.frequency = new_frequency
|
||||
|
||||
db.session.commit()
|
||||
logger.info('TEMPLATES | rename | user=%s | template_id=%s name=%r',
|
||||
current_user.username, template.id, template.name)
|
||||
log_action(ACTION_UPDATE, 'Template', template.id, template.name,
|
||||
f'frequency={template.frequency}; via=rename')
|
||||
flash(f'Template "{template.name}" updated successfully.', 'success')
|
||||
return redirect(url_for('templates.index'))
|
||||
|
||||
|
||||
@bp.route('/<int:template_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def delete_template(template_id):
|
||||
template = db.session.get(InspectionTemplate, template_id)
|
||||
if template is None:
|
||||
abort(404)
|
||||
|
||||
if template.inspections.count() > 0:
|
||||
flash('Cannot delete template with existing inspections.', 'danger')
|
||||
return redirect(url_for('templates.index'))
|
||||
|
||||
template_name = template.name
|
||||
template_id_snap = template.id
|
||||
db.session.delete(template)
|
||||
db.session.commit()
|
||||
logger.info('TEMPLATES | delete | user=%s | template_id=%s name=%r',
|
||||
current_user.username, template_id_snap, template_name)
|
||||
log_action(ACTION_DELETE, 'Template', template_id_snap, template_name)
|
||||
|
||||
flash(f'Template "{template_name}" deleted successfully.', 'success')
|
||||
return redirect(url_for('templates.index'))
|
||||
|
||||
|
||||
@bp.route('/<int:template_id>/toggle-active', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def toggle_active(template_id):
|
||||
template = db.session.get(InspectionTemplate, template_id)
|
||||
if template is None:
|
||||
abort(404)
|
||||
template.active = not template.active
|
||||
db.session.commit()
|
||||
state = 'activated' if template.active else 'deactivated'
|
||||
logger.info('TEMPLATES | toggle_active | user=%s | template_id=%s active=%s',
|
||||
current_user.username, template.id, template.active)
|
||||
log_action(ACTION_UPDATE, 'Template', template.id, template.name, f'active={template.active}')
|
||||
flash(f'Template "{template.name}" {state}.', 'success')
|
||||
return redirect(url_for('templates.index'))
|
||||
|
||||
|
||||
@bp.route('/<int:template_id>/duplicate', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def duplicate_template(template_id):
|
||||
src = db.session.get(InspectionTemplate, template_id)
|
||||
if src is None:
|
||||
abort(404)
|
||||
|
||||
# Duplicate the template header
|
||||
new_tpl = InspectionTemplate(
|
||||
name=f'{src.name} (Copy)',
|
||||
description=src.description,
|
||||
frequency=src.frequency,
|
||||
created_by=current_user.id
|
||||
)
|
||||
db.session.add(new_tpl)
|
||||
db.session.flush() # get new_tpl.id before committing
|
||||
|
||||
# Duplicate all checklist items
|
||||
for item in src.checklist_items.order_by(ChecklistItem.display_order).all():
|
||||
new_item = ChecklistItem(
|
||||
template_id=new_tpl.id,
|
||||
category=item.category,
|
||||
item_description=item.item_description,
|
||||
scoring_type=item.scoring_type,
|
||||
weight=item.weight,
|
||||
requires_photo=item.requires_photo,
|
||||
display_order=item.display_order
|
||||
)
|
||||
db.session.add(new_item)
|
||||
|
||||
# Duplicate form schema if present
|
||||
if src.form_schema:
|
||||
new_tpl.form_schema = src.form_schema
|
||||
|
||||
db.session.commit()
|
||||
logger.info('TEMPLATES | duplicate | user=%s | new_template_id=%s source_id=%s',
|
||||
current_user.username, new_tpl.id, src.id)
|
||||
log_action(ACTION_CREATE, 'Template', new_tpl.id, new_tpl.name,
|
||||
f'duplicated_from={src.id}; frequency={new_tpl.frequency}')
|
||||
|
||||
flash(f'Template "{src.name}" duplicated successfully.', 'success')
|
||||
return redirect(url_for('templates.index'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Form Editor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@bp.route('/<int:template_id>/form-editor')
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def form_editor(template_id):
|
||||
template = db.session.get(InspectionTemplate, template_id)
|
||||
if template is None:
|
||||
abort(404)
|
||||
form_schema = template.get_form_schema()
|
||||
return render_template(
|
||||
'templates/form_editor.html',
|
||||
template=template,
|
||||
form_schema=form_schema # pass the list — tojson handles encoding in the template
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/<int:template_id>/form-editor/save', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def save_form_schema(template_id):
|
||||
"""AJAX endpoint — receives the full form schema as JSON and persists it."""
|
||||
template = db.session.get(InspectionTemplate, template_id)
|
||||
if template is None:
|
||||
abort(404)
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if data is None:
|
||||
return jsonify({'success': False, 'error': 'Invalid JSON payload'}), 400
|
||||
|
||||
fields = data.get('fields', [])
|
||||
|
||||
# Hard cap on total field count to prevent oversized JSON payloads
|
||||
MAX_FIELDS = 150
|
||||
if len(fields) > MAX_FIELDS:
|
||||
return jsonify({'success': False, 'error': f'Form may not exceed {MAX_FIELDS} fields.'}), 400
|
||||
|
||||
# Basic sanitisation — ensure each field has the minimum required keys
|
||||
# Track seen IDs to enforce uniqueness
|
||||
seen_ids = set()
|
||||
sanitised = []
|
||||
for field in fields:
|
||||
if not isinstance(field, dict):
|
||||
continue
|
||||
if not field.get('id') or not field.get('type'):
|
||||
continue
|
||||
# Reject duplicate field IDs
|
||||
field_id = str(field.get('id', ''))
|
||||
if field_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(field_id)
|
||||
ftype = str(field.get('type', 'text'))
|
||||
entry = {
|
||||
'id': field_id,
|
||||
'type': ftype,
|
||||
'label': str(field.get('label', 'Untitled'))[:255],
|
||||
'placeholder': str(field.get('placeholder', ''))[:255],
|
||||
'required': bool(field.get('required', False)),
|
||||
'options': field.get('options', []) if ftype in ('radio', 'checkbox_group', 'select', 'pass_fail') else [],
|
||||
'help_text': str(field.get('help_text', ''))[:500],
|
||||
'order': int(field.get('order', 0)),
|
||||
# Grid position & size
|
||||
'col': max(1, min(12, int(field.get('col', 1)))),
|
||||
'row': max(1, min(9999, int(field.get('row', 1)))),
|
||||
'colSpan': max(1, min(12, int(field.get('colSpan', 6)))),
|
||||
'rowSpan': max(1, min(20, int(field.get('rowSpan', 2)))),
|
||||
}
|
||||
# Table-specific fields
|
||||
if ftype == 'table':
|
||||
raw_hdrs = field.get('col_headers', ['Column 1', 'Column 2', 'Column 3'])
|
||||
col_headers = [str(h)[:100] for h in raw_hdrs if isinstance(h, str)][:20] or ['Column 1']
|
||||
entry['col_headers'] = col_headers
|
||||
entry['table_cols'] = len(col_headers)
|
||||
entry['table_rows'] = max(1, min(30, int(field.get('table_rows', 3))))
|
||||
# Label-specific fields
|
||||
if ftype == 'label':
|
||||
entry['text_content'] = str(field.get('text_content', 'Label text'))[:2000]
|
||||
entry['font_size'] = field.get('font_size', 'normal') if field.get('font_size') in ('small','normal','large','x-large') else 'normal'
|
||||
entry['font_weight'] = 'bold' if field.get('font_weight') == 'bold' else 'normal'
|
||||
# Button-specific fields
|
||||
if ftype in ('button_submit', 'button_print', 'button_email'):
|
||||
defaults = {'button_submit':'Submit Form','button_print':'Print Form','button_email':'Email Form'}
|
||||
entry['btn_label'] = str(field.get('btn_label', defaults[ftype]))[:100]
|
||||
sanitised.append(entry)
|
||||
|
||||
template.form_schema = sanitised
|
||||
db.session.commit()
|
||||
logger.info('TEMPLATES | save_form_schema | user=%s | template_id=%s fields=%s',
|
||||
current_user.username, template.id, len(sanitised))
|
||||
log_action(ACTION_UPDATE, 'Template', template.id, template.name,
|
||||
f'form_schema saved; field_count={len(sanitised)}')
|
||||
|
||||
return jsonify({'success': True, 'field_count': len(sanitised)})
|
||||
|
||||
|
||||
@bp.route('/<int:template_id>/form-editor/preview')
|
||||
@login_required
|
||||
def form_preview(template_id):
|
||||
"""Renders a read-only preview of the dynamic form."""
|
||||
template = db.session.get(InspectionTemplate, template_id)
|
||||
if template is None:
|
||||
abort(404)
|
||||
form_fields = template.get_form_schema()
|
||||
return render_template(
|
||||
'templates/form_preview.html',
|
||||
template=template,
|
||||
form_fields=form_fields
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Checklist Item Management (legacy, kept for backwards compatibility)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@bp.route('/<int:template_id>/items/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def create_checklist_item(template_id):
|
||||
template = db.session.get(InspectionTemplate, template_id)
|
||||
if template is None:
|
||||
abort(404)
|
||||
form = ChecklistItemForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
max_order = db.session.query(db.func.max(ChecklistItem.display_order))\
|
||||
.filter_by(template_id=template.id).scalar() or 0
|
||||
|
||||
item = ChecklistItem(
|
||||
template_id=template.id,
|
||||
category=form.category.data,
|
||||
item_description=form.item_description.data,
|
||||
scoring_type=form.scoring_type.data,
|
||||
weight=form.weight.data,
|
||||
requires_photo=form.requires_photo.data,
|
||||
display_order=max_order + 1
|
||||
)
|
||||
db.session.add(item)
|
||||
db.session.commit()
|
||||
logger.info('TEMPLATES | create_checklist_item | user=%s | item_id=%s template_id=%s',
|
||||
current_user.username, item.id, template.id)
|
||||
log_action(ACTION_CREATE, 'ChecklistItem', item.id, item.item_description[:80],
|
||||
f'template_id={template.id}; category={item.category or ""}; '
|
||||
f'scoring_type={item.scoring_type}')
|
||||
|
||||
flash('Checklist item added successfully.', 'success')
|
||||
return redirect(url_for('templates.edit_template', template_id=template.id))
|
||||
|
||||
return render_template(
|
||||
'templates/item_form.html',
|
||||
form=form,
|
||||
template=template,
|
||||
title='Add Checklist Item'
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/items/<int:item_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def edit_checklist_item(item_id):
|
||||
item = db.session.get(ChecklistItem, item_id)
|
||||
if item is None:
|
||||
abort(404)
|
||||
form = ChecklistItemForm(obj=item)
|
||||
|
||||
if form.validate_on_submit():
|
||||
item.category = form.category.data
|
||||
item.item_description = form.item_description.data
|
||||
item.scoring_type = form.scoring_type.data
|
||||
item.weight = form.weight.data
|
||||
item.requires_photo = form.requires_photo.data
|
||||
db.session.commit()
|
||||
logger.info('TEMPLATES | edit_checklist_item | user=%s | item_id=%s template_id=%s',
|
||||
current_user.username, item.id, item.template_id)
|
||||
log_action(ACTION_UPDATE, 'ChecklistItem', item.id, item.item_description[:80],
|
||||
f'template_id={item.template_id}; category={item.category or ""}; '
|
||||
f'scoring_type={item.scoring_type}')
|
||||
flash('Checklist item updated successfully.', 'success')
|
||||
return redirect(url_for('templates.edit_template', template_id=item.template_id))
|
||||
|
||||
return render_template(
|
||||
'templates/item_form.html',
|
||||
form=form,
|
||||
item=item,
|
||||
template=item.template,
|
||||
title='Edit Checklist Item'
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/items/<int:item_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def delete_checklist_item(item_id):
|
||||
item = db.session.get(ChecklistItem, item_id)
|
||||
if item is None:
|
||||
abort(404)
|
||||
template_id = item.template_id
|
||||
item_desc = item.item_description[:80]
|
||||
item_id_snap = item.id
|
||||
db.session.delete(item)
|
||||
db.session.commit()
|
||||
logger.info('TEMPLATES | delete_checklist_item | user=%s | item_id=%s template_id=%s',
|
||||
current_user.username, item_id_snap, template_id)
|
||||
log_action(ACTION_DELETE, 'ChecklistItem', item_id_snap, item_desc,
|
||||
f'template_id={template_id}')
|
||||
flash('Checklist item deleted successfully.', 'success')
|
||||
return redirect(url_for('templates.edit_template', template_id=template_id))
|
||||
|
||||
|
||||
@bp.route('/<int:template_id>/items/reorder', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def reorder_items(template_id):
|
||||
template = db.session.get(InspectionTemplate, template_id)
|
||||
if template is None:
|
||||
abort(404)
|
||||
item_order = request.json.get('item_order', [])
|
||||
|
||||
for index, item_id in enumerate(item_order):
|
||||
item = db.session.get(ChecklistItem, item_id)
|
||||
if item and item.template_id == template.id:
|
||||
item.display_order = index
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({'success': True})
|
||||
@@ -0,0 +1,317 @@
|
||||
/* =============================================================
|
||||
iPad & iPad mini Responsive Overrides
|
||||
Target breakpoints:
|
||||
iPad mini : 744px × 1133px (portrait) / 1133px × 744px (landscape)
|
||||
iPad (10th): 820px × 1180px (portrait) / 1180px × 820px (landscape)
|
||||
iPad Air : 820px × 1180px (portrait)
|
||||
iPad Pro 11: 834px × 1194px (portrait)
|
||||
All covered by max-width: 1194px + touch-specific rules.
|
||||
============================================================= */
|
||||
|
||||
/* ── 1. Global touch-friendly baseline ── */
|
||||
@media (pointer: coarse), (max-width: 1194px) {
|
||||
/* Minimum 44px touch targets on interactive elements */
|
||||
.btn,
|
||||
.nav-link,
|
||||
.dropdown-item,
|
||||
input[type="checkbox"],
|
||||
input[type="radio"],
|
||||
.form-check-input {
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Slightly larger form controls for finger input */
|
||||
.form-control,
|
||||
.form-select {
|
||||
min-height: 44px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Prevent double-tap zoom on buttons */
|
||||
button,
|
||||
a {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 2. Navbar — collapse at iPad widths ── */
|
||||
@media (max-width: 1024px) {
|
||||
.navbar-collapse .navbar-nav .nav-link {
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.navbar-collapse {
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 3. Container padding on iPad ── */
|
||||
@media (max-width: 1024px) {
|
||||
.container-fluid {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 4. Dashboard stat cards — 2-up on iPad mini portrait ── */
|
||||
@media (max-width: 820px) {
|
||||
.col-md-3 {
|
||||
flex: 0 0 50%;
|
||||
max-width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 5. Form grid — THE critical fix ──
|
||||
The form editor/execute/preview use a fixed 12-column grid where
|
||||
each column is 72px wide (total ≈ 920px + gaps).
|
||||
On iPads this overflows horizontally.
|
||||
Solution: scale the grid using CSS custom properties + clamp()
|
||||
so it always fits within the viewport while maintaining proportions.
|
||||
─────────────────────────────────────────────────────────────── */
|
||||
@media (max-width: 1024px) {
|
||||
/* Override the fixed cell width with a fluid value */
|
||||
:root {
|
||||
--grid-cols: 12;
|
||||
--grid-gap: 8px;
|
||||
/* Each cell = (available_width - 11 gaps) / 12 columns */
|
||||
--cell-w-fluid: calc((min(96vw, 900px) - 11 * var(--grid-gap)) / var(--grid-cols));
|
||||
}
|
||||
|
||||
/* Execute & preview .form-grid */
|
||||
.form-grid {
|
||||
grid-template-columns: repeat(12, var(--cell-w-fluid)) !important;
|
||||
width: 100% !important; /* remove max-content so it fills container */
|
||||
overflow-x: visible !important;
|
||||
}
|
||||
|
||||
/* Row height scales proportionally: original ratio was 52/72 ≈ 0.72 */
|
||||
.form-grid {
|
||||
grid-auto-rows: calc(var(--cell-w-fluid) * 0.72) !important;
|
||||
}
|
||||
|
||||
/* Cells: let font scale with viewport */
|
||||
.fg-cell .field-lbl {
|
||||
font-size: clamp(0.6rem, 1.2vw, 0.74rem) !important;
|
||||
}
|
||||
|
||||
.fg-cell .form-control,
|
||||
.fg-cell .form-select {
|
||||
font-size: clamp(0.65rem, 1.3vw, 0.76rem) !important;
|
||||
min-height: unset; /* override the 44px rule inside grids — cells are constrained */
|
||||
padding: 0.15rem 0.35rem !important;
|
||||
}
|
||||
|
||||
/* insp-body / preview-body scroll container */
|
||||
.insp-body,
|
||||
.preview-body {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 1rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 6. Form editor layout — sidebar panels ── */
|
||||
@media (max-width: 1024px) {
|
||||
/* Editor uses a 3-column CSS grid: sidebar | canvas | properties.
|
||||
On iPad: stack canvas below sidebars, or let sidebars collapse. */
|
||||
.editor-layout {
|
||||
grid-template-columns: 1fr !important;
|
||||
grid-template-rows: auto 1fr auto !important;
|
||||
}
|
||||
|
||||
.editor-sidebar-left,
|
||||
.editor-sidebar-right {
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 7. Tables ── */
|
||||
@media (max-width: 1024px) {
|
||||
/* Ensure all tables have horizontal scroll */
|
||||
.table-responsive {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Tables not wrapped in .table-responsive */
|
||||
table:not(.tbl-field) {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Table action buttons — stack vertically if needed */
|
||||
td .btn + form,
|
||||
td .btn + .btn {
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 8. Cards & layout — improve spacing ── */
|
||||
@media (max-width: 820px) {
|
||||
/* Full-width cards on narrow iPad portrait */
|
||||
.col-md-6[class*="offset-"],
|
||||
.col-md-8[class*="offset-"] {
|
||||
margin-left: 0 !important;
|
||||
max-width: 100% !important;
|
||||
flex: 0 0 100% !important;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Page header action buttons — stack on small iPads */
|
||||
.col-md-4.text-end.d-flex {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem !important;
|
||||
justify-content: flex-start !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 9. Inspection execute — header & footer ── */
|
||||
@media (max-width: 1024px) {
|
||||
.insp-wrap {
|
||||
padding: 0 0.5rem 5rem !important;
|
||||
}
|
||||
|
||||
.insp-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start !important;
|
||||
gap: 0.75rem !important;
|
||||
padding: 1rem !important;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.insp-header .d-flex.gap-2 {
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.insp-footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch !important;
|
||||
gap: 0.5rem !important;
|
||||
padding: 0.75rem 1rem !important;
|
||||
}
|
||||
|
||||
.insp-footer .btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Flag Issue button — full width on small screens */
|
||||
.insp-header .btn {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 10. Preview header ── */
|
||||
@media (max-width: 1024px) {
|
||||
.preview-wrap {
|
||||
padding: 0.5rem 0.5rem 3rem !important;
|
||||
margin-top: 0.5rem !important;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start !important;
|
||||
gap: 0.75rem !important;
|
||||
padding: 1rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 11. Modals — full width on iPad mini ── */
|
||||
@media (max-width: 768px) {
|
||||
.modal-dialog {
|
||||
margin: 0.5rem;
|
||||
max-width: calc(100vw - 1rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 12. Rating stars — tap targets sized to grid cell, not oversized ── */
|
||||
@media (pointer: coarse) {
|
||||
/* Inside the inspection form grid, stars must stay compact to fit their cell.
|
||||
The grid cell height is ~50px; 1.6rem stars at 44px each would overflow. */
|
||||
.rating-stars button {
|
||||
font-size: 1rem; /* fits within grid cell rows */
|
||||
padding: 0.15rem;
|
||||
min-height: unset; /* do not enforce 44px — cell height controls this */
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
/* Outside the grid (e.g. standalone forms), keep comfortable tap targets */
|
||||
:not(.form-grid) .rating-stars button {
|
||||
font-size: 1.4rem;
|
||||
padding: 0.2rem;
|
||||
}
|
||||
|
||||
/* Upload zone — compact inside grid, comfortable outside */
|
||||
.upload-zone {
|
||||
min-height: 44px; /* reduced from 80px — fits grid cell */
|
||||
}
|
||||
|
||||
/* Signature pad — taller for finger drawing */
|
||||
.signature-pad-wrap {
|
||||
min-height: 120px !important;
|
||||
}
|
||||
|
||||
/* Sig clear button — larger touch target */
|
||||
.sig-clear {
|
||||
padding: 0.3rem 0.6rem !important;
|
||||
font-size: 0.75rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 13. Facility view action buttons row ── */
|
||||
@media (max-width: 1024px) {
|
||||
.col-md-4.text-end {
|
||||
text-align: left !important;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 14. Reports — filter bar ── */
|
||||
@media (max-width: 820px) {
|
||||
.row.g-3 .col-md-3,
|
||||
.row.g-3 .col-md-4,
|
||||
.row.g-3 .col-md-2 {
|
||||
flex: 0 0 50%;
|
||||
max-width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 15. Checklist items in legacy inspect view ── */
|
||||
@media (max-width: 1024px) {
|
||||
.checklist-item {
|
||||
padding: 0.75rem !important;
|
||||
}
|
||||
|
||||
.rating-option {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 16. Prevent subpixel rendering issues on Retina displays ── */
|
||||
@media (-webkit-min-device-pixel-ratio: 2) {
|
||||
.card,
|
||||
.modal-content {
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 17. Safe-area insets (iPad with home indicator) ── */
|
||||
@supports (padding-bottom: env(safe-area-inset-bottom)) {
|
||||
.insp-footer,
|
||||
.container-fluid {
|
||||
padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
/* ════════════════════════════════════════════════════════════════════════
|
||||
Janitorial QC — Global UI Theme
|
||||
────────────────────────────────────────────────────────────────────────
|
||||
Purpose: unify every signed-in page onto one polished design system by
|
||||
retuning Bootstrap's own components. UI/UX only — no markup/route/JS change.
|
||||
|
||||
Design tokens (matching the app's premium pages: inspections/view,
|
||||
templates/form_editor, etc.):
|
||||
Font DM Sans
|
||||
Accent #2563eb / hover #1d4ed8 / soft #eff6ff
|
||||
Slate text #0f172a · heading #374151 · muted #64748b / #94a3b8
|
||||
border #e2e8f0 / #cbd5e1 · surface #f8fafc / #f1f5f9 · page #eef0f4
|
||||
Chrome navbar #1a1d23 (matches the dark card-headers on premium pages)
|
||||
Green #16a34a Amber #f59e0b Danger #dc2626
|
||||
Shape radius .85rem cards / .5rem controls · soft shadows
|
||||
|
||||
Load order: AFTER bootstrap.min.css, BEFORE page-level {% block extra_css %}
|
||||
so any page's own styles still win.
|
||||
════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── 1. Design tokens (retune Bootstrap CSS variables) ───────────────────── */
|
||||
:root {
|
||||
--bs-primary: #2563eb;
|
||||
--bs-primary-rgb: 37, 99, 235;
|
||||
--bs-success: #16a34a;
|
||||
--bs-success-rgb: 22, 163, 74;
|
||||
--bs-warning: #f59e0b;
|
||||
--bs-warning-rgb: 245, 158, 11;
|
||||
--bs-danger: #dc2626;
|
||||
--bs-danger-rgb: 220, 38, 38;
|
||||
|
||||
--bs-link-color: #2563eb;
|
||||
--bs-link-color-rgb: 37, 99, 235;
|
||||
--bs-link-hover-color: #1d4ed8;
|
||||
--bs-link-hover-color-rgb: 29, 78, 216;
|
||||
|
||||
--bs-body-color: #0f172a;
|
||||
--bs-body-color-rgb: 15, 23, 42;
|
||||
--bs-secondary-color: rgba(100, 116, 139, 1);
|
||||
--bs-body-bg: #eef0f4;
|
||||
--bs-border-color: #e2e8f0;
|
||||
|
||||
--bs-border-radius: .55rem;
|
||||
--bs-border-radius-sm: .4rem;
|
||||
--bs-border-radius-lg: .85rem;
|
||||
--bs-border-radius-xl: 1rem;
|
||||
|
||||
--bs-body-font-family: 'DM Sans', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
|
||||
--jqc-ink: #0f172a;
|
||||
--jqc-heading: #374151;
|
||||
--jqc-muted: #64748b;
|
||||
--jqc-faint: #94a3b8;
|
||||
--jqc-border: #e2e8f0;
|
||||
--jqc-border-2: #cbd5e1;
|
||||
--jqc-surface: #f8fafc;
|
||||
--jqc-surface-2: #f1f5f9;
|
||||
--jqc-accent: #2563eb;
|
||||
--jqc-accent-700: #1d4ed8;
|
||||
--jqc-accent-50: #eff6ff;
|
||||
--jqc-navbar: #2563eb; /* bright blue top bar (matches the app accent) */
|
||||
--jqc-shadow: 0 1px 2px rgba(15, 23, 42, .06), 0 1px 3px rgba(15, 23, 42, .05);
|
||||
--jqc-shadow-md: 0 4px 24px rgba(15, 23, 42, .10);
|
||||
}
|
||||
|
||||
/* ── 2. Base typography ──────────────────────────────────────────────────── */
|
||||
body {
|
||||
font-family: var(--bs-body-font-family);
|
||||
background-color: var(--bs-body-bg);
|
||||
color: var(--jqc-ink);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6,
|
||||
.h1, .h2, .h3, .h4, .h5, .h6 {
|
||||
font-family: var(--bs-body-font-family);
|
||||
font-weight: 700;
|
||||
letter-spacing: -.01em;
|
||||
}
|
||||
.text-muted { color: var(--jqc-muted) !important; }
|
||||
a { text-underline-offset: 2px; }
|
||||
|
||||
/* ── 3. Navbar — bright blue chrome (app accent) ─────────────────────────── */
|
||||
.navbar.bg-primary {
|
||||
background-color: var(--jqc-navbar) !important;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, .08);
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, .12);
|
||||
}
|
||||
.navbar .navbar-brand {
|
||||
font-weight: 700;
|
||||
letter-spacing: -.01em;
|
||||
}
|
||||
.navbar-dark .navbar-nav .nav-link {
|
||||
color: rgba(255, 255, 255, .85);
|
||||
border-radius: 6px;
|
||||
padding-inline: .7rem;
|
||||
transition: background-color .15s, color .15s;
|
||||
}
|
||||
.navbar-dark .navbar-nav .nav-link:hover {
|
||||
color: #fff;
|
||||
background-color: rgba(255, 255, 255, .10);
|
||||
}
|
||||
.navbar-dark .navbar-nav .nav-link.active {
|
||||
background-color: rgba(255, 255, 255, .20) !important;
|
||||
box-shadow: inset 0 -2px 0 rgba(255, 255, 255, .65) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* ── 4. Cards ────────────────────────────────────────────────────────────── */
|
||||
.card {
|
||||
border: 1px solid var(--jqc-border);
|
||||
border-radius: var(--bs-border-radius-lg);
|
||||
box-shadow: var(--jqc-shadow);
|
||||
}
|
||||
.card.shadow-sm { box-shadow: var(--jqc-shadow) !important; }
|
||||
.card.shadow,
|
||||
.card.shadow-lg { box-shadow: var(--jqc-shadow-md) !important; }
|
||||
|
||||
.card-header {
|
||||
background-color: var(--jqc-surface);
|
||||
border-bottom: 1px solid var(--jqc-border);
|
||||
color: var(--jqc-heading);
|
||||
font-weight: 600;
|
||||
padding: .7rem 1rem;
|
||||
}
|
||||
.card-header.bg-light { background-color: var(--jqc-surface) !important; }
|
||||
.card-header.bg-white { background-color: #fff !important; }
|
||||
|
||||
/* Colored stat cards (dashboard) — flatten + soften corners */
|
||||
.card.text-white { border: none; }
|
||||
.card.text-white .text-white-50 { color: rgba(255, 255, 255, .72) !important; }
|
||||
|
||||
/* ── 5. Buttons ──────────────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
font-weight: 500;
|
||||
border-radius: var(--bs-border-radius);
|
||||
--bs-btn-focus-box-shadow: 0 0 0 .2rem rgba(37, 99, 235, .25);
|
||||
}
|
||||
.btn-primary {
|
||||
--bs-btn-bg: var(--jqc-accent);
|
||||
--bs-btn-border-color: var(--jqc-accent);
|
||||
--bs-btn-hover-bg: var(--jqc-accent-700);
|
||||
--bs-btn-hover-border-color: var(--jqc-accent-700);
|
||||
--bs-btn-active-bg: var(--jqc-accent-700);
|
||||
--bs-btn-active-border-color: var(--jqc-accent-700);
|
||||
--bs-btn-disabled-bg: var(--jqc-accent);
|
||||
--bs-btn-disabled-border-color: var(--jqc-accent);
|
||||
}
|
||||
.btn-outline-primary {
|
||||
--bs-btn-color: var(--jqc-accent);
|
||||
--bs-btn-border-color: var(--jqc-accent);
|
||||
--bs-btn-hover-bg: var(--jqc-accent);
|
||||
--bs-btn-hover-border-color: var(--jqc-accent);
|
||||
--bs-btn-active-bg: var(--jqc-accent);
|
||||
--bs-btn-active-border-color: var(--jqc-accent);
|
||||
}
|
||||
.btn-success {
|
||||
--bs-btn-bg: var(--bs-success); --bs-btn-border-color: var(--bs-success);
|
||||
--bs-btn-hover-bg: #15803d; --bs-btn-hover-border-color: #15803d;
|
||||
--bs-btn-active-bg: #15803d; --bs-btn-active-border-color: #15803d;
|
||||
--bs-btn-disabled-bg: var(--bs-success); --bs-btn-disabled-border-color: var(--bs-success);
|
||||
}
|
||||
.btn-danger {
|
||||
--bs-btn-bg: var(--bs-danger); --bs-btn-border-color: var(--bs-danger);
|
||||
--bs-btn-hover-bg: #b91c1c; --bs-btn-hover-border-color: #b91c1c;
|
||||
--bs-btn-active-bg: #b91c1c; --bs-btn-active-border-color: #b91c1c;
|
||||
--bs-btn-disabled-bg: var(--bs-danger); --bs-btn-disabled-border-color: var(--bs-danger);
|
||||
}
|
||||
.btn-warning {
|
||||
--bs-btn-bg: var(--bs-warning); --bs-btn-border-color: var(--bs-warning);
|
||||
--bs-btn-color: #422006; --bs-btn-hover-color: #422006;
|
||||
--bs-btn-hover-bg: #d97706; --bs-btn-hover-border-color: #d97706;
|
||||
--bs-btn-active-bg: #d97706; --bs-btn-active-border-color: #d97706;
|
||||
}
|
||||
.btn-outline-secondary {
|
||||
--bs-btn-color: var(--jqc-muted);
|
||||
--bs-btn-border-color: var(--jqc-border-2);
|
||||
--bs-btn-hover-bg: var(--jqc-surface);
|
||||
--bs-btn-hover-color: var(--jqc-accent);
|
||||
--bs-btn-hover-border-color: var(--jqc-accent);
|
||||
--bs-btn-active-bg: var(--jqc-surface-2);
|
||||
--bs-btn-active-color: var(--jqc-ink);
|
||||
--bs-btn-active-border-color: var(--jqc-border-2);
|
||||
}
|
||||
.btn-link { --bs-btn-color: var(--jqc-accent); --bs-btn-hover-color: var(--jqc-accent-700); text-decoration: none; }
|
||||
|
||||
/* ── 6. Forms ────────────────────────────────────────────────────────────── */
|
||||
.form-label {
|
||||
color: var(--jqc-heading);
|
||||
font-weight: 500;
|
||||
margin-bottom: .35rem;
|
||||
}
|
||||
.form-control, .form-select {
|
||||
border-color: var(--jqc-border);
|
||||
color: var(--jqc-ink);
|
||||
border-radius: var(--bs-border-radius);
|
||||
}
|
||||
.form-control::placeholder { color: var(--jqc-faint); }
|
||||
.form-control:focus, .form-select:focus {
|
||||
border-color: var(--jqc-accent);
|
||||
box-shadow: 0 0 0 .2rem rgba(37, 99, 235, .18);
|
||||
}
|
||||
.input-group-text {
|
||||
background-color: var(--jqc-surface);
|
||||
border-color: var(--jqc-border);
|
||||
color: var(--jqc-muted);
|
||||
}
|
||||
.form-check-input:checked {
|
||||
background-color: var(--jqc-accent);
|
||||
border-color: var(--jqc-accent);
|
||||
}
|
||||
.form-check-input:focus {
|
||||
border-color: var(--jqc-accent);
|
||||
box-shadow: 0 0 0 .2rem rgba(37, 99, 235, .18);
|
||||
}
|
||||
|
||||
/* ── 7. Tables ───────────────────────────────────────────────────────────── */
|
||||
.table {
|
||||
--bs-table-color: var(--jqc-ink);
|
||||
--bs-table-border-color: var(--jqc-border);
|
||||
border-color: var(--jqc-border);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.table > thead th,
|
||||
.table-light, .table-light > th, .table-light > td {
|
||||
background-color: var(--jqc-surface-2);
|
||||
color: var(--jqc-heading);
|
||||
font-weight: 600;
|
||||
border-bottom-color: var(--jqc-border);
|
||||
}
|
||||
.table > thead th {
|
||||
text-transform: none;
|
||||
font-size: .82rem;
|
||||
letter-spacing: .01em;
|
||||
padding-block: .6rem;
|
||||
}
|
||||
.table > tbody > tr { border-color: var(--jqc-border); }
|
||||
.table-hover > tbody > tr:hover > * {
|
||||
background-color: var(--jqc-surface);
|
||||
--bs-table-bg-state: var(--jqc-surface);
|
||||
}
|
||||
/* Cards that wrap a flush table: clip corners so the table follows the radius */
|
||||
.card > .table-responsive:last-child,
|
||||
.card > .card-body.p-0 > .table-responsive,
|
||||
.card > .card-body.p-0 > .table {
|
||||
border-bottom-left-radius: var(--bs-border-radius-lg);
|
||||
border-bottom-right-radius: var(--bs-border-radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── 8. Badges ───────────────────────────────────────────────────────────── */
|
||||
.badge {
|
||||
font-weight: 600;
|
||||
letter-spacing: .01em;
|
||||
border-radius: .4rem;
|
||||
padding: .35em .6em;
|
||||
}
|
||||
|
||||
/* ── 9. Alerts ───────────────────────────────────────────────────────────── */
|
||||
.alert {
|
||||
border-radius: var(--bs-border-radius);
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
/* ── 10. Dropdowns ───────────────────────────────────────────────────────── */
|
||||
.dropdown-menu {
|
||||
border: 1px solid var(--jqc-border);
|
||||
border-radius: var(--bs-border-radius);
|
||||
box-shadow: var(--jqc-shadow-md);
|
||||
padding: .35rem;
|
||||
--bs-dropdown-link-active-bg: var(--jqc-accent);
|
||||
}
|
||||
.dropdown-item {
|
||||
border-radius: var(--bs-border-radius-sm);
|
||||
padding: .45rem .7rem;
|
||||
color: var(--jqc-ink);
|
||||
}
|
||||
.dropdown-item:hover, .dropdown-item:focus {
|
||||
background-color: var(--jqc-surface);
|
||||
color: var(--jqc-accent);
|
||||
}
|
||||
.dropdown-divider { border-top-color: var(--jqc-border); }
|
||||
|
||||
/* ── 11. Misc components ─────────────────────────────────────────────────── */
|
||||
.page-link { color: var(--jqc-accent); }
|
||||
.page-item.active .page-link {
|
||||
background-color: var(--jqc-accent);
|
||||
border-color: var(--jqc-accent);
|
||||
}
|
||||
.nav-tabs .nav-link.active { color: var(--jqc-accent); }
|
||||
.list-group-item-action:hover { background-color: var(--jqc-surface); }
|
||||
.modal-content { border: 1px solid var(--jqc-border); border-radius: var(--bs-border-radius-lg); }
|
||||
.spinner-border.text-primary { color: var(--jqc-accent) !important; }
|
||||
hr { color: var(--jqc-border-2); }
|
||||
|
||||
/* Notification dropdown (base.html) — align accent + hover */
|
||||
.notif-item.unread { border-left-color: var(--jqc-accent) !important; background-color: var(--jqc-accent-50) !important; }
|
||||
.notif-item:hover { background-color: var(--jqc-surface) !important; }
|
||||
|
||||
/* ── 12. Focus visibility (accessibility) ────────────────────────────────── */
|
||||
:focus-visible { outline: 2px solid rgba(37, 99, 235, .45); outline-offset: 1px; }
|
||||
@@ -0,0 +1,27 @@
|
||||
{#
|
||||
_sla_badge.html — Reusable SLA status badge macro.
|
||||
|
||||
Usage:
|
||||
{% from '_sla_badge.html' import sla_badge %}
|
||||
{{ sla_badge(issue) }}
|
||||
#}
|
||||
|
||||
{% macro sla_badge(issue) %}
|
||||
{% set status = sla_status(issue) %}
|
||||
{% if status == 'breached' %}
|
||||
<span class="badge sla-breached" title="SLA breached — past deadline">
|
||||
<i class="bi bi-alarm me-1"></i>SLA Breached
|
||||
</span>
|
||||
{% elif status == 'at_risk' %}
|
||||
{% set hrs = sla_hours_remaining(issue) %}
|
||||
<span class="badge sla-at-risk" title="SLA at risk — {{ hrs }}h remaining">
|
||||
<i class="bi bi-alarm me-1"></i>At Risk{% if hrs is not none %} · {{ hrs }}h{% endif %}
|
||||
</span>
|
||||
{% elif status == 'ok' %}
|
||||
{% set hrs = sla_hours_remaining(issue) %}
|
||||
<span class="badge sla-ok" title="Within SLA — {{ hrs }}h remaining">
|
||||
<i class="bi bi-check-circle me-1"></i>On Track{% if hrs is not none %} · {{ hrs }}h{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{# resolved issues show nothing #}
|
||||
{% endmacro %}
|
||||
@@ -0,0 +1,225 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Broadcast Notifications{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2><i class="bi bi-megaphone-fill me-2"></i>Broadcast Notification</h2>
|
||||
<p class="text-muted mb-0">
|
||||
Send an in-app notification to all active iOS app users in the selected roles.
|
||||
Messages are delivered within 60 seconds via the app's background poll.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="row g-4">
|
||||
|
||||
{# ── Compose Form ─────────────────────────────────────────────────── #}
|
||||
<div class="col-lg-5">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<i class="bi bi-send-fill me-2"></i><strong>Compose Message</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('broadcast.send') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label fw-semibold">
|
||||
Title <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text" class="form-control" id="title" name="title"
|
||||
maxlength="255" required placeholder="e.g. App Update Available">
|
||||
<div class="form-text">Appears as the notification banner title on iPad.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="body" class="form-label fw-semibold">
|
||||
Message <span class="text-danger">*</span>
|
||||
</label>
|
||||
<textarea class="form-control" id="body" name="body"
|
||||
rows="4" required maxlength="500"
|
||||
placeholder="e.g. A new version of JanitorialQC is available. Please update to v1.3 from the App Store."></textarea>
|
||||
<div class="d-flex justify-content-between align-items-center mt-1">
|
||||
<span class="form-text mb-0">The full message body shown in the notification and in-app inbox.</span>
|
||||
<span id="bodyCounter" class="form-text mb-0 text-muted">0 / 500</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">
|
||||
Target Roles <span class="text-danger">*</span>
|
||||
</label>
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
{% for role in roles %}
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
name="roles" value="{{ role }}"
|
||||
id="role_{{ role }}"
|
||||
{% if role == 'inspector' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="role_{{ role }}">
|
||||
{{ role_labels[role] }}
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="form-text">Only active users in the selected roles will receive this message.</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary btn-lg"
|
||||
onclick="return confirm('Send this broadcast to all selected users?')">
|
||||
<i class="bi bi-send-fill me-2"></i>Send Broadcast
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Broadcast History ─────────────────────────────────────────────── #}
|
||||
<div class="col-lg-7">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-clock-history me-2"></i><strong>Recent Broadcasts</strong>
|
||||
<span class="text-muted fw-normal ms-2">(last 50)</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if history %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Sent</th>
|
||||
<th>Title</th>
|
||||
<th>Roles</th>
|
||||
<th class="text-center">Recipients</th>
|
||||
<th>By</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for b in history %}
|
||||
<tr class="broadcast-row" style="cursor:pointer;"
|
||||
data-title="{{ b.title | e }}"
|
||||
data-body="{{ b.body | e }}"
|
||||
data-sent-at="{{ b.sent_at.strftime('%b %-d, %Y %I:%M %p') }}"
|
||||
data-sent-by="{{ (b.sent_by.display_name if b.sent_by else '—') | e }}"
|
||||
data-recipients="{{ b.recipient_count }}"
|
||||
data-roles="{{ b.target_roles | join(',') }}">
|
||||
<td class="text-nowrap text-muted small">
|
||||
{{ b.sent_at.strftime('%b %-d, %Y') }}<br>
|
||||
<span class="text-muted" style="font-size:.75rem;">
|
||||
{{ b.sent_at.strftime('%I:%M %p') }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-semibold">{{ b.title }}</div>
|
||||
<div class="text-muted small text-truncate" style="max-width:220px;">{{ b.body }}</div>
|
||||
</td>
|
||||
<td>
|
||||
{% for role in b.target_roles %}
|
||||
<span class="badge bg-secondary me-1">
|
||||
{{ role_labels.get(role, role) }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="badge bg-primary rounded-pill">
|
||||
{{ b.recipient_count }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="small text-muted">
|
||||
{{ b.sent_by.display_name if b.sent_by else '—' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-megaphone fs-1 d-block mb-2 opacity-25"></i>
|
||||
No broadcasts sent yet.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /row -->
|
||||
|
||||
{# ── Broadcast Detail Modal ──────────────────────────────────────────────── #}
|
||||
<div class="modal fade" id="broadcastDetailModal" tabindex="-1" aria-labelledby="broadcastDetailTitle" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-primary text-white">
|
||||
<h5 class="modal-title" id="broadcastDetailTitle">
|
||||
<i class="bi bi-megaphone-fill me-2"></i><span id="bdTitle"></span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="bdBody" class="mb-3" style="white-space:pre-wrap;"></p>
|
||||
<hr>
|
||||
<dl class="row mb-0 small text-muted">
|
||||
<dt class="col-sm-4">Sent</dt>
|
||||
<dd class="col-sm-8" id="bdSentAt"></dd>
|
||||
<dt class="col-sm-4">By</dt>
|
||||
<dd class="col-sm-8" id="bdSentBy"></dd>
|
||||
<dt class="col-sm-4">Recipients</dt>
|
||||
<dd class="col-sm-8" id="bdRecipients"></dd>
|
||||
<dt class="col-sm-4">Roles</dt>
|
||||
<dd class="col-sm-8" id="bdRoles"></dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var ROLE_LABELS = {{ role_labels | tojson }};
|
||||
|
||||
document.querySelectorAll('.broadcast-row').forEach(function(row) {
|
||||
row.addEventListener('click', function() {
|
||||
var roles = this.dataset.roles ? this.dataset.roles.split(',') : [];
|
||||
document.getElementById('bdTitle').textContent = this.dataset.title;
|
||||
document.getElementById('bdBody').textContent = this.dataset.body;
|
||||
document.getElementById('bdSentAt').textContent = this.dataset.sentAt;
|
||||
document.getElementById('bdSentBy').textContent = this.dataset.sentBy;
|
||||
document.getElementById('bdRecipients').textContent = this.dataset.recipients + ' user(s)';
|
||||
document.getElementById('bdRoles').textContent = roles.map(function(r) {
|
||||
return ROLE_LABELS[r] || r;
|
||||
}).join(', ');
|
||||
new bootstrap.Modal(document.getElementById('broadcastDetailModal')).show();
|
||||
});
|
||||
});
|
||||
|
||||
(function () {
|
||||
var textarea = document.getElementById('body');
|
||||
var counter = document.getElementById('bodyCounter');
|
||||
var MAX = 500;
|
||||
|
||||
function update() {
|
||||
var len = textarea.value.length;
|
||||
counter.textContent = len + ' / ' + MAX;
|
||||
counter.className = 'form-text mb-0 ' + (len >= MAX ? 'text-danger fw-semibold' : len >= MAX * 0.9 ? 'text-warning' : 'text-muted');
|
||||
}
|
||||
|
||||
textarea.addEventListener('input', update);
|
||||
update();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,254 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Audit Trail{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3 align-items-center">
|
||||
<div class="col">
|
||||
<h2><i class="bi bi-shield-check"></i> Audit Trail</h2>
|
||||
<p class="text-muted mb-0">Complete, immutable log of all system actions.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Filter Bar ──────────────────────────────────────────────────────────── -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body py-3">
|
||||
<form method="GET" action="{{ url_for('audit.index') }}" class="row g-2 align-items-end">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label form-label-sm fw-semibold mb-1">User</label>
|
||||
<select name="user_id" class="form-select form-select-sm">
|
||||
<option value="">All Users</option>
|
||||
{% for u in users %}
|
||||
<option value="{{ u.id }}" {% if filter_user == u.id|string %}selected{% endif %}>
|
||||
{{ u.username }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label form-label-sm fw-semibold mb-1">Action</label>
|
||||
<select name="action" class="form-select form-select-sm">
|
||||
<option value="">All Actions</option>
|
||||
{% for a in distinct_actions %}
|
||||
<option value="{{ a }}" {% if filter_action == a %}selected{% endif %}>{{ a }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label form-label-sm fw-semibold mb-1">Entity Type</label>
|
||||
<select name="entity_type" class="form-select form-select-sm">
|
||||
<option value="">All Types</option>
|
||||
{% for t in distinct_entity_types %}
|
||||
<option value="{{ t }}" {% if filter_entity_type == t %}selected{% endif %}>{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label form-label-sm fw-semibold mb-1">From Date</label>
|
||||
<input type="date" name="date_from" class="form-control form-control-sm"
|
||||
value="{{ filter_date_from }}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label form-label-sm fw-semibold mb-1">To Date</label>
|
||||
<input type="date" name="date_to" class="form-control form-control-sm"
|
||||
value="{{ filter_date_to }}">
|
||||
</div>
|
||||
<div class="col-md-2 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm flex-fill">
|
||||
<i class="bi bi-funnel me-1"></i>Filter
|
||||
</button>
|
||||
<a href="{{ url_for('audit.index') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-x-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Results ─────────────────────────────────────────────────────────────── -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<span class="fw-semibold">
|
||||
<i class="bi bi-list-ul me-1"></i>
|
||||
{{ logs.total }} record{{ 's' if logs.total != 1 else '' }}
|
||||
{% if filter_user or filter_action or filter_entity_type or filter_date_from or filter_date_to %}
|
||||
<span class="badge bg-info ms-1">Filtered</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<small class="text-muted">Page {{ logs.page }} of {{ logs.pages }}</small>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger"
|
||||
data-bs-toggle="modal" data-bs-target="#purgeModal">
|
||||
<i class="bi bi-trash me-1"></i>Purge Old Logs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if logs.items %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:160px">Timestamp</th>
|
||||
<th>User</th>
|
||||
<th>Role</th>
|
||||
<th>Action</th>
|
||||
<th>Entity</th>
|
||||
<th>Label</th>
|
||||
<th>IP Address</th>
|
||||
<th style="width:60px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in logs.items %}
|
||||
<tr>
|
||||
<td class="text-nowrap text-muted small">
|
||||
{{ entry.created_at.strftime('%Y-%m-%d %H:%M:%S') }}
|
||||
</td>
|
||||
<td>
|
||||
<strong>{{ entry.username }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-{% if entry.user_role == 'admin' %}danger{% elif entry.user_role == 'director' %}warning{% else %}info{% endif %} bg-opacity-75">
|
||||
{{ entry.user_role | title }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge
|
||||
{% if entry.action == 'CREATE' %}bg-success
|
||||
{% elif entry.action == 'UPDATE' %}bg-primary
|
||||
{% elif entry.action == 'DELETE' %}bg-danger
|
||||
{% elif entry.action == 'LOGIN' %}bg-secondary
|
||||
{% elif entry.action == 'LOGOUT' %}bg-secondary
|
||||
{% elif entry.action == 'EXPORT' %}bg-warning text-dark
|
||||
{% else %}bg-light text-dark{% endif %}">
|
||||
{{ entry.action }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-muted small">{{ entry.entity_type }}</td>
|
||||
<td class="small">
|
||||
{{ entry.entity_label or '—' }}
|
||||
{% if entry.entity_id %}
|
||||
<span class="text-muted">#{{ entry.entity_id }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-muted small font-monospace">
|
||||
{{ entry.ip_address or '—' }}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('audit.view', log_id=entry.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary py-0 px-2">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="bi bi-shield-check fs-2 d-block mb-2"></i>
|
||||
No audit records match the current filters.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if logs.pages > 1 %}
|
||||
<div class="card-footer bg-light d-flex justify-content-center">
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
{% if logs.has_prev %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('audit.index', page=logs.prev_num,
|
||||
user_id=filter_user, action=filter_action,
|
||||
entity_type=filter_entity_type,
|
||||
date_from=filter_date_from, date_to=filter_date_to) }}">
|
||||
« Prev
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% for p in logs.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
||||
{% if p %}
|
||||
<li class="page-item {% if p == logs.page %}active{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('audit.index', page=p,
|
||||
user_id=filter_user, action=filter_action,
|
||||
entity_type=filter_entity_type,
|
||||
date_from=filter_date_from, date_to=filter_date_to) }}">
|
||||
{{ p }}
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if logs.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('audit.index', page=logs.next_num,
|
||||
user_id=filter_user, action=filter_action,
|
||||
entity_type=filter_entity_type,
|
||||
date_from=filter_date_from, date_to=filter_date_to) }}">
|
||||
Next »
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- ── Purge Modal ──────────────────────────────────────────────────────────── -->
|
||||
<div class="modal fade" id="purgeModal" tabindex="-1" aria-labelledby="purgeModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-danger text-white">
|
||||
<h5 class="modal-title" id="purgeModalLabel">
|
||||
<i class="bi bi-trash me-2"></i>Purge Old Audit Logs
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('audit.purge') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning d-flex align-items-center gap-2 mb-3">
|
||||
<i class="bi bi-exclamation-triangle-fill fs-5 flex-shrink-0"></i>
|
||||
<span>This action is <strong>permanent and irreversible.</strong>
|
||||
Deleted log entries cannot be recovered.</span>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Delete logs older than</label>
|
||||
<select name="older_than" class="form-select" id="purgeOlderThan" required>
|
||||
<option value="">— Select a threshold —</option>
|
||||
<option value="7">7 days</option>
|
||||
<option value="30">30 days</option>
|
||||
<option value="60">60 days</option>
|
||||
<option value="90">90 days</option>
|
||||
<option value="180">180 days</option>
|
||||
<option value="365">1 year</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="text-muted small mb-0">
|
||||
All audit log entries created before the selected threshold will be
|
||||
permanently deleted. A single audit entry recording this purge will
|
||||
be retained.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger" id="purgeSubmitBtn" disabled>
|
||||
<i class="bi bi-trash me-1"></i>Purge Logs
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('purgeOlderThan').addEventListener('change', function () {
|
||||
document.getElementById('purgeSubmitBtn').disabled = !this.value;
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,111 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Audit Entry #{{ entry.id }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4 align-items-center">
|
||||
<div class="col">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-1">
|
||||
<li class="breadcrumb-item">
|
||||
<a href="{{ url_for('audit.index') }}">Audit Trail</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item active">Entry #{{ entry.id }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h2 class="mb-0"><i class="bi bi-shield-check"></i> Audit Entry #{{ entry.id }}</h2>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="{{ url_for('audit.index') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to Audit Trail
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-light fw-semibold">
|
||||
<i class="bi bi-info-circle me-1"></i>Event Details
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-4 text-muted">Action</dt>
|
||||
<dd class="col-sm-8">
|
||||
<span class="badge fs-6
|
||||
{% if entry.action == 'CREATE' %}bg-success
|
||||
{% elif entry.action == 'UPDATE' %}bg-primary
|
||||
{% elif entry.action == 'DELETE' %}bg-danger
|
||||
{% elif entry.action in ('LOGIN','LOGOUT') %}bg-secondary
|
||||
{% elif entry.action == 'EXPORT' %}bg-warning text-dark
|
||||
{% else %}bg-light text-dark{% endif %}">
|
||||
{{ entry.action }}
|
||||
</span>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4 text-muted">Entity Type</dt>
|
||||
<dd class="col-sm-8">{{ entry.entity_type }}</dd>
|
||||
|
||||
<dt class="col-sm-4 text-muted">Entity ID</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% if entry.entity_id %}#{{ entry.entity_id }}{% else %}<span class="text-muted">—</span>{% endif %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4 text-muted">Label</dt>
|
||||
<dd class="col-sm-8">{{ entry.entity_label or '—' }}</dd>
|
||||
|
||||
<dt class="col-sm-4 text-muted">Details</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% if entry.details %}
|
||||
<code class="text-break">{{ entry.details }}</code>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-light fw-semibold">
|
||||
<i class="bi bi-person-circle me-1"></i>Actor & Context
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-4 text-muted">Username</dt>
|
||||
<dd class="col-sm-8">
|
||||
<strong>{{ entry.username }}</strong>
|
||||
{% if entry.user_id %}
|
||||
<span class="text-muted small">(ID #{{ entry.user_id }})</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary ms-1">Deleted</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4 text-muted">Role at Time</dt>
|
||||
<dd class="col-sm-8">
|
||||
<span class="badge bg-{% if entry.user_role == 'admin' %}danger{% elif entry.user_role == 'director' %}warning{% else %}info{% endif %}">
|
||||
{{ entry.user_role | title }}
|
||||
</span>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4 text-muted">Timestamp</dt>
|
||||
<dd class="col-sm-8">
|
||||
<span class="font-monospace">
|
||||
{{ entry.created_at.strftime('%Y-%m-%d %H:%M:%S') }}
|
||||
</span>
|
||||
<small class="text-muted ms-1">Eastern Time</small>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4 text-muted">IP Address</dt>
|
||||
<dd class="col-sm-8">
|
||||
<code>{{ entry.ip_address or '—' }}</code>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,79 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Forgot Password — Janitorial QC</title>
|
||||
<link rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||
<style>
|
||||
body { background: #eef0f4; font-family: 'Segoe UI', Arial, sans-serif; }
|
||||
.setup-card {
|
||||
max-width: 460px; margin: 80px auto;
|
||||
border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,.1);
|
||||
}
|
||||
.setup-header {
|
||||
background: #1a1d23; color: #fff;
|
||||
border-radius: 12px 12px 0 0;
|
||||
padding: 1.5rem 1.75rem 1.25rem;
|
||||
}
|
||||
.setup-header h4 { margin: 0; font-weight: 600; }
|
||||
.setup-header p { color: #94a3b8; font-size: .85rem; margin: .35rem 0 0; }
|
||||
.setup-body { background: #fff; border-radius: 0 0 12px 12px; padding: 1.75rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="setup-card">
|
||||
<div class="setup-header">
|
||||
<h4><i class="bi bi-envelope-open me-2"></i>Forgot Your Password?</h4>
|
||||
<p>Enter the email address on your account and we'll send you a reset link.</p>
|
||||
</div>
|
||||
<div class="setup-body">
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ 'danger' if cat == 'danger' else 'warning' if cat == 'warning' else 'info' if cat == 'info' else 'success' }}
|
||||
alert-dismissible fade show py-2 mb-3" role="alert">
|
||||
{{ msg }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="email" class="form-label fw-semibold">Email Address</label>
|
||||
<input type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
class="form-control {{ 'is-invalid' if form.email.errors else '' }}"
|
||||
autocomplete="email"
|
||||
autofocus
|
||||
placeholder="you@example.com">
|
||||
{% for error in form.email.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="bi bi-send me-1"></i>Send Reset Link
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<a href="{{ url_for('auth.login') }}" class="text-muted small">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to Login
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,84 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Contract Assignments — {{ user.display_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2><i class="bi bi-briefcase text-primary me-2"></i>Contract Assignments</h2>
|
||||
<p class="text-muted mb-0">
|
||||
Inspector <strong>{{ user.display_name }}</strong> can only access facilities
|
||||
belonging to the contracts ticked below.
|
||||
</p>
|
||||
</div>
|
||||
<a href="{{ url_for('auth.list_users') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Users
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<span class="fw-semibold">Active Contracts</span>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge bg-primary" id="assignedCount">{{ assigned_pids|length }} assigned</span>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="setAll(true)">Select All</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="setAll(false)">Deselect All</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if projects %}
|
||||
<div class="list-group list-group-flush">
|
||||
{% for project in projects %}
|
||||
<label class="list-group-item list-group-item-action d-flex align-items-center gap-3 py-3">
|
||||
<input class="form-check-input flex-shrink-0" type="checkbox"
|
||||
name="project_ids" value="{{ project.id }}"
|
||||
{{ 'checked' if project.id in assigned_pids }}>
|
||||
<div>
|
||||
<div class="fw-semibold">{{ project.name }}</div>
|
||||
{% if project.description %}
|
||||
<div class="text-muted small">{{ project.description }}</div>
|
||||
{% endif %}
|
||||
<div class="text-muted small">
|
||||
{{ project.facilities.count() }} facilit{{ 'ies' if project.facilities.count() != 1 else 'y' }}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-body text-muted">
|
||||
No active contracts exist. Create a contract first.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if projects %}
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-floppy me-1"></i>Save Assignments
|
||||
</button>
|
||||
<a href="{{ url_for('auth.list_users') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function setAll(checked) {
|
||||
document.querySelectorAll('input[name="project_ids"]').forEach(cb => cb.checked = checked);
|
||||
updateCount();
|
||||
}
|
||||
|
||||
function updateCount() {
|
||||
const n = document.querySelectorAll('input[name="project_ids"]:checked').length;
|
||||
document.getElementById('assignedCount').textContent = n + ' assigned';
|
||||
}
|
||||
|
||||
document.querySelectorAll('input[name="project_ids"]').forEach(cb => {
|
||||
cb.addEventListener('change', updateCount);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,334 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Login - Janitorial QC{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* ════════════════════════════════════════════════════════════════════
|
||||
Login page — split-screen layout
|
||||
Theme colors preserved: brand #1a56db / #1a4ab5 / #1551c7,
|
||||
quality-green #16a34a, amber #d97706 / #f59e0b, light-blue surfaces.
|
||||
════════════════════════════════════════════════════════════════════ */
|
||||
body.login-page { min-height: 100vh; background: #fff; }
|
||||
|
||||
/* Let the split fill the viewport: neutralize base.html content wrapper */
|
||||
body.login-page .container-fluid.mt-4 {
|
||||
margin-top: 0 !important;
|
||||
padding: 0;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* Flash messages float as top-center toasts instead of pushing layout */
|
||||
body.login-page .container-fluid.mt-4 > .alert {
|
||||
position: fixed;
|
||||
top: 1rem; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1080;
|
||||
min-width: 320px; max-width: 92vw;
|
||||
box-shadow: 0 8px 28px rgba(0,0,0,.18);
|
||||
}
|
||||
|
||||
.login-split { display: flex; min-height: 100vh; }
|
||||
|
||||
/* ── LEFT: brand / QC showcase ──────────────────────────────────────── */
|
||||
.login-brand {
|
||||
flex: 1 1 55%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #1a56db 0%, #1a4ab5 55%, #1551c7 100%);
|
||||
color: #fff;
|
||||
padding: 3rem 3.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 1.75rem;
|
||||
}
|
||||
.login-brand::before { /* dotted texture */
|
||||
content: "";
|
||||
position: absolute; inset: 0;
|
||||
background-image: radial-gradient(rgba(255,255,255,.12) 1.5px, transparent 1.5px);
|
||||
background-size: 26px 26px;
|
||||
opacity: .55; pointer-events: none;
|
||||
}
|
||||
.login-brand::after { /* soft glow */
|
||||
content: "";
|
||||
position: absolute; width: 460px; height: 460px;
|
||||
right: -140px; top: -120px;
|
||||
background: radial-gradient(circle, rgba(255,255,255,.14), transparent 70%);
|
||||
border-radius: 50%; pointer-events: none;
|
||||
}
|
||||
.login-brand > * { position: relative; z-index: 2; max-width: 500px; }
|
||||
|
||||
.brand-logo { display: flex; align-items: center; gap: .8rem; }
|
||||
.brand-logo .logo-badge {
|
||||
width: 50px; height: 50px; border-radius: 13px;
|
||||
background: rgba(255,255,255,.16);
|
||||
border: 1px solid rgba(255,255,255,.22);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
.brand-logo .name { font-weight: 700; font-size: 1.3rem; line-height: 1.1; }
|
||||
.brand-logo .sub { font-size: .8rem; opacity: .82; }
|
||||
|
||||
.brand-headline { font-size: 2.05rem; font-weight: 700; line-height: 1.22; }
|
||||
.brand-sub { opacity: .86; font-size: 1.02rem; line-height: 1.55; }
|
||||
|
||||
.feature-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 1.05rem; }
|
||||
.feature-list li { display: flex; align-items: flex-start; gap: .85rem; }
|
||||
.feature-list .fi {
|
||||
flex: 0 0 auto; width: 32px; height: 32px; border-radius: 50%;
|
||||
background: #16a34a; color: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: .95rem; box-shadow: 0 0 0 4px rgba(22,163,74,.22);
|
||||
}
|
||||
.feature-list .ft { font-weight: 600; line-height: 1.2; }
|
||||
.feature-list .fd { font-size: .85rem; opacity: .82; }
|
||||
|
||||
.stat-row { display: flex; gap: .75rem; flex-wrap: wrap; }
|
||||
.stat-chip {
|
||||
background: rgba(255,255,255,.12);
|
||||
border: 1px solid rgba(255,255,255,.20);
|
||||
border-radius: 12px; padding: .6rem 1.05rem; min-width: 102px;
|
||||
}
|
||||
.stat-chip .v { font-size: 1.4rem; font-weight: 700; line-height: 1; }
|
||||
.stat-chip .l { font-size: .7rem; opacity: .82; text-transform: uppercase; letter-spacing: .6px; margin-top: .25rem; }
|
||||
.stat-chip.g .v { color: #86efac; }
|
||||
.stat-chip.a .v { color: #fcd34d; }
|
||||
|
||||
/* Decorative janitorial / QC illustration anchored bottom-right */
|
||||
.brand-illustration {
|
||||
position: absolute; right: -8px; bottom: -10px;
|
||||
width: 340px; max-width: 42%; height: auto;
|
||||
z-index: 1; opacity: .95; pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── RIGHT: sign-in form ────────────────────────────────────────────── */
|
||||
.login-form-side {
|
||||
flex: 1 1 45%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 2.5rem 1.5rem; background: #fff;
|
||||
}
|
||||
.login-form-inner { width: 100%; max-width: 400px; }
|
||||
|
||||
.form-mobile-brand { display: none; } /* shown only on small screens */
|
||||
.form-mobile-brand .logo-badge {
|
||||
width: 56px; height: 56px; border-radius: 15px;
|
||||
background: #1a56db; color: #fff;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 1.8rem;
|
||||
}
|
||||
.form-mobile-brand .name { font-weight: 700; color: #1a56db; font-size: 1.2rem; margin-top: .55rem; }
|
||||
.form-mobile-brand .sub { color: #6b7280; font-size: .8rem; }
|
||||
|
||||
.login-form-inner h2 { font-weight: 700; color: #16264a; margin-bottom: .25rem; }
|
||||
.login-form-inner .lead-sub { color: #6b7280; margin-bottom: 1.75rem; }
|
||||
|
||||
.login-form-side .input-group-text {
|
||||
background: #f0f7ff; border-color: #d4e1f4; color: #1a56db;
|
||||
}
|
||||
.login-form-side .form-control:focus {
|
||||
border-color: #1a56db; box-shadow: 0 0 0 .2rem rgba(26,86,219,.18);
|
||||
}
|
||||
.login-form-side .pw-toggle {
|
||||
border-color: #d4e1f4; color: #6b8fc7; background: #fff;
|
||||
}
|
||||
.login-form-side .pw-toggle:hover { color: #1a56db; }
|
||||
|
||||
.btn-login {
|
||||
background: #1a56db; border-color: #1a56db; color: #fff; font-weight: 600;
|
||||
}
|
||||
.btn-login:hover, .btn-login:focus { background: #1648b8; border-color: #1648b8; color: #fff; }
|
||||
|
||||
.login-foot { margin-top: 2rem; text-align: center; color: #9aa3af; font-size: .8rem; }
|
||||
|
||||
/* ── Responsive: collapse to single column ──────────────────────────── */
|
||||
@media (max-width: 991.98px) {
|
||||
.login-brand { display: none; }
|
||||
.form-mobile-brand {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
text-align: center; margin-bottom: 1.75rem;
|
||||
}
|
||||
.login-form-inner h2, .login-form-inner .lead-sub { text-align: center; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<script>document.body.classList.add('login-page');</script>
|
||||
|
||||
<div class="login-split">
|
||||
|
||||
{# ══ LEFT: brand / quality-control showcase ══ #}
|
||||
<section class="login-brand">
|
||||
<div class="brand-logo">
|
||||
<span class="logo-badge"><i class="bi bi-clipboard-check-fill"></i></span>
|
||||
<span>
|
||||
<span class="name d-block">Janitorial Quality Control System</span>
|
||||
<span class="sub">By L.T Services, Inc</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 class="brand-headline mb-2">Quality control for spotless facilities.</h1>
|
||||
<p class="brand-sub mb-0">Standardized inspections, real-time issue tracking, and
|
||||
clear performance reporting — keeping every facility audit-ready.</p>
|
||||
</div>
|
||||
|
||||
<ul class="feature-list">
|
||||
<li>
|
||||
<span class="fi"><i class="bi bi-clipboard-check"></i></span>
|
||||
<span><span class="ft d-block">Standardized facility inspections</span>
|
||||
<span class="fd">Consistent checklists across every site.</span></span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="fi"><i class="bi bi-search"></i></span>
|
||||
<span><span class="ft d-block">Real-time issue tracking</span>
|
||||
<span class="fd">Log, assign, and resolve quality issues fast.</span></span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="fi"><i class="bi bi-bar-chart-line"></i></span>
|
||||
<span><span class="ft d-block">Automated quality reporting</span>
|
||||
<span class="fd">Scores and trends, ready to share.</span></span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="stat-row">
|
||||
<div class="stat-chip g"><div class="v">98%</div><div class="l">Pass rate</div></div>
|
||||
<div class="stat-chip"> <div class="v">247</div><div class="l">Inspections</div></div>
|
||||
<div class="stat-chip a"><div class="v">4.8</div><div class="l">Avg score</div></div>
|
||||
</div>
|
||||
|
||||
{# ── Decorative inspection / janitorial illustration ── #}
|
||||
<svg class="brand-illustration" viewBox="0 0 360 340" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<!-- clipboard card -->
|
||||
<rect x="48" y="36" width="210" height="252" rx="16" fill="#ffffff" opacity="0.97"/>
|
||||
<rect x="48" y="36" width="210" height="52" rx="16" fill="#1a56db"/>
|
||||
<rect x="48" y="64" width="210" height="24" fill="#1a56db"/>
|
||||
<rect x="118" y="26" width="70" height="24" rx="10" fill="#dceeff" stroke="#93c5fd" stroke-width="2"/>
|
||||
<text x="153" y="70" font-family="Arial,sans-serif" font-size="17" font-weight="700"
|
||||
fill="#ffffff" text-anchor="middle">Inspection</text>
|
||||
<!-- row 1 (pass) -->
|
||||
<circle cx="78" cy="124" r="11" fill="#dcfce7" stroke="#86efac" stroke-width="2"/>
|
||||
<polyline points="73,124 78,130 85,118" fill="none" stroke="#16a34a" stroke-width="3"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<line x1="98" y1="124" x2="236" y2="124" stroke="#cfe0f5" stroke-width="5" stroke-linecap="round"/>
|
||||
<!-- row 2 (pass) -->
|
||||
<circle cx="78" cy="160" r="11" fill="#dcfce7" stroke="#86efac" stroke-width="2"/>
|
||||
<polyline points="73,160 78,166 85,154" fill="none" stroke="#16a34a" stroke-width="3"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<line x1="98" y1="160" x2="236" y2="160" stroke="#cfe0f5" stroke-width="5" stroke-linecap="round"/>
|
||||
<!-- row 3 (flag / amber) -->
|
||||
<circle cx="78" cy="196" r="11" fill="#fef9c3" stroke="#fde047" stroke-width="2"/>
|
||||
<line x1="78" y1="190" x2="78" y2="197" stroke="#d97706" stroke-width="3" stroke-linecap="round"/>
|
||||
<circle cx="78" cy="201" r="1.6" fill="#d97706"/>
|
||||
<line x1="98" y1="196" x2="206" y2="196" stroke="#cfe0f5" stroke-width="5" stroke-linecap="round"/>
|
||||
<!-- row 4 -->
|
||||
<circle cx="78" cy="232" r="11" fill="#eef4fc" stroke="#c2d9f8" stroke-width="2"/>
|
||||
<line x1="98" y1="232" x2="236" y2="232" stroke="#e4edf9" stroke-width="5" stroke-linecap="round"/>
|
||||
<!-- shield-check badge -->
|
||||
<g transform="translate(214,210)">
|
||||
<path d="M30 0 L58 10 L58 38 Q58 62 30 74 Q2 62 2 38 L2 10 Z"
|
||||
fill="#16a34a"/>
|
||||
<polyline points="16,38 26,49 46,25" fill="none" stroke="#ffffff" stroke-width="5"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<!-- magnifying glass -->
|
||||
<circle cx="118" cy="206" r="56" fill="none" stroke="#1a56db" stroke-width="0" />
|
||||
<circle cx="118" cy="206" r="46" fill="#dbeafe" fill-opacity="0.55" stroke="#1a56db" stroke-width="7"/>
|
||||
<line x1="151" y1="239" x2="182" y2="270" stroke="#1a56db" stroke-width="11" stroke-linecap="round"/>
|
||||
<text x="118" y="214" font-family="Arial,sans-serif" font-size="26" font-weight="800"
|
||||
fill="#1a56db" text-anchor="middle">QC</text>
|
||||
</svg>
|
||||
</section>
|
||||
|
||||
{# ══ RIGHT: sign-in form ══ #}
|
||||
<section class="login-form-side">
|
||||
<div class="login-form-inner">
|
||||
|
||||
<div class="form-mobile-brand">
|
||||
<span class="logo-badge"><i class="bi bi-clipboard-check-fill"></i></span>
|
||||
<span class="name">Janitorial Quality Control System</span>
|
||||
<span class="sub">By L.T Services, Inc</span>
|
||||
</div>
|
||||
|
||||
<h2>Welcome</h2>
|
||||
<p class="lead-sub">Sign in to access your inspections and reports.</p>
|
||||
|
||||
<form method="POST" action="{{ url_for('auth.login') }}">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.username.label(class="form-label fw-semibold") }}
|
||||
<div class="input-group input-group-lg">
|
||||
<span class="input-group-text"><i class="bi bi-person"></i></span>
|
||||
{{ form.username(class="form-control", placeholder="Enter username", autofocus=true) }}
|
||||
</div>
|
||||
{% if form.username.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for error in form.username.errors %}{{ error }}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="d-flex justify-content-between align-items-baseline">
|
||||
{{ form.password.label(class="form-label fw-semibold mb-0") }}
|
||||
<a href="{{ url_for('auth.forgot_password') }}"
|
||||
class="small text-muted text-decoration-none">Forgot password?</a>
|
||||
</div>
|
||||
<div class="input-group input-group-lg mt-1">
|
||||
<span class="input-group-text"><i class="bi bi-lock"></i></span>
|
||||
{{ form.password(class="form-control", placeholder="Enter password") }}
|
||||
<button class="btn pw-toggle" type="button" id="pw-toggle" tabindex="-1"
|
||||
aria-label="Show password">
|
||||
<i class="bi bi-eye" id="pw-toggle-icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% if form.password.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for error in form.password.errors %}{{ error }}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-4 form-check">
|
||||
{{ form.remember_me(class="form-check-input") }}
|
||||
{{ form.remember_me.label(class="form-check-label text-muted") }}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-login btn-lg w-100">
|
||||
<i class="bi bi-box-arrow-in-right me-1"></i> Log In
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="login-foot">
|
||||
© <span id="copy-year"></span> Janitorial QC System
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('copy-year').textContent = new Date().getFullYear();
|
||||
|
||||
// Password visibility toggle
|
||||
(function () {
|
||||
var btn = document.getElementById('pw-toggle');
|
||||
var icon = document.getElementById('pw-toggle-icon');
|
||||
var input = document.getElementById('password');
|
||||
if (btn && input) {
|
||||
btn.addEventListener('click', function () {
|
||||
var show = input.type === 'password';
|
||||
input.type = show ? 'text' : 'password';
|
||||
icon.classList.toggle('bi-eye', !show);
|
||||
icon.classList.toggle('bi-eye-slash', show);
|
||||
btn.setAttribute('aria-label', show ? 'Hide password' : 'Show password');
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
// Remove .login-page when navigating away (SPA-style guard)
|
||||
window.addEventListener('pagehide', function () {
|
||||
document.body.classList.remove('login-page');
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,218 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Notification Matrix{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.matrix-wrap { max-width: 1100px; margin: 0 auto; }
|
||||
|
||||
/* ── Matrix table ── */
|
||||
.matrix-tbl { border-collapse: collapse; width: 100%; font-size: .82rem; }
|
||||
.matrix-tbl th, .matrix-tbl td {
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: .35rem .55rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.matrix-tbl thead th {
|
||||
background: #1a1d23; color: #fff; font-weight: 600;
|
||||
text-align: center; white-space: nowrap;
|
||||
}
|
||||
.matrix-tbl thead th.event-col { text-align: left; }
|
||||
.matrix-tbl thead th.group-hdr {
|
||||
background: #2563eb; font-size: .75rem; letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.matrix-tbl tbody tr:nth-child(even) td { background: #f8fafc; }
|
||||
.matrix-tbl tbody tr:hover td { background: #eff6ff; }
|
||||
.matrix-tbl td.event-name { font-weight: 500; color: #1a1d23; white-space: nowrap; }
|
||||
.matrix-tbl td.check-cell { text-align: center; }
|
||||
|
||||
/* Checkbox styling */
|
||||
.matrix-check {
|
||||
width: 1.1rem; height: 1.1rem; cursor: pointer;
|
||||
accent-color: #2563eb;
|
||||
}
|
||||
/* Disabled row (event has no meaningful broadcast) */
|
||||
.matrix-tbl tr.implicit td { color: #94a3b8; }
|
||||
.matrix-tbl tr.implicit td.event-name { color: #64748b; font-style: italic; }
|
||||
|
||||
/* Custom emails cell */
|
||||
.custom-cell { min-width: 180px; }
|
||||
.custom-input {
|
||||
width: 100%; font-size: .75rem;
|
||||
border: 1px solid #e2e8f0; border-radius: 4px;
|
||||
padding: .2rem .4rem; color: #374151;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.custom-input:focus {
|
||||
outline: none; border-color: #2563eb;
|
||||
box-shadow: 0 0 0 2px rgba(37,99,235,.15);
|
||||
}
|
||||
|
||||
/* Legend */
|
||||
.legend { font-size: .78rem; color: #64748b; }
|
||||
.legend span { display: inline-flex; align-items: center; gap: .3rem; margin-right: 1rem; }
|
||||
|
||||
/* Implicit badge */
|
||||
.badge-implicit {
|
||||
font-size: .62rem; background: #f1f5f9; color: #64748b;
|
||||
border: 1px solid #e2e8f0; border-radius: 4px;
|
||||
padding: 1px 5px; vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="matrix-wrap mt-3">
|
||||
|
||||
{# ── Page header ── #}
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<h4 class="mb-0"><i class="bi bi-grid-3x3-gap-fill text-primary me-2"></i>Notification Matrix</h4>
|
||||
<p class="text-muted mb-0 mt-1" style="font-size:.85rem;">
|
||||
Control which roles receive email notifications for each event.
|
||||
<strong>Assignee</strong>, <strong>followers</strong>, and <strong>customer portal</strong>
|
||||
recipients are handled automatically where marked.
|
||||
</p>
|
||||
</div>
|
||||
<a href="{{ url_for('auth.list_users') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left"></i> Back to Users
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# ── Legend ── #}
|
||||
<div class="legend mb-3 d-flex flex-wrap align-items-center">
|
||||
<span><input type="checkbox" checked disabled class="matrix-check"> Enabled by default</span>
|
||||
<span><input type="checkbox" disabled class="matrix-check"> Disabled by default</span>
|
||||
<span><span class="badge-implicit">implicit</span> Always notified — not controlled here</span>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('auth.notification_matrix') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="table-responsive shadow-sm rounded">
|
||||
<table class="matrix-tbl">
|
||||
<thead>
|
||||
{# Row 1: spanning group headers #}
|
||||
<tr>
|
||||
<th class="event-col" rowspan="2" style="min-width:200px;">Event</th>
|
||||
<th class="group-hdr" colspan="4">Internal Recipients</th>
|
||||
<th class="group-hdr" colspan="1">Customer</th>
|
||||
<th class="group-hdr" colspan="1">Custom Recipients</th>
|
||||
</tr>
|
||||
{# Row 2: individual role headers #}
|
||||
<tr>
|
||||
{% for role_key, role_label in matrix_roles if role_key != 'custom' and role_key != 'customer' %}
|
||||
<th style="min-width:80px;">{{ role_label }}</th>
|
||||
{% endfor %}
|
||||
<th style="min-width:80px;">Customer</th>
|
||||
<th style="min-width:200px;">Email addresses<br><span style="font-weight:400;font-size:.7rem;color:#94a3b8;">(comma-separated)</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for event_key, event_label in matrix_events.items() %}
|
||||
{% set event_state = state.get(event_key, {}) %}
|
||||
<tr>
|
||||
<td class="event-name">
|
||||
{{ event_label }}
|
||||
{# Show implicit labels where assignee/followers are always notified #}
|
||||
{% if event_key in ('issue_assigned', 'issue_reassigned', 'issue_unassigned',
|
||||
'issue_status', 'issue_comment') %}
|
||||
<span class="badge-implicit ms-1">assignee</span>
|
||||
{% endif %}
|
||||
{% if event_key == 'issue_follow_update' %}
|
||||
<span class="badge-implicit ms-1">followers</span>
|
||||
{% endif %}
|
||||
{% if event_key == 'sla_alert' %}
|
||||
<span class="badge-implicit ms-1">assignee</span>
|
||||
<span class="badge-implicit ms-1">followers</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
{# Internal role checkboxes (admin, supervisor, inspector, project_manager) #}
|
||||
{% for role_key, _ in matrix_roles if role_key not in ('custom', 'customer') %}
|
||||
{% set row = event_state.get(role_key) %}
|
||||
{% if row is not none %}
|
||||
{% set checked = row.enabled %}
|
||||
{% else %}
|
||||
{% set checked = defaults.get((event_key, role_key), false) %}
|
||||
{% endif %}
|
||||
<td class="check-cell">
|
||||
<input type="checkbox"
|
||||
class="matrix-check"
|
||||
name="matrix_{{ event_key }}_{{ role_key }}"
|
||||
value="1"
|
||||
{% if checked %}checked{% endif %}>
|
||||
</td>
|
||||
{% endfor %}
|
||||
|
||||
{# Customer checkbox #}
|
||||
{% set cust_row = event_state.get('customer') %}
|
||||
{% if cust_row is not none %}
|
||||
{% set cust_checked = cust_row.enabled %}
|
||||
{% else %}
|
||||
{% set cust_checked = defaults.get((event_key, 'customer'), false) %}
|
||||
{% endif %}
|
||||
<td class="check-cell">
|
||||
<input type="checkbox"
|
||||
class="matrix-check"
|
||||
name="matrix_{{ event_key }}_customer"
|
||||
value="1"
|
||||
{% if cust_checked %}checked{% endif %}>
|
||||
</td>
|
||||
|
||||
{# Custom emails text input #}
|
||||
{% set custom_row = event_state.get('custom') %}
|
||||
{% if custom_row is not none %}
|
||||
{% set custom_val = custom_row.get_custom_emails() | join(', ') %}
|
||||
{% else %}
|
||||
{% set custom_val = '' %}
|
||||
{% endif %}
|
||||
<td class="custom-cell">
|
||||
<input type="text"
|
||||
class="custom-input"
|
||||
name="custom_{{ event_key }}"
|
||||
value="{{ custom_val }}"
|
||||
placeholder="e.g. ops@company.com, mgr@co.com">
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 d-flex gap-2 align-items-center">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check2-circle me-1"></i>Save Matrix
|
||||
</button>
|
||||
<a href="{{ url_for('auth.notification_matrix') }}" class="btn btn-outline-secondary">
|
||||
Reset
|
||||
</a>
|
||||
<span class="text-muted ms-2" style="font-size:.8rem;">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Changes take effect immediately for all subsequent notifications.
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# ── Notes card ── #}
|
||||
<div class="card mt-4 border-0 bg-light">
|
||||
<div class="card-body py-2 px-3">
|
||||
<p class="mb-1" style="font-size:.8rem;">
|
||||
<strong>Internal:</strong> Admin, Supervisor, Inspector, and Project Manager users
|
||||
receive in-app notifications and emails based on their individual
|
||||
<a href="{{ url_for('notifications.preferences') }}">preference settings</a>.
|
||||
</p>
|
||||
<p class="mb-1" style="font-size:.8rem;">
|
||||
<strong>Customer:</strong> Customer-portal users are notified only for facilities
|
||||
they are assigned to via their contract/facility assignments.
|
||||
</p>
|
||||
<p class="mb-0" style="font-size:.8rem;">
|
||||
<strong>Custom Recipients:</strong> Additional email addresses (e.g. external managers)
|
||||
receive a plain email. They do not get in-app notifications and are not affected
|
||||
by individual user preference settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,224 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}My Profile{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2><i class="bi bi-person-circle"></i> My Profile</h2>
|
||||
<p class="text-muted mb-0">Manage your account information and review your activity.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
|
||||
<!-- ── Left column: account card + stats ─────────────────────────── -->
|
||||
<div class="col-lg-4">
|
||||
|
||||
<!-- Account Info Card -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body text-center py-4">
|
||||
<div class="mb-3">
|
||||
<span class="display-1 text-primary">
|
||||
<i class="bi bi-person-circle"></i>
|
||||
</span>
|
||||
</div>
|
||||
<h4 class="mb-1 fw-bold">{{ current_user.display_name }}</h4>
|
||||
{% if current_user.full_name %}
|
||||
<p class="text-muted mb-1" style="font-size:.85rem;">@{{ current_user.username }}</p>
|
||||
{% endif %}
|
||||
<p class="text-muted mb-2">{{ current_user.email }}</p>
|
||||
<span class="badge fs-6 bg-{% if current_user.role == 'admin' %}danger{% elif current_user.role == 'director' %}warning{% else %}info{% endif %}">
|
||||
<i class="bi bi-{% if current_user.role == 'admin' %}shield-fill{% elif current_user.role == 'director' %}star-fill{% else %}person-badge-fill{% endif %} me-1"></i>
|
||||
{{ current_user.role | title }}
|
||||
</span>
|
||||
<hr>
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-calendar3 me-1"></i>
|
||||
Member since {{ current_user.created_at.strftime('%B %d, %Y') }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Activity Stats Card -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light">
|
||||
<h6 class="mb-0 fw-semibold"><i class="bi bi-bar-chart-fill me-1"></i>Activity Summary</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row text-center g-3">
|
||||
<div class="col-6">
|
||||
<div class="p-2 rounded bg-primary bg-opacity-10">
|
||||
<div class="fs-3 fw-bold text-primary">{{ total_inspections }}</div>
|
||||
<small class="text-muted">Total Inspections</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="p-2 rounded bg-success bg-opacity-10">
|
||||
<div class="fs-3 fw-bold text-success">{{ completed_inspections }}</div>
|
||||
<small class="text-muted">Completed</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="p-2 rounded bg-warning bg-opacity-10">
|
||||
<div class="fs-3 fw-bold text-warning">{{ open_issues }}</div>
|
||||
<small class="text-muted">Open Issues</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="p-2 rounded bg-info bg-opacity-10">
|
||||
{% if total_inspections > 0 %}
|
||||
<div class="fs-3 fw-bold text-info">
|
||||
{{ ((completed_inspections / total_inspections) * 100) | int }}%
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="fs-3 fw-bold text-info">—</div>
|
||||
{% endif %}
|
||||
<small class="text-muted">Completion Rate</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── Right column: edit form + recent inspections ──────────────── -->
|
||||
<div class="col-lg-8">
|
||||
|
||||
<!-- Edit Profile Form -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-light">
|
||||
<h6 class="mb-0 fw-semibold"><i class="bi bi-pencil-square me-1"></i>Edit Profile</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('auth.profile') }}">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<!-- Full Name -->
|
||||
<div class="mb-3">
|
||||
{{ form.full_name.label(class="form-label fw-semibold") }}
|
||||
{{ form.full_name(class="form-control", placeholder="e.g. Jane Smith") }}
|
||||
{% for error in form.full_name.errors %}
|
||||
<div class="invalid-feedback d-block">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div class="mb-3">
|
||||
{{ form.email.label(class="form-label fw-semibold") }}
|
||||
{{ form.email(class="form-control" + (" is-invalid" if form.email.errors else ""),
|
||||
placeholder="your@email.com") }}
|
||||
{% for error in form.email.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<hr class="my-3">
|
||||
<p class="text-muted small mb-3">
|
||||
<i class="bi bi-lock me-1"></i>Leave the password fields blank to keep your current password.
|
||||
</p>
|
||||
|
||||
<!-- Current Password -->
|
||||
<div class="mb-3">
|
||||
{{ form.current_password.label(class="form-label fw-semibold") }}
|
||||
{{ form.current_password(class="form-control" + (" is-invalid" if form.current_password.errors else ""),
|
||||
autocomplete="current-password") }}
|
||||
{% for error in form.current_password.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- New Password -->
|
||||
<div class="mb-3">
|
||||
{{ form.new_password.label(class="form-label fw-semibold") }}
|
||||
{{ form.new_password(class="form-control" + (" is-invalid" if form.new_password.errors else ""),
|
||||
autocomplete="new-password") }}
|
||||
{% for error in form.new_password.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
<div class="form-text">Minimum 6 characters.</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirm New Password -->
|
||||
<div class="mb-4">
|
||||
{{ form.confirm_password.label(class="form-label fw-semibold") }}
|
||||
{{ form.confirm_password(class="form-control" + (" is-invalid" if form.confirm_password.errors else ""),
|
||||
autocomplete="new-password") }}
|
||||
{% for error in form.confirm_password.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-circle me-1"></i>Save Changes
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Inspections -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0 fw-semibold"><i class="bi bi-clipboard-check me-1"></i>Recent Inspections</h6>
|
||||
<a href="{{ url_for('inspections.index') }}" class="btn btn-sm btn-outline-primary">
|
||||
View All
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if recent_inspections %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Facility</th>
|
||||
<th>Template</th>
|
||||
<th>Score</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for insp in recent_inspections %}
|
||||
<tr>
|
||||
<td>{{ insp.inspection_date.strftime('%Y-%m-%d') }}</td>
|
||||
<td>{{ insp.facility.name if insp.facility else '—' }}</td>
|
||||
<td>{{ insp.template.name if insp.template else '—' }}</td>
|
||||
<td>
|
||||
{% if insp.overall_score is not none %}
|
||||
<span class="badge bg-{% if insp.overall_score >= 80 %}success{% elif insp.overall_score >= 60 %}warning{% else %}danger{% endif %}">
|
||||
{{ "%.1f"|format(insp.overall_score) }}%
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-{% if insp.status == 'completed' %}success{% elif insp.status == 'in_progress' %}primary{% else %}warning{% endif %}">
|
||||
{{ insp.status | replace('_', ' ') | title }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('inspections.view', inspection_id=insp.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4 text-muted">
|
||||
<i class="bi bi-clipboard-x fs-3 d-block mb-2"></i>
|
||||
No inspections recorded yet.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,151 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Reset Password — Janitorial QC</title>
|
||||
<link rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||
<style>
|
||||
body { background: #eef0f4; font-family: 'Segoe UI', Arial, sans-serif; }
|
||||
.setup-card {
|
||||
max-width: 460px; margin: 80px auto;
|
||||
border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,.1);
|
||||
}
|
||||
.setup-header {
|
||||
background: #1a1d23; color: #fff;
|
||||
border-radius: 12px 12px 0 0;
|
||||
padding: 1.5rem 1.75rem 1.25rem;
|
||||
}
|
||||
.setup-header h4 { margin: 0; font-weight: 600; }
|
||||
.setup-header p { color: #94a3b8; font-size: .85rem; margin: .35rem 0 0; }
|
||||
.setup-body { background: #fff; border-radius: 0 0 12px 12px; padding: 1.75rem; }
|
||||
.req-item { font-size: .8rem; color: #64748b; }
|
||||
.req-item.met { color: #16a34a; }
|
||||
.strength-bar { height: 4px; border-radius: 2px; transition: all .3s; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="setup-card">
|
||||
<div class="setup-header">
|
||||
<h4><i class="bi bi-shield-lock me-2"></i>Set a New Password</h4>
|
||||
<p>Hi {{ user.display_name }}. Choose a new secure password for your account.</p>
|
||||
</div>
|
||||
<div class="setup-body">
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ 'danger' if cat == 'danger' else 'warning' if cat == 'warning' else 'success' }}
|
||||
alert-dismissible fade show py-2 mb-3" role="alert">
|
||||
{{ msg }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label fw-semibold">New Password</label>
|
||||
<input type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
class="form-control {{ 'is-invalid' if form.password.errors else '' }}"
|
||||
autocomplete="new-password"
|
||||
autofocus>
|
||||
{% for error in form.password.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="mt-2 mb-1">
|
||||
<div class="bg-light rounded" style="height:4px;">
|
||||
<div id="strengthBar" class="strength-bar bg-secondary" style="width:0%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-3 mt-2">
|
||||
<span class="req-item" id="req-len">
|
||||
<i class="bi bi-circle me-1"></i>8+ characters
|
||||
</span>
|
||||
<span class="req-item" id="req-upper">
|
||||
<i class="bi bi-circle me-1"></i>Uppercase letter
|
||||
</span>
|
||||
<span class="req-item" id="req-num">
|
||||
<i class="bi bi-circle me-1"></i>Number
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="confirm_password" class="form-label fw-semibold">Confirm Password</label>
|
||||
<input type="password"
|
||||
id="confirm_password"
|
||||
name="confirm_password"
|
||||
class="form-control {{ 'is-invalid' if form.confirm_password.errors else '' }}"
|
||||
autocomplete="new-password">
|
||||
{% for error in form.confirm_password.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
<div id="matchFeedback" class="form-text" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100" id="submitBtn">
|
||||
<i class="bi bi-check2-circle me-1"></i>Reset Password & Log In
|
||||
</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var pwEl = document.getElementById('password');
|
||||
var cfEl = document.getElementById('confirm_password');
|
||||
var bar = document.getElementById('strengthBar');
|
||||
var reqLen = document.getElementById('req-len');
|
||||
var reqUpper = document.getElementById('req-upper');
|
||||
var reqNum = document.getElementById('req-num');
|
||||
var matchFb = document.getElementById('matchFeedback');
|
||||
|
||||
function markReq(el, met) {
|
||||
el.className = 'req-item' + (met ? ' met' : '');
|
||||
el.querySelector('i').className = (met ? 'bi bi-check-circle-fill' : 'bi bi-circle') + ' me-1';
|
||||
}
|
||||
|
||||
function updateStrength(pw) {
|
||||
var hasLen = pw.length >= 8;
|
||||
var hasUpper = /[A-Z]/.test(pw);
|
||||
var hasNum = /[0-9]/.test(pw);
|
||||
var score = [hasLen, hasUpper, hasNum, pw.length >= 12].filter(Boolean).length;
|
||||
markReq(reqLen, hasLen);
|
||||
markReq(reqUpper, hasUpper);
|
||||
markReq(reqNum, hasNum);
|
||||
var color = score <= 1 ? 'danger' : score === 2 ? 'warning' : score === 3 ? 'info' : 'success';
|
||||
bar.style.width = (score * 25) + '%';
|
||||
bar.className = 'strength-bar bg-' + color;
|
||||
}
|
||||
|
||||
function checkMatch() {
|
||||
if (!cfEl.value) { matchFb.style.display = 'none'; return; }
|
||||
matchFb.style.display = '';
|
||||
if (pwEl.value === cfEl.value) {
|
||||
matchFb.textContent = '✓ Passwords match';
|
||||
matchFb.style.color = '#16a34a';
|
||||
} else {
|
||||
matchFb.textContent = '✗ Passwords do not match';
|
||||
matchFb.style.color = '#dc2626';
|
||||
}
|
||||
}
|
||||
|
||||
pwEl.addEventListener('input', function () { updateStrength(this.value); checkMatch(); });
|
||||
cfEl.addEventListener('input', checkMatch);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,130 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
|
||||
{# ── Account Status card (edit mode only, not own account) ── #}
|
||||
{% if user and user.id != current_user.id %}
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<span class="fw-semibold me-2">Account Status:</span>
|
||||
<span class="badge fs-6 bg-{{ 'success' if user.active else 'secondary' }}">
|
||||
{{ 'Active' if user.active else 'Disabled' }}
|
||||
</span>
|
||||
<div class="form-text mt-1">
|
||||
{% if user.active %}
|
||||
Disabling this account will immediately prevent the user from logging in.
|
||||
{% else %}
|
||||
This account is currently disabled — the user cannot log in.
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('auth.toggle_active', user_id=user.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit"
|
||||
class="btn btn-sm {{ 'btn-outline-secondary' if user.active else 'btn-outline-success' }}"
|
||||
onclick="return confirm('{{ 'Disable' if user.active else 'Enable' }} user {{ user.username }}?')">
|
||||
<i class="bi bi-{{ 'person-slash' if user.active else 'person-check' }} me-1"></i>
|
||||
{{ 'Disable Account' if user.active else 'Enable Account' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Edit form ── #}
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0">{{ title }}</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.username.label(class="form-label") }}
|
||||
{{ form.username(class="form-control") }}
|
||||
{% if form.username.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for error in form.username.errors %}{{ error }}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.full_name.label(class="form-label") }}
|
||||
{{ form.full_name(class="form-control", placeholder="e.g. Jane Smith") }}
|
||||
{% if form.full_name.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for error in form.full_name.errors %}{{ error }}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.email.label(class="form-label") }}
|
||||
{{ form.email(class="form-control") }}
|
||||
{% if form.email.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for error in form.email.errors %}{{ error }}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.password.label(class="form-label") }}
|
||||
{{ form.password(class="form-control", placeholder="Leave blank to keep current" if user else "") }}
|
||||
{% if form.password.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for error in form.password.errors %}{{ error }}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.confirm_password.label(class="form-label") }}
|
||||
{{ form.confirm_password(class="form-control") }}
|
||||
{% if form.confirm_password.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for error in form.confirm_password.errors %}{{ error }}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
{{ form.role.label(class="form-label") }}
|
||||
{% if director_editing %}
|
||||
{# Directors can see the current role but cannot change it #}
|
||||
<div class="form-control bg-light text-muted" style="cursor: not-allowed;">
|
||||
{{ (user.role if user else 'Inspector').replace('_', ' ')|title }}
|
||||
</div>
|
||||
<div class="form-text">Role assignment requires Administrator access.</div>
|
||||
{% else %}
|
||||
{{ form.role(class="form-select") }}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-save"></i> Save User
|
||||
</button>
|
||||
<a href="{{ url_for('auth.list_users') }}" class="btn btn-secondary">
|
||||
<i class="bi bi-x-circle"></i> Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,99 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}User Management{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<h2><i class="bi bi-people-fill"></i> Internal User Management</h2>
|
||||
</div>
|
||||
<div class="col-md-6 text-end">
|
||||
<a href="{{ url_for('auth.create_user') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Add User
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Full Name</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Contracts</th>
|
||||
<th>Created</th>
|
||||
<th>Status</th>
|
||||
<th width="220">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr class="{{ 'table-secondary text-muted' if not user.active else '' }}">
|
||||
<td><strong>{{ user.username }}</strong></td>
|
||||
<td>{{ user.full_name or '—' }}</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td>
|
||||
<span class="badge bg-{% if user.role == 'admin' %}danger{% elif user.role == 'director' %}warning{% elif user.role == 'project_manager' %}primary{% elif user.role == 'customer' %}success{% else %}info{% endif %}">
|
||||
{{ user.role.replace('_',' ')|title }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if user.role == 'inspector' %}
|
||||
{% set cnt = inspector_contract_counts.get(user.id, 0) %}
|
||||
{% if cnt > 0 %}
|
||||
<span class="badge bg-success">{{ cnt }} contract{{ 's' if cnt != 1 else '' }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark" title="No contracts assigned — inspector sees nothing">None</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted small">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ user.created_at.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
{% if user.active %}
|
||||
<span class="badge bg-success">Active</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Disabled</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('auth.edit_user', user_id=user.id) }}" class="btn btn-sm btn-outline-primary" title="Edit">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
{% if user.role == 'inspector' %}
|
||||
<a href="{{ url_for('auth.assign_inspector_contracts', user_id=user.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary" title="Assign contracts">
|
||||
<i class="bi bi-briefcase"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if user.id != current_user.id %}
|
||||
<form method="POST" action="{{ url_for('auth.toggle_active', user_id=user.id) }}" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit"
|
||||
class="btn btn-sm {{ 'btn-outline-secondary' if user.active else 'btn-outline-success' }}"
|
||||
title="{{ 'Disable' if user.active else 'Enable' }}"
|
||||
onclick="return confirm('{{ 'Disable' if user.active else 'Enable' }} user {{ user.username }}?')">
|
||||
<i class="bi bi-{{ 'person-slash' if user.active else 'person-check' }}"></i>
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('auth.delete_user', user_id=user.id) }}" class="d-inline" onsubmit="return confirm('Delete this user?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,463 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<!-- iOS / iPadOS web app meta tags -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<title>{% block title %}Janitorial QC System{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/ipad_responsive.css') }}">
|
||||
{% block extra_css %}{% endblock %}
|
||||
<style>
|
||||
/* ── Notification bell styles ── */
|
||||
.notif-bell-wrapper { position: relative; }
|
||||
.notif-badge {
|
||||
position: absolute;
|
||||
top: 2px; right: 2px;
|
||||
font-size: 0.6rem;
|
||||
min-width: 16px; height: 16px; line-height: 16px;
|
||||
padding: 0 4px; border-radius: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.notif-dropdown {
|
||||
width: 380px;
|
||||
max-height: 520px;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
.notif-item {
|
||||
border-left: 3px solid transparent;
|
||||
transition: background 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.notif-item.unread {
|
||||
border-left-color: #0d6efd;
|
||||
background-color: #f0f6ff;
|
||||
}
|
||||
.notif-item:hover { background-color: #e8f0fe; }
|
||||
.notif-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 2px; }
|
||||
.notif-body { font-size: 0.78rem; color: #555; white-space: normal; }
|
||||
.notif-time { font-size: 0.7rem; color: #999; }
|
||||
.notif-empty { padding: 24px; text-align: center; color: #aaa; font-size: 0.85rem; }
|
||||
|
||||
/* ── Active nav tab ── */
|
||||
.navbar-dark .navbar-nav .nav-link.active {
|
||||
background-color: rgba(255, 255, 255, 0.18);
|
||||
color: #ffffff !important;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
box-shadow: inset 0 -2px 0 rgba(255,255,255,0.6);
|
||||
}
|
||||
.navbar-dark .navbar-nav .nav-link:not(.active):hover {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% if current_user.is_authenticated %}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ url_for('dashboard.index') }}">
|
||||
<i class="bi bi-clipboard-check"></i> Janitorial QC
|
||||
</a>
|
||||
<!-- ── Bell + toggler always visible on mobile/tablet ── -->
|
||||
<div class="d-flex align-items-center gap-2 ms-auto me-2 d-lg-none">
|
||||
<!-- Notification bell (always visible) -->
|
||||
<div class="dropdown">
|
||||
<a class="nav-link position-relative notif-bell-wrapper text-white"
|
||||
href="#"
|
||||
id="notifDropdownMobile"
|
||||
role="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
title="Notifications">
|
||||
<i class="bi bi-bell fs-5"></i>
|
||||
{% if unread_notification_count > 0 %}
|
||||
<span class="badge bg-danger notif-badge" id="notif-count-badge-mobile">
|
||||
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge-mobile"></span>
|
||||
{% endif %}
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow"
|
||||
id="notif-dropdown-menu-mobile">
|
||||
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom">
|
||||
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
|
||||
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none mark-all-read-btn"
|
||||
style="font-size:.75rem;">Mark all as read</button>
|
||||
</div>
|
||||
<div class="notif-list-mobile">
|
||||
<div class="notif-empty">Loading…</div>
|
||||
</div>
|
||||
<div class="border-top d-flex justify-content-between px-3 py-2" style="font-size:.8rem;">
|
||||
<a href="{{ url_for('notifications.index') }}" class="text-decoration-none">
|
||||
<i class="bi bi-list-ul me-1"></i>View all
|
||||
</a>
|
||||
<a href="{{ url_for('notifications.preferences') }}" class="text-decoration-none text-muted">
|
||||
<i class="bi bi-gear me-1"></i>Preferences
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('dashboard.') }}" href="{{ url_for('dashboard.index') }}">Dashboard</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('reports.') or request.endpoint.startswith('scheduled_reports.')) }}" href="{{ url_for('reports.index') }}">Reports</a>
|
||||
</li>
|
||||
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('projects.') }}" href="{{ url_for('projects.index') }}">Contracts</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('facilities.') }}" href="{{ url_for('facilities.list_facilities') }}">Facilities</a>
|
||||
</li>
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('templates.') }}" href="{{ url_for('templates.index') }}">Templates</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspections.') }}" href="{{ url_for('inspections.index') }}">Inspections</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('issues.') and request.endpoint != 'issues.verification_queue' }}" href="{{ url_for('issues.index') }}">Issues</a>
|
||||
</li>
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link d-flex align-items-center gap-1 {{ 'active' if request.endpoint == 'issues.verification_queue' }}"
|
||||
href="{{ url_for('issues.verification_queue') }}">
|
||||
Verify
|
||||
{% if pending_verification_count and pending_verification_count > 0 %}
|
||||
<span class="badge bg-info text-dark"
|
||||
style="font-size:.65rem;line-height:1;">
|
||||
{{ pending_verification_count }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role == 'admin' %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('auth.') and 'user' in request.endpoint }}" href="{{ url_for('auth.list_users') }}">Users</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('customers.') }}" href="{{ url_for('customers.index') }}">Customers</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
|
||||
href="{{ url_for('support.admin_tickets') }}">
|
||||
Support
|
||||
{% if open_support_tickets_count > 0 %}
|
||||
<span class="badge bg-danger ms-1">{{ open_support_tickets_count }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role == 'customer' %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
|
||||
href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="bi bi-chat-dots me-1"></i>Support
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('support.chat') }}">
|
||||
<i class="bi bi-chat-dots me-2"></i>Ask a Question
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('support.my_tickets') }}">
|
||||
<i class="bi bi-inbox me-2"></i>My Requests
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role == 'admin' %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('audit.') }}" href="{{ url_for('audit.index') }}">Audit Trail</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'auth.notification_matrix' }}" href="{{ url_for('auth.notification_matrix') }}">
|
||||
<i class="bi bi-grid-3x3-gap-fill"></i> Notif. Matrix
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('broadcast.') }}"
|
||||
href="{{ url_for('broadcast.index') }}">
|
||||
<i class="bi bi-megaphone-fill"></i> Broadcast
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<ul class="navbar-nav align-items-center">
|
||||
|
||||
<!-- ── Notification Bell (desktop lg+ only) ── -->
|
||||
<li class="nav-item dropdown me-2 d-none d-lg-block">
|
||||
<a class="nav-link position-relative notif-bell-wrapper"
|
||||
href="#"
|
||||
id="notifDropdown"
|
||||
role="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
title="Notifications">
|
||||
<i class="bi bi-bell fs-5"></i>
|
||||
{% if unread_notification_count > 0 %}
|
||||
<span class="badge bg-danger notif-badge" id="notif-count-badge">
|
||||
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge"></span>
|
||||
{% endif %}
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow"
|
||||
id="notif-dropdown-menu">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center
|
||||
px-3 py-2 border-bottom">
|
||||
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
|
||||
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none"
|
||||
id="mark-all-read-btn" style="font-size:.75rem;">
|
||||
Mark all as read
|
||||
</button>
|
||||
</div>
|
||||
<!-- Items -->
|
||||
<div id="notif-list">
|
||||
<div class="notif-empty">Loading…</div>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<div class="border-top d-flex justify-content-between px-3 py-2"
|
||||
style="font-size:.8rem;">
|
||||
<a href="{{ url_for('notifications.index') }}"
|
||||
class="text-decoration-none">
|
||||
<i class="bi bi-list-ul me-1"></i>View all
|
||||
</a>
|
||||
<a href="{{ url_for('notifications.preferences') }}"
|
||||
class="text-decoration-none text-muted">
|
||||
<i class="bi bi-gear me-1"></i>Preferences
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<!-- ── End Notification Bell ── -->
|
||||
|
||||
<!-- User menu -->
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown"
|
||||
role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-person-circle"></i> {{ current_user.username }}
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<a class="dropdown-item"
|
||||
href="{{ url_for('auth.profile') }}">
|
||||
<i class="bi bi-person-circle me-1"></i>My Profile
|
||||
</a>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item"
|
||||
href="{{ url_for('notifications.preferences') }}">
|
||||
<i class="bi bi-bell-slash me-1"></i>Notification Preferences
|
||||
</a>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('auth.logout') }}">
|
||||
<i class="bi bi-box-arrow-right me-1"></i>Logout
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
<div class="container-fluid mt-4">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const FEED_URL = '{{ url_for("notifications.feed") }}';
|
||||
const MARK_READ_BASE = '/notifications/';
|
||||
const MARK_ALL_URL = '{{ url_for("notifications.mark_all_read") }}';
|
||||
const CSRF_TOKEN = '{{ csrf_token() }}';
|
||||
const POLL_INTERVAL = 60000; // 60 seconds
|
||||
|
||||
// ── Element refs — desktop bell (lg+) and mobile/tablet bell (<lg) ──
|
||||
const badgeDesktop = document.getElementById('notif-count-badge');
|
||||
const badgeMobile = document.getElementById('notif-count-badge-mobile');
|
||||
const listDesktop = document.getElementById('notif-list');
|
||||
const listMobile = document.querySelector('.notif-list-mobile');
|
||||
|
||||
// ── Update both badge instances ────────────────────────────────────────
|
||||
function updateBadge(count) {
|
||||
[badgeDesktop, badgeMobile].forEach(function(badge) {
|
||||
if (!badge) return;
|
||||
if (count > 0) {
|
||||
badge.textContent = count > 99 ? '99+' : count;
|
||||
badge.classList.remove('d-none');
|
||||
} else {
|
||||
badge.textContent = '';
|
||||
badge.classList.add('d-none');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Render notification items into a given container ───────────────────
|
||||
function renderInto(container, notifications) {
|
||||
if (!container) return;
|
||||
if (!notifications.length) {
|
||||
container.innerHTML = '<div class="notif-empty">'
|
||||
+ '<i class="bi bi-check2-circle me-1"></i>You\'re all caught up!</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = notifications.map(function(n) {
|
||||
return '<div class="d-block text-decoration-none text-dark notif-item px-3 py-2 border-bottom '
|
||||
+ (n.is_read ? '' : 'unread') + '"'
|
||||
+ ' data-notif-id="' + n.id + '"'
|
||||
+ ' data-link="' + escapeAttr(n.link || '') + '">'
|
||||
+ '<div class="notif-title">' + escapeHtml(n.title) + '</div>'
|
||||
+ '<div class="notif-body">' + escapeHtml(n.body) + '</div>'
|
||||
+ '<div class="notif-time">' + escapeHtml(n.created_at) + '</div>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
container.querySelectorAll('.notif-item').forEach(function(el) {
|
||||
el.addEventListener('click', function() {
|
||||
var id = this.dataset.notifId;
|
||||
var link = this.dataset.link;
|
||||
markRead(id, function() {
|
||||
el.classList.remove('unread');
|
||||
if (link) window.location.href = link;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderNotifications(notifications) {
|
||||
renderInto(listDesktop, notifications);
|
||||
renderInto(listMobile, notifications);
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g,'&').replace(/</g,'<')
|
||||
.replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
function escapeAttr(str) { return escapeHtml(str); }
|
||||
|
||||
// ── Fetch + update ─────────────────────────────────────────────────────
|
||||
window.fetchNotifications = function fetchNotifications() {
|
||||
fetch(FEED_URL, { credentials: 'same-origin' })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
updateBadge(data.unread_count);
|
||||
window._jqcNotifications = data.notifications;
|
||||
var deskEl = document.getElementById('notifDropdown');
|
||||
var mobileEl = document.getElementById('notifDropdownMobile');
|
||||
var deskOpen = deskEl && deskEl.getAttribute('aria-expanded') === 'true';
|
||||
var mobileOpen = mobileEl && mobileEl.getAttribute('aria-expanded') === 'true';
|
||||
if (deskOpen || mobileOpen) {
|
||||
renderNotifications(data.notifications);
|
||||
}
|
||||
})
|
||||
.catch(function() {});
|
||||
};
|
||||
|
||||
function markRead(id, callback) {
|
||||
fetch(MARK_READ_BASE + id + '/mark-read', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': CSRF_TOKEN, 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function() { if (callback) callback(); fetchNotifications(); })
|
||||
.catch(function() { if (callback) callback(); });
|
||||
}
|
||||
|
||||
// ── Show dropdown → render cached data immediately ─────────────────────
|
||||
['notifDropdown', 'notifDropdownMobile'].forEach(function(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.addEventListener('show.bs.dropdown', function() {
|
||||
if (window._jqcNotifications) {
|
||||
renderNotifications(window._jqcNotifications);
|
||||
} else {
|
||||
fetchNotifications();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Mark all read — works from either bell ─────────────────────────────
|
||||
document.querySelectorAll('#mark-all-read-btn, .mark-all-read-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
fetch(MARK_ALL_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': CSRF_TOKEN,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function() {
|
||||
updateBadge(0);
|
||||
document.querySelectorAll('.notif-item.unread').forEach(function(el) {
|
||||
el.classList.remove('unread');
|
||||
});
|
||||
if (window._jqcNotifications) {
|
||||
window._jqcNotifications.forEach(function(n) { n.is_read = true; });
|
||||
}
|
||||
})
|
||||
.catch(function() {});
|
||||
});
|
||||
});
|
||||
|
||||
fetchNotifications();
|
||||
setInterval(fetchNotifications, POLL_INTERVAL);
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,122 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-7 offset-md-2">
|
||||
|
||||
{% if customer %}
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<span class="fw-semibold me-2">Account Status:</span>
|
||||
<span class="badge fs-6 bg-{{ 'success' if customer.active else 'secondary' }}">
|
||||
{{ 'Active' if customer.active else 'Disabled' }}
|
||||
</span>
|
||||
<div class="form-text mt-1">
|
||||
{% if customer.active %}
|
||||
Disabling prevents the customer from logging in immediately.
|
||||
{% else %}
|
||||
This account is currently disabled — the customer cannot log in.
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('customers.toggle_active', customer_id=customer.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit"
|
||||
class="btn btn-sm {{ 'btn-outline-warning' if customer.active else 'btn-outline-success' }}"
|
||||
onclick="return confirm('{{ 'Disable' if customer.active else 'Enable' }} {{ customer.username }}?')">
|
||||
<i class="bi bi-{{ 'person-slash' if customer.active else 'person-check' }} me-1"></i>
|
||||
{{ 'Disable Account' if customer.active else 'Enable Account' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0"><i class="bi bi-person-badge me-2"></i>{{ title }}</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.username.label(class="form-label") }}
|
||||
{{ form.username(class="form-control") }}
|
||||
{% if form.username.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for e in form.username.errors %}{{ e }}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.full_name.label(class="form-label") }}
|
||||
{{ form.full_name(class="form-control", placeholder="e.g. Jane Smith") }}
|
||||
{% if form.full_name.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for e in form.full_name.errors %}{{ e }}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.email.label(class="form-label") }}
|
||||
{{ form.email(class="form-control") }}
|
||||
{% if form.email.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for e in form.email.errors %}{{ e }}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.password.label(class="form-label") }}
|
||||
{{ form.password(class="form-control",
|
||||
placeholder="Leave blank to keep current" if customer else "Min. 8 characters") }}
|
||||
{% if form.password.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for e in form.password.errors %}{{ e }}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.confirm_password.label(class="form-label") }}
|
||||
{{ form.confirm_password(class="form-control") }}
|
||||
{% if form.confirm_password.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for e in form.confirm_password.errors %}{{ e }}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 mt-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-save me-1"></i>
|
||||
{{ 'Save Changes' if customer else 'Create Customer' }}
|
||||
</button>
|
||||
{% if customer %}
|
||||
<a href="{{ url_for('customers.manage', customer_id=customer.id) }}"
|
||||
class="btn btn-secondary">
|
||||
<i class="bi bi-x-circle me-1"></i> Cancel
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('customers.index') }}" class="btn btn-secondary">
|
||||
<i class="bi bi-x-circle me-1"></i> Cancel
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,160 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Bulk Customer Import{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2><i class="bi bi-upload text-primary me-2"></i>Bulk Customer Import</h2>
|
||||
<p class="text-muted mb-0">Create multiple customer accounts and assignments from a CSV file.</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('customers.import_template') }}" class="btn btn-sm btn-outline-success">
|
||||
<i class="bi bi-download me-1"></i>Download Template
|
||||
</a>
|
||||
<a href="{{ url_for('customers.index') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to Customers
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Format guide ── #}
|
||||
<div class="card border-0 bg-light mb-4">
|
||||
<div class="card-body py-3 px-4">
|
||||
<h6 class="fw-semibold mb-2"><i class="bi bi-info-circle me-1 text-primary"></i>CSV Format</h6>
|
||||
<div class="row g-3 small">
|
||||
<div class="col-md-4">
|
||||
<strong>Required columns</strong>
|
||||
<ul class="mb-0 mt-1 ps-3">
|
||||
<li><code>username</code> — unique login name</li>
|
||||
<li><code>email</code> — unique email address</li>
|
||||
<li><code>password</code> — min 8 characters</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<strong>Optional columns</strong>
|
||||
<ul class="mb-0 mt-1 ps-3">
|
||||
<li><code>project_name</code> — exact contract name</li>
|
||||
<li><code>facility_name</code> — exact facility name within contract (leave blank for all)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<strong>Tips</strong>
|
||||
<ul class="mb-0 mt-1 ps-3">
|
||||
<li>Repeat a username on multiple rows to assign them to multiple contracts</li>
|
||||
<li>Leave <code>facility_name</code> blank to grant access to all facilities in the contract</li>
|
||||
<li>Existing usernames/emails will be flagged as errors before anything is saved</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Upload form ── #}
|
||||
{% if not preview_rows %}
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-primary text-white fw-semibold">
|
||||
<i class="bi bi-file-earmark-arrow-up me-1"></i> Upload CSV File
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Select CSV File</label>
|
||||
<input type="file" name="csv_file" accept=".csv" class="form-control" required>
|
||||
<div class="form-text">Maximum recommended file size: 500 KB · UTF-8 encoding.</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-search me-1"></i> Parse & Preview
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
{# ── Preview results ── #}
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center
|
||||
bg-{{ 'danger' if has_errors else 'success' }} text-white">
|
||||
<span class="fw-semibold">
|
||||
<i class="bi bi-{{ 'x-circle' if has_errors else 'check-circle' }} me-1"></i>
|
||||
Preview — {{ preview_rows|length }} row(s) parsed
|
||||
</span>
|
||||
<span>
|
||||
<span class="badge bg-white text-success">{{ valid_count }} valid</span>
|
||||
{% set err_count = preview_rows|length - valid_count %}
|
||||
{% if err_count > 0 %}
|
||||
<span class="badge bg-white text-danger ms-1">{{ err_count }} error(s)</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0" style="font-size:.85rem;">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th width="50">Row</th>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Contract</th>
|
||||
<th>Facility Scope</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in preview_rows %}
|
||||
<tr class="{{ 'table-danger' if row.status == 'error' else '' }}">
|
||||
<td class="text-muted">{{ row.row }}</td>
|
||||
<td>{{ row.username or '—' }}</td>
|
||||
<td>{{ row.email or '—' }}</td>
|
||||
<td>{{ row.project }}</td>
|
||||
<td>{{ row.facility }}</td>
|
||||
<td>
|
||||
{% if row.status == 'ok' %}
|
||||
<span class="badge bg-success">Ready</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">Error</span>
|
||||
<ul class="mb-0 ps-3 text-danger" style="font-size:.78rem;">
|
||||
{% for e in row.errors %}<li>{{ e }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Action buttons ── #}
|
||||
{% if has_errors %}
|
||||
<div class="alert alert-danger">
|
||||
<i class="bi bi-exclamation-triangle-fill me-1"></i>
|
||||
<strong>Errors found.</strong> Fix the issues above and re-upload.
|
||||
Error rows will be skipped — only valid rows can be imported.
|
||||
{% if valid_count > 0 %}
|
||||
You may still import the {{ valid_count }} valid row(s) by clicking below.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="d-flex gap-3 align-items-center">
|
||||
{% if valid_count > 0 %}
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="confirmed" value="1">
|
||||
<input type="hidden" name="rows_json" value="{{ rows_json }}">
|
||||
<button type="submit" class="btn btn-success"
|
||||
onclick="return confirm('Create {{ valid_count }} customer account(s) and their assignments?')">
|
||||
<i class="bi bi-person-check me-1"></i>
|
||||
Import {{ valid_count }} Valid Row{{ 's' if valid_count != 1 else '' }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('customers.bulk_import') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-counterclockwise me-1"></i> Upload Different File
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,152 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Customer Management{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4 align-items-center">
|
||||
<div class="col">
|
||||
<h2><i class="bi bi-person-badge"></i> Customer Management</h2>
|
||||
<p class="text-muted mb-0">Manage portal access for all customer accounts.</p>
|
||||
</div>
|
||||
<div class="col-auto d-flex gap-2">
|
||||
<a href="{{ url_for('customers.bulk_import') }}" class="btn btn-outline-success">
|
||||
<i class="bi bi-upload"></i> Import CSV
|
||||
</a>
|
||||
<a href="{{ url_for('customers.create') }}" class="btn btn-primary">
|
||||
<i class="bi bi-person-plus"></i> New Customer
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if customers %}
|
||||
|
||||
{% if expired_invitations %}
|
||||
<div class="alert alert-warning d-flex align-items-start gap-3 mb-3" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill fs-5 mt-1 flex-shrink-0"></i>
|
||||
<div>
|
||||
<strong>{{ expired_invitations|length }} invitation{{ 's' if expired_invitations|length != 1 else '' }} expired</strong>
|
||||
— the following customer{{ 's' if expired_invitations|length != 1 else '' }} never completed account setup
|
||||
and {{ 'their' if expired_invitations|length != 1 else 'their' }} link has expired:
|
||||
<ul class="mb-2 mt-1">
|
||||
{% for c in expired_invitations %}
|
||||
<li>
|
||||
<strong>{{ c.display_name }}</strong> ({{ c.email }}) —
|
||||
expired {{ c.set_password_token_expires.strftime('%Y-%m-%d %H:%M') }}
|
||||
|
||||
<form method="POST" action="{{ url_for('customers.resend_invite', customer_id=c.id) }}"
|
||||
class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-warning py-0 px-2"
|
||||
style="font-size:.75rem;">
|
||||
<i class="bi bi-send me-1"></i>Resend
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<span class="text-muted small">Resend a fresh 72-hour invitation link or delete the account if it is no longer needed.</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Full Name</th>
|
||||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
<th>Assigned Contracts</th>
|
||||
<th>Accessible Facilities</th>
|
||||
<th>Created</th>
|
||||
<th width="160"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for customer in customers %}
|
||||
{% set assignments = assignment_map[customer.id] %}
|
||||
{% set facility_ids = scope_map[customer.id] %}
|
||||
<tr class="{{ 'table-secondary text-muted' if not customer.active else '' }}">
|
||||
<td>
|
||||
<strong>
|
||||
<a href="{{ url_for('customers.manage', customer_id=customer.id) }}"
|
||||
class="text-decoration-none">
|
||||
{{ customer.username }}
|
||||
</a>
|
||||
</strong>
|
||||
</td>
|
||||
<td>{{ customer.full_name or '—' }}</td>
|
||||
<td class="small text-muted">{{ customer.email }}</td>
|
||||
<td>
|
||||
{% if customer.active %}
|
||||
<span class="badge bg-success">Active</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Disabled</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if assignments %}
|
||||
{% set project_names = assignments | map(attribute='project') | map(attribute='name') | unique | list %}
|
||||
{% for pname in project_names %}
|
||||
<span class="badge bg-primary me-1">{{ pname }}</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="text-muted small">— None —</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if facility_ids %}
|
||||
<span class="badge bg-info text-dark">{{ facility_ids|length }} facilit{{ 'y' if facility_ids|length == 1 else 'ies' }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted small">— None —</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="small text-muted">{{ customer.created_at.strftime('%Y-%m-%d') }}</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('customers.manage', customer_id=customer.id) }}"
|
||||
class="btn btn-sm btn-outline-primary" title="Manage">
|
||||
<i class="bi bi-gear"></i>
|
||||
</a>
|
||||
<a href="{{ url_for('customers.edit', customer_id=customer.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary" title="Edit">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<form method="POST"
|
||||
action="{{ url_for('customers.toggle_active', customer_id=customer.id) }}"
|
||||
class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit"
|
||||
class="btn btn-sm {{ 'btn-outline-warning' if customer.active else 'btn-outline-success' }}"
|
||||
title="{{ 'Disable' if customer.active else 'Enable' }}"
|
||||
onclick="return confirm('{{ 'Disable' if customer.active else 'Enable' }} {{ customer.username }}?')">
|
||||
<i class="bi bi-{{ 'person-slash' if customer.active else 'person-check' }}"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Summary footer ── #}
|
||||
<div class="mt-3 text-muted small">
|
||||
{{ customers|length }} customer account{{ 's' if customers|length != 1 else '' }} total
|
||||
· {{ customers|selectattr('active')|list|length }} active
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body text-center py-5 text-muted">
|
||||
<i class="bi bi-person-badge fs-1 d-block mb-3 opacity-25"></i>
|
||||
<p class="mb-3">No customer accounts have been created yet.</p>
|
||||
<a href="{{ url_for('customers.create') }}" class="btn btn-primary">
|
||||
<i class="bi bi-person-plus"></i> Create First Customer
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,75 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Create Customer Account{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-6 offset-md-3">
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0">
|
||||
<i class="bi bi-person-plus-fill me-2"></i>Create Customer Account
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
<p class="text-muted mb-4" style="font-size:.9rem;">
|
||||
Enter the customer's name and email address. An invitation email will be
|
||||
sent automatically with a secure link where they can choose their own
|
||||
username and password. The account will be activated once they complete that step.
|
||||
</p>
|
||||
|
||||
<form method="POST">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.full_name.label(class="form-label fw-semibold") }}
|
||||
{{ form.full_name(class="form-control" + (" is-invalid" if form.full_name.errors else ""),
|
||||
placeholder="e.g. Jane Smith", autofocus=true) }}
|
||||
{% for error in form.full_name.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
{{ form.email.label(class="form-label fw-semibold") }}
|
||||
{{ form.email(class="form-control" + (" is-invalid" if form.email.errors else ""),
|
||||
placeholder="jane@example.com") }}
|
||||
{% for error in form.email.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
<div class="form-text">
|
||||
<i class="bi bi-envelope me-1"></i>
|
||||
An invitation email with an account setup link will be sent to this address.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-send me-1"></i>Create & Send Invitation
|
||||
</button>
|
||||
<a href="{{ url_for('customers.index') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-circle me-1"></i>Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3 border-0 bg-light">
|
||||
<div class="card-body py-2 px-3">
|
||||
<p class="mb-1" style="font-size:.8rem;">
|
||||
<i class="bi bi-info-circle me-1 text-primary"></i>
|
||||
<strong>What happens next:</strong>
|
||||
</p>
|
||||
<ol class="mb-0 ps-3" style="font-size:.8rem; color:#555;">
|
||||
<li>An invitation email is sent to the customer with a secure 72-hour link.</li>
|
||||
<li>The customer clicks the link and chooses their own username and password.</li>
|
||||
<li>The account becomes fully active and they can log in immediately.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,249 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ customer.username }} — Customer Portal{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4 align-items-center">
|
||||
<div class="col">
|
||||
<h2>
|
||||
<i class="bi bi-person-badge"></i> {{ customer.display_name }}
|
||||
{% if not customer.active %}
|
||||
<span class="badge bg-secondary ms-2 fs-6">Disabled</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success ms-2 fs-6">Active</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
<p class="text-muted mb-0 small">{{ customer.email }}{% if customer.full_name %} · @{{ customer.username }}{% endif %}</p>
|
||||
</div>
|
||||
<div class="col-auto d-flex gap-2">
|
||||
<a href="{{ url_for('customers.edit', customer_id=customer.id) }}"
|
||||
class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-pencil"></i> Edit Account
|
||||
</a>
|
||||
<form method="POST"
|
||||
action="{{ url_for('customers.toggle_active', customer_id=customer.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit"
|
||||
class="btn btn-sm {{ 'btn-outline-warning' if customer.active else 'btn-outline-success' }}"
|
||||
onclick="return confirm('{{ 'Disable' if customer.active else 'Enable' }} {{ customer.username }}?')">
|
||||
<i class="bi bi-{{ 'person-slash' if customer.active else 'person-check' }} me-1"></i>
|
||||
{{ 'Disable' if customer.active else 'Enable' }}
|
||||
</button>
|
||||
</form>
|
||||
<a href="{{ url_for('customers.index') }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-arrow-left"></i> All Customers
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
|
||||
{# ── Left column: account info + scoped facilities ── #}
|
||||
<div class="col-md-4">
|
||||
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header bg-light fw-semibold">
|
||||
<i class="bi bi-person-circle me-1"></i> Account Details
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0 small">
|
||||
<dt class="col-5 text-muted">Full Name</dt>
|
||||
<dd class="col-7">{{ customer.full_name or '—' }}</dd>
|
||||
<dt class="col-5 text-muted">Username</dt>
|
||||
<dd class="col-7">{{ customer.username }}</dd>
|
||||
<dt class="col-5 text-muted">Email</dt>
|
||||
<dd class="col-7">{{ customer.email }}</dd>
|
||||
<dt class="col-5 text-muted">Status</dt>
|
||||
<dd class="col-7">
|
||||
<span class="badge bg-{{ 'success' if customer.active else 'secondary' }}">
|
||||
{{ 'Active' if customer.active else 'Disabled' }}
|
||||
</span>
|
||||
</dd>
|
||||
<dt class="col-5 text-muted">Password</dt>
|
||||
<dd class="col-7">
|
||||
{% if customer.password_set %}
|
||||
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Set</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark"><i class="bi bi-hourglass-split me-1"></i>Pending setup</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt class="col-5 text-muted">Created</dt>
|
||||
<dd class="col-7">{{ customer.created_at.strftime('%Y-%m-%d') }}</dd>
|
||||
<dt class="col-5 text-muted">Assignments</dt>
|
||||
<dd class="col-7">{{ assignments|length }}</dd>
|
||||
<dt class="col-5 text-muted">Facilities</dt>
|
||||
<dd class="col-7">{{ facilities|length }}</dd>
|
||||
</dl>
|
||||
{% if not customer.password_set %}
|
||||
<hr class="my-3">
|
||||
<form method="POST"
|
||||
action="{{ url_for('customers.resend_invite', customer_id=customer.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm w-100"
|
||||
onclick="return confirm('Resend invitation email to {{ customer.email }}?')">
|
||||
<i class="bi bi-send me-1"></i>Resend Invitation Email
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Scoped facilities ── #}
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light fw-semibold">
|
||||
<i class="bi bi-building me-1"></i> Accessible Facilities
|
||||
</div>
|
||||
{% if facilities %}
|
||||
<div class="card-body p-0">
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for f in facilities %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center
|
||||
py-2 px-3 small">
|
||||
<span>
|
||||
<i class="bi bi-building text-muted me-1"></i>{{ f.name }}
|
||||
</span>
|
||||
{% if f.project %}
|
||||
<span class="badge bg-primary" style="font-size:.65rem;">{{ f.project.name }}</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-body text-muted small">
|
||||
No facilities accessible yet — add an assignment below.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{# ── Right column: assignments ── #}
|
||||
<div class="col-md-8">
|
||||
|
||||
{# ── Current assignments table ── #}
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-light fw-semibold d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-diagram-3 me-1"></i> Contract Assignments</span>
|
||||
</div>
|
||||
{% if assignments %}
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Contract</th>
|
||||
<th>Facility Scope</th>
|
||||
<th>Assigned</th>
|
||||
<th width="60"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for a in assignments %}
|
||||
<tr>
|
||||
<td class="small">
|
||||
<a href="{{ url_for('projects.view', project_id=a.project_id) }}"
|
||||
class="text-decoration-none">
|
||||
{{ a.project.name }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{% if a.facility %}
|
||||
<span class="badge bg-info text-dark small">{{ a.facility.name }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary small">All facilities</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-muted small">{{ a.created_at.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
<form method="POST"
|
||||
action="{{ url_for('customers.remove_assignment', assignment_id=a.id) }}"
|
||||
onsubmit="return confirm('Remove this assignment?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger"
|
||||
title="Remove">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-body text-muted small">No assignments yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Add assignment form ── #}
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light fw-semibold">
|
||||
<i class="bi bi-plus-circle me-1"></i> Add Assignment
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST"
|
||||
action="{{ url_for('customers.add_assignment', customer_id=customer.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small fw-semibold">Contract</label>
|
||||
<select name="project_id" id="proj-select" class="form-select form-select-sm"
|
||||
required>
|
||||
<option value="">— Select contract —</option>
|
||||
{% for p in projects %}
|
||||
<option value="{{ p.id }}">{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small fw-semibold">Facility Scope</label>
|
||||
<select name="facility_id" id="fac-select" class="form-select form-select-sm">
|
||||
<option value="">— All facilities in contract —</option>
|
||||
</select>
|
||||
<div class="form-text" style="font-size:.72rem;">
|
||||
Leave blank to grant access to all facilities in the contract.
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm w-100">
|
||||
<i class="bi bi-plus-circle me-1"></i> Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const projSelect = document.getElementById('proj-select');
|
||||
const facSelect = document.getElementById('fac-select');
|
||||
|
||||
projSelect.addEventListener('change', function () {
|
||||
const projectId = this.value;
|
||||
facSelect.innerHTML = '<option value="">— All facilities in contract —</option>';
|
||||
|
||||
if (!projectId) return;
|
||||
|
||||
fetch('/customers/facilities-for-project/' + projectId, { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
data.forEach(function (f) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = f.id;
|
||||
opt.textContent = f.name;
|
||||
facSelect.appendChild(opt);
|
||||
});
|
||||
})
|
||||
.catch(function () {});
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,179 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Set Your Password — Janitorial QC</title>
|
||||
<link rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||
<style>
|
||||
body { background: #eef0f4; font-family: 'Segoe UI', Arial, sans-serif; }
|
||||
.setup-card {
|
||||
max-width: 460px; margin: 80px auto;
|
||||
border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,.1);
|
||||
}
|
||||
.setup-header {
|
||||
background: #1a1d23; color: #fff;
|
||||
border-radius: 12px 12px 0 0;
|
||||
padding: 1.5rem 1.75rem 1.25rem;
|
||||
}
|
||||
.setup-header h4 { margin: 0; font-weight: 600; }
|
||||
.setup-header p { color: #94a3b8; font-size: .85rem; margin: .35rem 0 0; }
|
||||
.setup-body { background: #fff; border-radius: 0 0 12px 12px; padding: 1.75rem; }
|
||||
.req-item { font-size: .8rem; color: #64748b; }
|
||||
.req-item.met { color: #16a34a; }
|
||||
.strength-bar { height: 4px; border-radius: 2px; transition: all .3s; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="setup-card">
|
||||
<div class="setup-header">
|
||||
<h4><i class="bi bi-shield-lock me-2"></i>Set Your Password</h4>
|
||||
<p>Welcome, {{ user.display_name }}. Choose your username and a secure password to activate your account.</p>
|
||||
</div>
|
||||
<div class="setup-body">
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ 'danger' if cat == 'danger' else 'warning' if cat == 'warning' else 'success' }}
|
||||
alert-dismissible fade show py-2 mb-3" role="alert">
|
||||
{{ msg }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" id="setPasswordForm" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label fw-semibold">Choose a Username</label>
|
||||
<input type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
class="form-control {{ 'is-invalid' if form.username.errors else '' }}"
|
||||
autocomplete="username"
|
||||
autofocus
|
||||
maxlength="100">
|
||||
{% for error in form.username.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
<div class="form-text" style="font-size:.78rem;">
|
||||
3–100 characters. You will use this to log in.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label fw-semibold">New Password</label>
|
||||
<input type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
class="form-control {{ 'is-invalid' if form.password.errors else '' }}"
|
||||
autocomplete="new-password">
|
||||
{% for error in form.password.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
|
||||
{# Strength bar #}
|
||||
<div class="mt-2 mb-1">
|
||||
<div class="bg-light rounded" style="height:4px;">
|
||||
<div id="strengthBar" class="strength-bar bg-secondary" style="width:0%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Requirements checklist #}
|
||||
<div class="d-flex flex-wrap gap-3 mt-2">
|
||||
<span class="req-item" id="req-len">
|
||||
<i class="bi bi-circle me-1"></i>8+ characters
|
||||
</span>
|
||||
<span class="req-item" id="req-upper">
|
||||
<i class="bi bi-circle me-1"></i>Uppercase letter
|
||||
</span>
|
||||
<span class="req-item" id="req-num">
|
||||
<i class="bi bi-circle me-1"></i>Number
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="confirm_password" class="form-label fw-semibold">Confirm Password</label>
|
||||
<input type="password"
|
||||
id="confirm_password"
|
||||
name="confirm_password"
|
||||
class="form-control {{ 'is-invalid' if form.confirm_password.errors else '' }}"
|
||||
autocomplete="new-password">
|
||||
{% for error in form.confirm_password.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
<div id="matchFeedback" class="form-text" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100" id="submitBtn">
|
||||
<i class="bi bi-check2-circle me-1"></i>Activate Account & Log In
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var pwEl = document.getElementById('password');
|
||||
var cfEl = document.getElementById('confirm_password');
|
||||
var bar = document.getElementById('strengthBar');
|
||||
var reqLen = document.getElementById('req-len');
|
||||
var reqUpper = document.getElementById('req-upper');
|
||||
var reqNum = document.getElementById('req-num');
|
||||
var matchFb = document.getElementById('matchFeedback');
|
||||
var submitBtn = document.getElementById('submitBtn');
|
||||
|
||||
function markReq(el, met) {
|
||||
el.className = 'req-item' + (met ? ' met' : '');
|
||||
el.querySelector('i').className = (met ? 'bi bi-check-circle-fill' : 'bi bi-circle') + ' me-1';
|
||||
}
|
||||
|
||||
function updateStrength(pw) {
|
||||
var score = 0;
|
||||
var hasLen = pw.length >= 8;
|
||||
var hasUpper = /[A-Z]/.test(pw);
|
||||
var hasNum = /[0-9]/.test(pw);
|
||||
if (hasLen) score++;
|
||||
if (hasUpper) score++;
|
||||
if (hasNum) score++;
|
||||
if (pw.length >= 12) score++;
|
||||
|
||||
markReq(reqLen, hasLen);
|
||||
markReq(reqUpper, hasUpper);
|
||||
markReq(reqNum, hasNum);
|
||||
|
||||
var pct = score * 25;
|
||||
var color = score <= 1 ? 'danger' : score === 2 ? 'warning' : score === 3 ? 'info' : 'success';
|
||||
bar.style.width = pct + '%';
|
||||
bar.className = 'strength-bar bg-' + color;
|
||||
}
|
||||
|
||||
function checkMatch() {
|
||||
if (!cfEl.value) { matchFb.style.display = 'none'; return; }
|
||||
matchFb.style.display = '';
|
||||
if (pwEl.value === cfEl.value) {
|
||||
matchFb.textContent = '✓ Passwords match';
|
||||
matchFb.style.color = '#16a34a';
|
||||
} else {
|
||||
matchFb.textContent = '✗ Passwords do not match';
|
||||
matchFb.style.color = '#dc2626';
|
||||
}
|
||||
}
|
||||
|
||||
pwEl.addEventListener('input', function () {
|
||||
updateStrength(this.value);
|
||||
checkMatch();
|
||||
});
|
||||
cfEl.addEventListener('input', checkMatch);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,483 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3 align-items-center">
|
||||
<div class="col">
|
||||
<h2 class="mb-0">Welcome, {{ current_user.display_name }}!</h2>
|
||||
<span class="badge bg-{% if current_user.role == 'admin' %}danger{% elif current_user.role == 'director' %}warning{% elif current_user.role == 'project_manager' %}primary{% elif current_user.role == 'customer' %}success{% else %}info{% endif %} mt-1">
|
||||
{{ current_user.role.replace('_',' ')|title }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Inspections section ─────────────────────────────────────────────────── #}
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<i class="bi bi-clipboard-data-fill text-primary"></i>
|
||||
<span class="fw-bold text-uppercase" style="font-size:.78rem;letter-spacing:.07em;color:#64748b;">Inspections</span>
|
||||
<div style="flex:1;height:1px;background:#e2e8f0;"></div>
|
||||
</div>
|
||||
<div class="row g-3 mb-4">
|
||||
|
||||
<div class="col-6 col-md">
|
||||
<a href="{{ url_for('inspections.index', date_from=today_str, date_to=today_str) }}" class="text-decoration-none">
|
||||
<div class="card text-white bg-primary h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||
<span class="small text-white-50 fw-semibold">Today's Inspections</span>
|
||||
<i class="bi bi-clipboard-data" style="font-size:1.4rem;opacity:.3;"></i>
|
||||
</div>
|
||||
<div class="fs-1 fw-bold lh-1">{{ today_inspections }}</div>
|
||||
<div class="mt-2" style="font-size:.72rem;opacity:.7;">All inspections started today</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md">
|
||||
<a href="{{ url_for('inspections.index', status='completed', date_from=today_str, date_to=today_str) }}" class="text-decoration-none">
|
||||
<div class="card text-white bg-success h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||
<span class="small text-white-50 fw-semibold">Submitted Today</span>
|
||||
<i class="bi bi-check-circle" style="font-size:1.4rem;opacity:.3;"></i>
|
||||
</div>
|
||||
<div class="fs-1 fw-bold lh-1">{{ completed_today }}</div>
|
||||
<div class="mt-2" style="font-size:.72rem;opacity:.7;">Fully completed & submitted today</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if current_user.role != 'customer' %}
|
||||
<div class="col-6 col-md">
|
||||
<a href="{{ url_for('inspections.index', status='in_progress') }}" class="text-decoration-none">
|
||||
<div class="card text-white h-100" style="background:#b45309;">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||
<span class="small fw-semibold" style="color:rgba(255,255,255,.6);">Stale In-Progress</span>
|
||||
<i class="bi bi-hourglass-split" style="font-size:1.4rem;opacity:.3;"></i>
|
||||
</div>
|
||||
<div class="fs-1 fw-bold lh-1">{{ stale_in_progress }}</div>
|
||||
<div class="mt-2" style="font-size:.72rem;opacity:.7;">Started >24 h ago, not yet submitted</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md">
|
||||
<a href="{{ url_for('inspections.index', status='follow_up') }}" class="text-decoration-none">
|
||||
<div class="card text-white h-100" style="background:#7c3aed;">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||
<span class="small fw-semibold" style="color:rgba(255,255,255,.6);">Pending Follow-ups</span>
|
||||
<i class="bi bi-arrow-repeat" style="font-size:1.4rem;opacity:.3;"></i>
|
||||
</div>
|
||||
<div class="fs-1 fw-bold lh-1">{{ pending_followups }}</div>
|
||||
<div class="mt-2" style="font-size:.72rem;opacity:.7;">Flagged for a follow-up re-inspection</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
{# ── Issues section ──────────────────────────────────────────────────────── #}
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<i class="bi bi-exclamation-triangle-fill text-danger"></i>
|
||||
<span class="fw-bold text-uppercase" style="font-size:.78rem;letter-spacing:.07em;color:#64748b;">Issues</span>
|
||||
<div style="flex:1;height:1px;background:#e2e8f0;"></div>
|
||||
</div>
|
||||
<div class="row g-3 mb-4">
|
||||
|
||||
<div class="col-6 col-md">
|
||||
<a href="{{ url_for('issues.index', status='open') }}" class="text-decoration-none">
|
||||
<div class="card text-white bg-warning h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||
<span class="small text-white-50 fw-semibold">Open Issues</span>
|
||||
<i class="bi bi-exclamation-triangle" style="font-size:1.4rem;opacity:.3;"></i>
|
||||
</div>
|
||||
<div class="fs-1 fw-bold lh-1">{{ open_issues }}</div>
|
||||
<div class="mt-2" style="font-size:.72rem;opacity:.75;">
|
||||
Active issues not yet resolved
|
||||
{% if open_issues > 0 %}
|
||||
· {% if severity_breakdown.critical > 0 %}<span class="badge bg-danger">{{ severity_breakdown.critical }}C</span> {% endif %}
|
||||
{% if severity_breakdown.high > 0 %}<span class="badge bg-danger">{{ severity_breakdown.high }}H</span> {% endif %}
|
||||
{% if severity_breakdown.medium > 0 %}<span class="badge bg-dark">{{ severity_breakdown.medium }}M</span> {% endif %}
|
||||
{% if severity_breakdown.low > 0 %}<span class="badge bg-secondary">{{ severity_breakdown.low }}L</span>{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md">
|
||||
<a href="{{ url_for('issues.index', date_from=today_str, date_to=today_str) }}" class="text-decoration-none">
|
||||
<div class="card text-white h-100" style="background:#ea580c;">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||
<span class="small fw-semibold" style="color:rgba(255,255,255,.6);">Issues Opened Today</span>
|
||||
<i class="bi bi-flag" style="font-size:1.4rem;opacity:.3;"></i>
|
||||
</div>
|
||||
<div class="fs-1 fw-bold lh-1">{{ issues_opened_today }}</div>
|
||||
<div class="mt-2" style="font-size:.72rem;opacity:.7;">New issues reported today</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md">
|
||||
<a href="{{ url_for('issues.index', status='resolved', date_from=today_str, date_to=today_str) }}" class="text-decoration-none">
|
||||
<div class="card text-white bg-info h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||
<span class="small text-white-50 fw-semibold">Resolved Today</span>
|
||||
<i class="bi bi-check2-all" style="font-size:1.4rem;opacity:.3;"></i>
|
||||
</div>
|
||||
<div class="fs-1 fw-bold lh-1">{{ resolved_today }}</div>
|
||||
<div class="mt-2" style="font-size:.72rem;opacity:.7;">Issues closed and resolved today</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if current_user.role != 'customer' %}
|
||||
<div class="col-6 col-md">
|
||||
<a href="{{ url_for('issues.index', status='pending_verification') }}" class="text-decoration-none">
|
||||
<div class="card text-white h-100" style="background:#2563eb;">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||
<span class="small fw-semibold" style="color:rgba(255,255,255,.6);">Pending Verification</span>
|
||||
<i class="bi bi-clipboard2-check" style="font-size:1.4rem;opacity:.3;"></i>
|
||||
</div>
|
||||
<div class="fs-1 fw-bold lh-1">{{ pending_verification }}</div>
|
||||
<div class="mt-2" style="font-size:.72rem;opacity:.7;">Resolved but awaiting supervisor sign-off</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md">
|
||||
<a href="{{ url_for('issues.index', status='open') }}" class="text-decoration-none">
|
||||
<div class="card text-white h-100" style="background:#16a34a;">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||
<span class="small fw-semibold" style="color:rgba(255,255,255,.6);">Unassigned Open</span>
|
||||
<i class="bi bi-person-dash" style="font-size:1.4rem;opacity:.3;"></i>
|
||||
</div>
|
||||
<div class="fs-1 fw-bold lh-1">{{ unassigned_open }}</div>
|
||||
<div class="mt-2" style="font-size:.72rem;opacity:.7;">Open issues with no one assigned</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
{# ── SLA Summary ─────────────────────────────────────────────────────────── #}
|
||||
{% if sla_breached > 0 or sla_at_risk > 0 %}
|
||||
<div class="row g-3 mb-4">
|
||||
{% if sla_breached > 0 %}
|
||||
<div class="col-6 col-md-3">
|
||||
<a href="{{ url_for('issues.index', sla='breached') }}" class="text-decoration-none">
|
||||
<div class="card border-danger h-100">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<div class="small text-danger fw-semibold">SLA Breached</div>
|
||||
<div class="fs-2 fw-bold text-danger">{{ sla_breached }}</div>
|
||||
</div>
|
||||
<i class="bi bi-alarm text-danger" style="font-size:2.5rem;opacity:.3;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if sla_at_risk > 0 %}
|
||||
<div class="col-6 col-md-3">
|
||||
<a href="{{ url_for('issues.index', sla='at_risk') }}" class="text-decoration-none">
|
||||
<div class="card border-warning h-100">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<div class="small text-warning fw-semibold">SLA At Risk</div>
|
||||
<div class="fs-2 fw-bold text-warning">{{ sla_at_risk }}</div>
|
||||
</div>
|
||||
<i class="bi bi-alarm text-warning" style="font-size:2.5rem;opacity:.3;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Recent activity ─────────────────────────────────────────────────────── #}
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-light fw-semibold">
|
||||
<i class="bi bi-clock-history me-1"></i>Recent Activity
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if recent_inspections %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Facility</th>
|
||||
<th>Area</th>
|
||||
{% if current_user.role != 'inspector' %}<th>Inspector</th>{% endif %}
|
||||
<th>Score</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for insp in recent_inspections %}
|
||||
<tr style="cursor:pointer;" onclick="window.location='{{ url_for('inspections.view', inspection_id=insp.id) }}'">
|
||||
<td><small>{{ insp.inspection_date.strftime('%Y-%m-%d %H:%M') }}</small></td>
|
||||
<td>{{ insp.facility.name }}</td>
|
||||
<td>{{ insp.area.name if insp.area else '—' }}</td>
|
||||
{% if current_user.role != 'inspector' %}<td>{{ insp.inspector.display_name }}</td>{% endif %}
|
||||
<td>
|
||||
{% if insp.overall_score %}
|
||||
<span class="badge bg-{% if insp.overall_score >= 90 %}success{% elif insp.overall_score >= 70 %}warning{% else %}danger{% endif %}">
|
||||
{{ insp.overall_score }}%
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-{% if insp.status == 'completed' %}success{% elif insp.status == 'flagged' %}danger{% else %}secondary{% endif %}">
|
||||
{{ 'Submitted' if insp.status == 'completed' else insp.status|title }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4 text-muted">
|
||||
<i class="bi bi-inbox fs-2 d-block mb-2"></i>No recent inspections.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{# ── Inspector activity today (admin / director / PM) ───────────────────── #}
|
||||
{% if inspector_activity %}
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<span class="fw-semibold"><i class="bi bi-people me-1"></i>Inspector Activity Today</span>
|
||||
<a href="{{ url_for('inspections.index', date_from=today_str, date_to=today_str) }}"
|
||||
class="btn btn-sm btn-outline-secondary">View all</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm table-hover mb-0" style="font-size:.85rem;">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Inspector</th>
|
||||
<th class="text-center" style="width:120px;">Submitted Today</th>
|
||||
<th style="width:200px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in inspector_activity %}
|
||||
<tr class="{{ 'table-success' if row.count > 0 else '' }}">
|
||||
<td>{{ row.name }}</td>
|
||||
<td class="text-center">
|
||||
{% if row.count > 0 %}
|
||||
<span class="badge bg-success">{{ row.count }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="progress" style="height:6px;margin-top:4px;">
|
||||
{% set max_count = inspector_activity | map(attribute='count') | max %}
|
||||
{% set pct = (row.count / max_count * 100) | int if max_count > 0 else 0 %}
|
||||
<div class="progress-bar bg-success" style="width:{{ pct }}%;"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── My open issues (inspector widget) ──────────────────────────────────── #}
|
||||
{% if my_issues %}
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<span class="fw-semibold"><i class="bi bi-person-check me-1 text-primary"></i>My Open Issues</span>
|
||||
<a href="{{ url_for('issues.index') }}" class="btn btn-sm btn-outline-secondary">View all</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0" style="font-size:.85rem;">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th width="50">ID</th>
|
||||
<th width="80">Severity</th>
|
||||
<th>Facility / Description</th>
|
||||
<th width="90">Status</th>
|
||||
<th width="110">SLA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for issue in my_issues %}
|
||||
{% set sla = sla_status(issue) %}
|
||||
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
|
||||
<td class="text-muted">
|
||||
<a href="{{ url_for('issues.view', issue_id=issue.id) }}" class="text-decoration-none fw-semibold">#{{ issue.id }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning text-dark' if issue.severity == 'medium' else 'secondary' }}">
|
||||
{{ issue.severity|title }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div>{{ issue.resolved_facility.name if issue.resolved_facility else '—' }}</div>
|
||||
<div class="text-muted small">{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'warning text-dark' if issue.status == 'in_progress' else 'danger' }}">
|
||||
{{ issue.status|replace('_',' ')|title }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if sla == 'breached' %}
|
||||
<span class="badge bg-danger"><i class="bi bi-alarm me-1"></i>Breached</span>
|
||||
{% elif sla == 'at_risk' %}
|
||||
<span class="badge bg-warning text-dark"><i class="bi bi-hourglass-split me-1"></i>{{ sla_hours_remaining(issue)|abs|round(1) }}h left</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">OK</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Customer portal: scoped facilities panel ───────────────────────────── #}
|
||||
{% if current_user.role == 'customer' %}
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light fw-semibold d-flex justify-content-between align-items-center"
|
||||
role="button" data-bs-toggle="collapse" data-bs-target="#facilitiesBody"
|
||||
aria-expanded="true" aria-controls="facilitiesBody" style="cursor:pointer;">
|
||||
<span><i class="bi bi-building me-1"></i> Your Facilities
|
||||
<i class="bi bi-chevron-down ms-1 small" id="facilitiesChevron"></i>
|
||||
</span>
|
||||
{% if customer_facilities %}
|
||||
<span class="badge bg-secondary rounded-pill">{{ customer_facilities|length }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="collapse show" id="facilitiesBody">
|
||||
{% if customer_facilities %}
|
||||
<div class="card-body pb-2">
|
||||
{% if customer_facilities|length > 6 %}
|
||||
<div class="mb-3">
|
||||
<input type="text" id="facilitySearch" class="form-control form-control-sm"
|
||||
placeholder="Search facilities…">
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row g-2" id="facilityGrid">
|
||||
{% for f in customer_facilities %}
|
||||
<div class="col-12 col-sm-6 col-lg-4 facility-col">
|
||||
<div class="border rounded p-2 h-100 d-flex flex-column facility-card">
|
||||
<div class="fw-semibold mb-1 small">{{ f.name }}</div>
|
||||
<div class="text-muted" style="font-size:.8rem;">{{ f.address or '—' }}</div>
|
||||
<div class="my-1">
|
||||
<span class="badge bg-light text-dark border" style="font-size:.75rem;">
|
||||
{{ f.project.name if f.project else '—' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-auto pt-1">
|
||||
<a href="{{ url_for('facilities.view_facility', facility_id=f.id) }}"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i> View
|
||||
</a>
|
||||
<a href="{{ url_for('reports.facility_report', facility_id=f.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary ms-1">
|
||||
<i class="bi bi-graph-up"></i> Report
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if customer_facilities|length > 9 %}
|
||||
<div id="facilityShowMore" class="text-center mt-3">
|
||||
<button class="btn btn-sm btn-link text-muted" id="toggleFacilities">
|
||||
Show all {{ customer_facilities|length }} facilities <i class="bi bi-chevron-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-body text-muted small">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
No facilities have been assigned to your account yet. Please contact your administrator.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var collapseEl = document.getElementById('facilitiesBody');
|
||||
var chevron = document.getElementById('facilitiesChevron');
|
||||
collapseEl.addEventListener('hide.bs.collapse', function () {
|
||||
chevron.classList.replace('bi-chevron-down', 'bi-chevron-up');
|
||||
});
|
||||
collapseEl.addEventListener('show.bs.collapse', function () {
|
||||
chevron.classList.replace('bi-chevron-up', 'bi-chevron-down');
|
||||
});
|
||||
{% if customer_facilities and customer_facilities|length > 9 %}
|
||||
var VISIBLE = 9;
|
||||
var cols = document.querySelectorAll('#facilityGrid .facility-col');
|
||||
var btn = document.getElementById('toggleFacilities');
|
||||
var more = document.getElementById('facilityShowMore');
|
||||
var expanded = false;
|
||||
|
||||
cols.forEach(function (c, i) { if (i >= VISIBLE) c.style.display = 'none'; });
|
||||
|
||||
btn.addEventListener('click', function () {
|
||||
expanded = !expanded;
|
||||
cols.forEach(function (c, i) {
|
||||
if (i >= VISIBLE) c.style.display = expanded ? '' : 'none';
|
||||
});
|
||||
btn.innerHTML = expanded
|
||||
? 'Show fewer <i class="bi bi-chevron-up"></i>'
|
||||
: 'Show all {{ customer_facilities|length }} facilities <i class="bi bi-chevron-down"></i>';
|
||||
});
|
||||
{% endif %}
|
||||
{% if customer_facilities and customer_facilities|length > 6 %}
|
||||
document.getElementById('facilitySearch').addEventListener('input', function () {
|
||||
var q = this.value.toLowerCase();
|
||||
document.querySelectorAll('#facilityGrid .facility-col').forEach(function (col) {
|
||||
var match = col.querySelector('.facility-card').textContent.toLowerCase().includes(q);
|
||||
col.style.display = match ? '' : 'none';
|
||||
});
|
||||
var more2 = document.getElementById('facilityShowMore');
|
||||
if (more2) more2.style.display = this.value ? 'none' : '';
|
||||
});
|
||||
{% endif %}
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,39 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0">{{ title }} - {{ facility.name }}</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.name.label(class="form-label") }}
|
||||
{{ form.name(class="form-control") }}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
{{ form.area_type.label(class="form-label") }}
|
||||
{{ form.area_type(class="form-select") }}
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-save"></i> Save Area
|
||||
</button>
|
||||
<a href="{{ url_for('facilities.view_facility', facility_id=facility.id) }}" class="btn btn-secondary">
|
||||
<i class="bi bi-x-circle"></i> Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,63 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0">{{ title }}</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.name.label(class="form-label") }}
|
||||
{{ form.name(class="form-control") }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.address.label(class="form-label") }}
|
||||
{{ form.address(class="form-control", rows=3) }}
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.contact_person.label(class="form-label") }}
|
||||
{{ form.contact_person(class="form-control") }}
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.contact_phone.label(class="form-label") }}
|
||||
{{ form.contact_phone(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.project_id.label(class="form-label") }}
|
||||
{{ form.project_id(class="form-select") }}
|
||||
<div class="form-text">Link this facility to a contract for customer portal access.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="form-check">
|
||||
{{ form.active(class="form-check-input") }}
|
||||
{{ form.active.label(class="form-check-label") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-save"></i> Save Facility
|
||||
</button>
|
||||
<a href="{{ url_for('facilities.list_facilities') }}" class="btn btn-secondary">
|
||||
<i class="bi bi-x-circle"></i> Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,194 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Facilities{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<h2><i class="bi bi-building"></i> Facilities</h2>
|
||||
</div>
|
||||
<div class="col-md-6 text-end">
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<a href="{{ url_for('facilities.create_facility') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Add Facility
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if grouped %}
|
||||
{% for group_key, group in grouped.items() %}
|
||||
{# ── Contract group header ────────────────────────────────────────────── #}
|
||||
{% set collapse_id = 'contract-' ~ loop.index %}
|
||||
<div class="mb-4">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<button class="btn btn-link text-decoration-none p-0 d-flex align-items-center gap-2 fw-semibold fs-5"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#{{ collapse_id }}"
|
||||
aria-expanded="false"
|
||||
aria-controls="{{ collapse_id }}">
|
||||
<i class="bi bi-chevron-down contract-chevron" style="transition: transform .2s; transform: rotate(-90deg);"></i>
|
||||
{% if group.project %}
|
||||
<i class="bi bi-briefcase text-primary"></i>
|
||||
{{ group.project.name }}
|
||||
{% else %}
|
||||
<i class="bi bi-dash-circle text-secondary"></i>
|
||||
<span class="text-secondary">No Contract Assigned</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
<span class="badge bg-secondary ms-2">{{ group.facilities|length }}</span>
|
||||
{% if group.project and current_user.role in ['admin', 'director', 'project_manager'] %}
|
||||
<a href="{{ url_for('projects.view', project_id=group.project.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary ms-2"
|
||||
title="View Contract">
|
||||
<i class="bi bi-arrow-right-circle"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Collapsible card grid ─────────────────────────────────────────── #}
|
||||
<div class="collapse" id="{{ collapse_id }}">
|
||||
<div class="row">
|
||||
{% for facility in group.facilities %}
|
||||
<div class="col-sm-6 col-md-4 col-lg-3 mb-3">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-body py-2 px-3">
|
||||
<div class="mb-1" style="font-size:.875rem;font-weight:600;line-height:1.3;">
|
||||
<a href="{{ url_for('facilities.view_facility', facility_id=facility.id) }}" class="text-decoration-none">
|
||||
{{ facility.name }}
|
||||
</a>
|
||||
{% if not facility.active %}
|
||||
<span class="badge bg-secondary" style="font-size:.7rem;">Inactive</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if facility.address %}
|
||||
<p class="card-text text-muted mb-1" style="font-size:.78rem;">
|
||||
<i class="bi bi-geo-alt"></i> {{ facility.address }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-1">
|
||||
<small class="text-muted" style="font-size:.78rem;">
|
||||
<i class="bi bi-diagram-3"></i> {{ facility.areas.count() }} areas
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent d-flex gap-2 py-2 px-3">
|
||||
<a href="{{ url_for('facilities.view_facility', facility_id=facility.id) }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i> View Details
|
||||
</a>
|
||||
{% if current_user.role == 'admin' %}
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-danger ms-auto"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#deleteModal"
|
||||
data-facility-id="{{ facility.id }}"
|
||||
data-facility-name="{{ facility.name }}"
|
||||
data-inspection-count="{{ facility.inspections.count() }}">
|
||||
<i class="bi bi-trash"></i> Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle"></i> No facilities configured yet.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.role == 'admin' %}
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-danger text-white">
|
||||
<h5 class="modal-title" id="deleteModalLabel">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i> Confirm Deletion
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>You are about to permanently delete:</p>
|
||||
<p class="fw-bold fs-5" id="modalFacilityName"></p>
|
||||
<div id="modalWarningBlock" class="alert alert-danger d-none">
|
||||
<i class="bi bi-x-circle-fill"></i>
|
||||
<strong>Cannot delete this facility.</strong> It has existing inspection records.
|
||||
Please remove all associated inspections first.
|
||||
</div>
|
||||
<div id="modalConfirmBlock">
|
||||
<p class="text-muted mb-0">This action is <strong>irreversible</strong>. All areas associated with this facility will also be deleted.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="bi bi-x-circle"></i> Cancel
|
||||
</button>
|
||||
<form id="deleteFacilityForm" method="POST" action="" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" id="confirmDeleteBtn" class="btn btn-danger">
|
||||
<i class="bi bi-trash-fill"></i> Delete Permanently
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// ── Rotate chevron on collapse toggle ────────────────────────────────
|
||||
document.querySelectorAll('[data-bs-toggle="collapse"]').forEach(function (btn) {
|
||||
const target = document.querySelector(btn.getAttribute('data-bs-target'));
|
||||
if (!target) return;
|
||||
const chevron = btn.querySelector('.contract-chevron');
|
||||
|
||||
target.addEventListener('hide.bs.collapse', function () {
|
||||
if (chevron) chevron.style.transform = 'rotate(-90deg)';
|
||||
});
|
||||
target.addEventListener('show.bs.collapse', function () {
|
||||
if (chevron) chevron.style.transform = 'rotate(0deg)';
|
||||
});
|
||||
});
|
||||
|
||||
{% if current_user.role == 'admin' %}
|
||||
// ── Delete modal wiring ──────────────────────────────────────────────
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
deleteModal.addEventListener('show.bs.modal', function (event) {
|
||||
const button = event.relatedTarget;
|
||||
const facilityId = button.getAttribute('data-facility-id');
|
||||
const facilityName = button.getAttribute('data-facility-name');
|
||||
const inspectionCount = parseInt(button.getAttribute('data-inspection-count'));
|
||||
|
||||
document.getElementById('modalFacilityName').textContent = facilityName;
|
||||
document.getElementById('deleteFacilityForm').action = '/facilities/' + facilityId + '/delete';
|
||||
|
||||
const warningBlock = document.getElementById('modalWarningBlock');
|
||||
const confirmBlock = document.getElementById('modalConfirmBlock');
|
||||
const confirmBtn = document.getElementById('confirmDeleteBtn');
|
||||
|
||||
if (inspectionCount > 0) {
|
||||
warningBlock.classList.remove('d-none');
|
||||
confirmBlock.classList.add('d-none');
|
||||
confirmBtn.disabled = true;
|
||||
} else {
|
||||
warningBlock.classList.add('d-none');
|
||||
confirmBlock.classList.remove('d-none');
|
||||
confirmBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,210 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ facility.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8">
|
||||
<h2><i class="bi bi-building"></i> {{ facility.name }}</h2>
|
||||
</div>
|
||||
<div class="col-md-4 text-end d-flex gap-2 justify-content-end align-items-start">
|
||||
<a href="{{ url_for('facilities.list_facilities') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Facilities
|
||||
</a>
|
||||
{% if current_user.role in ['admin', 'director', 'project_manager', 'customer'] %}
|
||||
<a href="{{ url_for('reports.facility_report', facility_id=facility.id) }}"
|
||||
class="btn btn-outline-info">
|
||||
<i class="bi bi-graph-up-arrow"></i> Scorecard
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<a href="{{ url_for('facilities.edit_facility', facility_id=facility.id) }}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
</a>
|
||||
<a href="{{ url_for('facilities.create_area', facility_id=facility.id) }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Add Area
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if current_user.role == 'admin' %}
|
||||
<button type="button"
|
||||
class="btn btn-danger"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#deleteModal">
|
||||
<i class="bi bi-trash-fill"></i> Delete Facility
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0">Facility Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm table-borderless">
|
||||
<tr>
|
||||
<th width="40%">Address:</th>
|
||||
<td>{{ facility.address or 'N/A' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Contact Person:</th>
|
||||
<td>{{ facility.contact_person or 'N/A' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Contact Phone:</th>
|
||||
<td>{{ facility.contact_phone or 'N/A' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Status:</th>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'success' if facility.active else 'secondary' }}">
|
||||
{{ 'Active' if facility.active else 'Inactive' }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0">Statistics</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row text-center g-2">
|
||||
<div class="col-4">
|
||||
<h3 class="text-primary">{{ areas|length }}</h3>
|
||||
<small class="text-muted">Areas</small>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<h3 class="text-info">{{ facility.inspections.count() }}</h3>
|
||||
<small class="text-muted">Inspections</small>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
{%- set ns = namespace(open=0) -%}
|
||||
{%- for area in areas -%}
|
||||
{%- set ns.open = ns.open + area.issues.filter_by(status='open').count() + area.issues.filter_by(status='in_progress').count() -%}
|
||||
{%- endfor -%}
|
||||
<h3 class="text-{{ 'danger' if ns.open > 0 else 'success' }}">{{ ns.open }}</h3>
|
||||
<small class="text-muted">Open Issues</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center mt-3">
|
||||
<a href="{{ url_for('reports.facility_report', facility_id=facility.id) }}"
|
||||
class="btn btn-sm btn-outline-info">
|
||||
<i class="bi bi-graph-up-arrow me-1"></i>View Full Scorecard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="bi bi-diagram-3"></i> Areas</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if areas %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Area Name</th>
|
||||
<th>Type</th>
|
||||
<th>Inspections</th>
|
||||
<th width="150">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for area in areas %}
|
||||
<tr>
|
||||
<td><strong>{{ area.name }}</strong></td>
|
||||
<td>
|
||||
<span class="badge bg-secondary">{{ area.area_type|title if area.area_type else 'N/A' }}</span>
|
||||
</td>
|
||||
<td>{{ area.inspections.count() }}</td>
|
||||
<td>
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<a href="{{ url_for('facilities.edit_area', area_id=area.id) }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
{% set area_issue_count = area.issues.count() %}
|
||||
{% set area_insp_count = area.inspections.count() %}
|
||||
<form method="POST" action="{{ url_for('facilities.delete_area', area_id=area.id) }}" class="d-inline"
|
||||
onsubmit="return confirm('Delete area '{{ area.name }}'? This cannot be undone.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger"
|
||||
{% if area_insp_count > 0 or area_issue_count > 0 %}
|
||||
disabled
|
||||
title="Cannot delete — {{ area_insp_count }} inspection(s) and {{ area_issue_count }} issue(s) on file"
|
||||
{% endif %}>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info mb-0">
|
||||
<i class="bi bi-info-circle"></i> No areas defined for this facility yet.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if current_user.role == 'admin' %}
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-danger text-white">
|
||||
<h5 class="modal-title" id="deleteModalLabel">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i> Confirm Deletion
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>You are about to permanently delete:</p>
|
||||
<p class="fw-bold fs-5">{{ facility.name }}</p>
|
||||
{% if facility.inspections.count() > 0 %}
|
||||
<div class="alert alert-danger mb-0">
|
||||
<i class="bi bi-x-circle-fill"></i>
|
||||
<strong>Cannot delete this facility.</strong> It has
|
||||
<strong>{{ facility.inspections.count() }} inspection record(s)</strong> on file.
|
||||
Please remove all associated inspections first.
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i>
|
||||
This action is <strong>irreversible</strong>. All
|
||||
<strong>{{ areas|length }} area(s)</strong> associated with this facility will also be deleted.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="bi bi-x-circle"></i> Cancel
|
||||
</button>
|
||||
<form method="POST" action="{{ url_for('facilities.delete_facility', facility_id=facility.id) }}" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit"
|
||||
class="btn btn-danger"
|
||||
{% if facility.inspections.count() > 0 %}disabled{% endif %}>
|
||||
<i class="bi bi-trash-fill"></i> Delete Permanently
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Flag Issue{% endblock %}
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-sm border-danger">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h5 class="mb-0"><i class="bi bi-exclamation-triangle"></i> Flag Issue During Inspection</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-light border mb-3">
|
||||
<strong>Inspection:</strong> {{ inspection.template.name }}<br>
|
||||
<strong>Facility:</strong> {{ inspection.facility.name }}
|
||||
</div>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{{ form.facility_id(type="hidden") }}
|
||||
<div class="mb-3">
|
||||
{{ form.severity.label(class="form-label fw-semibold") }}
|
||||
{{ form.severity(class="form-select") }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.description.label(class="form-label fw-semibold") }}
|
||||
{{ form.description(class="form-control", rows=4, placeholder="Describe the issue in detail…") }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.photo.label(class="form-label fw-semibold") }}
|
||||
{{ form.photo(class="form-control") }}
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
{{ form.assigned_to.label(class="form-label fw-semibold") }}
|
||||
{{ form.assigned_to(class="form-select") }}
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-danger"><i class="bi bi-flag"></i> Log Issue</button>
|
||||
<a href="{{ url_for('inspections.execute', inspection_id=inspection.id) }}"
|
||||
class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,286 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Inspections{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-clipboard-data"></i> Inspections</h2>
|
||||
{% if current_user.role != 'customer' %}
|
||||
<a href="{{ url_for('inspections.start') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> New Inspection
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Filters #}
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body py-2">
|
||||
<form method="get">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small mb-1">Inspection #</label>
|
||||
<input type="number" name="inspection_id" class="form-control form-control-sm"
|
||||
min="1" placeholder="ID" value="{{ inspection_id_filter }}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1">Status</label>
|
||||
<select name="status" class="form-select form-select-sm">
|
||||
<option value="">All Statuses</option>
|
||||
{% for s in ['in_progress','completed','flagged'] %}
|
||||
<option value="{{ s }}" {% if status_filter == s %}selected{% endif %}>{{ 'Submitted' if s == 'completed' else s|replace('_',' ')|title }}</option>
|
||||
{% endfor %}
|
||||
<option value="follow_up" {% if status_filter == 'follow_up' %}selected{% endif %}>Flagged Follow-up</option>
|
||||
<option value="has_issues" {% if status_filter == 'has_issues' %}selected{% endif %}>Has Logged Issues</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1">Contract</label>
|
||||
<select name="contract_id" id="insp_filter_contract" class="form-select form-select-sm">
|
||||
<option value="">All Contracts</option>
|
||||
{% for p in projects %}
|
||||
<option value="{{ p.id }}" {% if contract_filter == p.id|string %}selected{% endif %}>{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1">Facility</label>
|
||||
<select name="facility_id" id="insp_filter_facility" class="form-select form-select-sm">
|
||||
<option value="">All Facilities</option>
|
||||
{% for f in facilities %}
|
||||
<option value="{{ f.id }}" {% if facility_filter == f.id|string %}selected{% endif %}>{{ f.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% if inspectors %}
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1">Inspector</label>
|
||||
<select name="inspector_id" class="form-select form-select-sm">
|
||||
<option value="">All Inspectors</option>
|
||||
{% for u in inspectors %}
|
||||
<option value="{{ u.id }}" {% if inspector_filter == u.id|string %}selected{% endif %}>{{ u.display_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="row g-2 align-items-end mt-1">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1">Date From</label>
|
||||
<input type="date" name="date_from" class="form-control form-control-sm"
|
||||
value="{{ date_from_filter }}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1">Date To</label>
|
||||
<input type="date" name="date_to" class="form-control form-control-sm"
|
||||
value="{{ date_to_filter }}">
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small mb-1">Min Score</label>
|
||||
<input type="number" name="score_min" class="form-control form-control-sm"
|
||||
min="0" max="100" placeholder="0"
|
||||
value="{{ score_min_filter }}">
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small mb-1">Max Score</label>
|
||||
<input type="number" name="score_max" class="form-control form-control-sm"
|
||||
min="0" max="100" placeholder="100"
|
||||
value="{{ score_max_filter }}">
|
||||
</div>
|
||||
<div class="col-auto d-flex align-items-end gap-2 flex-wrap">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">Filter</button>
|
||||
<a href="{{ url_for('inspections.index') }}" class="btn btn-sm btn-outline-secondary">Clear</a>
|
||||
<a id="exportPdfBtn"
|
||||
href="{{ url_for('inspections.export_list_pdf', **request.args) }}"
|
||||
class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi bi-file-earmark-pdf"></i> Export PDF
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
{% if inspections.items %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>#</th><th>Date</th><th>Contract</th><th>Facility</th><th>Area</th>
|
||||
<th>Template</th><th>Inspector</th><th>Score</th>
|
||||
<th>Status</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ins in inspections.items %}
|
||||
<tr>
|
||||
<td><small class="text-muted">#{{ ins.id }}</small></td>
|
||||
<td>{{ ins.inspection_date.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td><small>{{ ins.facility.project.name if ins.facility and ins.facility.project else '—' }}</small></td>
|
||||
<td>{{ ins.facility.name }}</td>
|
||||
<td>{% if ins.area %}{{ ins.area.name }}{% else %}<span class="text-muted">—</span>{% endif %}</td>
|
||||
<td>{{ ins.template.name }}</td>
|
||||
<td>{{ ins.inspector.display_name }}</td>
|
||||
<td>
|
||||
{% if ins.overall_score %}
|
||||
<span class="badge bg-{{ 'success' if ins.overall_score >= 90 else 'warning' if ins.overall_score >= 70 else 'danger' }}">
|
||||
{{ ins.overall_score }}%
|
||||
</span>
|
||||
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'success' if ins.status == 'completed' else 'danger' if ins.status == 'flagged' else 'secondary' }}">
|
||||
{{ 'Submitted' if ins.status == 'completed' else ins.status|replace('_',' ')|title }}
|
||||
</span>
|
||||
{% if ins.status == 'in_progress' %}
|
||||
{% set hours_open = ((now - ins.inspection_date).total_seconds() / 3600) %}
|
||||
{% if hours_open > 24 %}
|
||||
<span class="badge bg-warning text-dark ms-1" title="In progress for over 24 hours — may be stale">
|
||||
<i class="bi bi-clock-history"></i> Stale
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if ins.follow_up_required and not ins.follow_ups.count() %}
|
||||
<span class="badge bg-danger ms-1" title="Follow-up re-inspection required">
|
||||
<i class="bi bi-arrow-repeat"></i> Follow-up
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{% if ins.status == 'in_progress' or ins.status == 'flagged' %}
|
||||
<a href="{{ url_for('inspections.execute', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-primary insp-list-link">Continue</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('inspections.view', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-secondary insp-list-link">View</a>
|
||||
{% endif %}
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-danger ms-1"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#deleteInspectionModal"
|
||||
data-inspection-id="{{ ins.id }}"
|
||||
data-inspection-label="{{ ins.template.name }} — {{ ins.facility.name }} ({{ ins.inspection_date.strftime('%Y-%m-%d') }})"
|
||||
title="Delete inspection">
|
||||
<i class="bi bi-trash3"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{# Pagination #}
|
||||
{% if inspections.pages > 1 %}
|
||||
<div class="d-flex justify-content-center py-3">
|
||||
<nav><ul class="pagination pagination-sm mb-0">
|
||||
{% for p in inspections.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
||||
{% if p %}
|
||||
<li class="page-item {{ 'active' if p == inspections.page }}">
|
||||
<a class="page-link" href="{{ url_for('inspections.index', page=p, inspection_id=inspection_id_filter, status=status_filter, contract_id=contract_filter, facility_id=facility_filter, date_from=date_from_filter, date_to=date_to_filter, score_min=score_min_filter, score_max=score_max_filter, inspector_id=inspector_filter) }}">{{ p }}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul></nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="alert alert-info m-3"><i class="bi bi-info-circle"></i> No inspections found.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<!-- Delete Inspection Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteInspectionModal" tabindex="-1" aria-labelledby="deleteInspectionModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-danger text-white">
|
||||
<h5 class="modal-title" id="deleteInspectionModalLabel">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i> Confirm Deletion
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>You are about to permanently delete the following inspection:</p>
|
||||
<p class="fw-bold" id="deleteInspectionLabel"></p>
|
||||
<p class="text-muted mb-0">This will also remove all associated results, flagged issues, and uploaded photos. This action is <strong>irreversible</strong>.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="bi bi-x-circle"></i> Cancel
|
||||
</button>
|
||||
<form id="deleteInspectionForm" method="POST" action="" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-trash3-fill"></i> Delete Permanently
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
// Save current filtered URL so view/execute pages can restore it on Back
|
||||
var links = document.querySelectorAll('.insp-list-link');
|
||||
links.forEach(function (a) {
|
||||
a.addEventListener('click', function () {
|
||||
sessionStorage.setItem('insp_list_back_url', window.location.href);
|
||||
});
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var contractSel = document.getElementById('insp_filter_contract');
|
||||
var facilitySel = document.getElementById('insp_filter_facility');
|
||||
if (!contractSel || !facilitySel) return;
|
||||
|
||||
var FACILITIES_URL = '{{ url_for("inspections.facilities_for_project", project_id=0) }}'.replace('/0', '/');
|
||||
|
||||
contractSel.addEventListener('change', function () {
|
||||
var projectId = this.value;
|
||||
facilitySel.value = '';
|
||||
if (!projectId) {
|
||||
facilitySel.innerHTML = '<option value="">All Facilities</option>';
|
||||
return;
|
||||
}
|
||||
facilitySel.disabled = true;
|
||||
facilitySel.innerHTML = '<option value="">Loading…</option>';
|
||||
fetch(FACILITIES_URL + projectId)
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
var html = '<option value="">All Facilities</option>';
|
||||
data.forEach(function (f) {
|
||||
html += '<option value="' + f.id + '">' + f.name + '</option>';
|
||||
});
|
||||
facilitySel.innerHTML = html;
|
||||
facilitySel.disabled = false;
|
||||
})
|
||||
.catch(function () { facilitySel.disabled = false; });
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const modal = document.getElementById('deleteInspectionModal');
|
||||
modal.addEventListener('show.bs.modal', function (event) {
|
||||
const btn = event.relatedTarget;
|
||||
const id = btn.getAttribute('data-inspection-id');
|
||||
const label = btn.getAttribute('data-inspection-label');
|
||||
document.getElementById('deleteInspectionLabel').textContent = label;
|
||||
document.getElementById('deleteInspectionForm').action = '/inspections/' + id + '/delete';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,153 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}New Inspection{% endblock %}
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0"><i class="bi bi-play-circle"></i> New Inspection</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.template_id.label(class="form-label fw-semibold") }}
|
||||
{{ form.template_id(class="form-select" + (" is-invalid" if form.template_id.errors else "")) }}
|
||||
{% for e in form.template_id.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.project_id.label(class="form-label fw-semibold") }}
|
||||
{{ form.project_id(class="form-select" + (" is-invalid" if form.project_id.errors else ""), id="projectSelect") }}
|
||||
{% for e in form.project_id.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.facility_id.label(class="form-label fw-semibold") }}
|
||||
{{ form.facility_id(class="form-select" + (" is-invalid" if form.facility_id.errors else ""), id="facilitySelect") }}
|
||||
{% for e in form.facility_id.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
|
||||
<div id="facilitySpinner" class="form-text text-muted d-none">
|
||||
<span class="spinner-border spinner-border-sm" role="status"></span> Loading facilities…
|
||||
</div>
|
||||
<div id="facilityEmpty" class="form-text text-warning d-none">
|
||||
No active facilities found for this contract.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" id="areaGroup" style="display:none;">
|
||||
{{ form.area_id.label(class="form-label fw-semibold") }}
|
||||
{{ form.area_id(class="form-select", id="areaSelect") }}
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<i class="bi bi-play-fill"></i> Begin Inspection
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const projectSel = document.getElementById('projectSelect');
|
||||
const facilitySel = document.getElementById('facilitySelect');
|
||||
const spinner = document.getElementById('facilitySpinner');
|
||||
const emptyMsg = document.getElementById('facilityEmpty');
|
||||
const areaGroup = document.getElementById('areaGroup');
|
||||
const areaSel = document.getElementById('areaSelect');
|
||||
|
||||
const FACILITIES_URL = `{{ url_for('inspections.facilities_for_project', project_id=0) }}`.replace('/0', '/');
|
||||
const AREAS_URL = `{{ url_for('inspections.areas_for_facility', facility_id=0) }}`.replace('/0', '/');
|
||||
|
||||
function loadAreas(facilityId, selectedAreaId) {
|
||||
if (!facilityId) {
|
||||
areaGroup.style.display = 'none';
|
||||
areaSel.innerHTML = '<option value="0">— No specific area —</option>';
|
||||
return;
|
||||
}
|
||||
fetch(AREAS_URL + facilityId)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.length === 0) {
|
||||
areaGroup.style.display = 'none';
|
||||
areaSel.innerHTML = '<option value="0">— No specific area —</option>';
|
||||
} else {
|
||||
areaSel.innerHTML = '<option value="0">— No specific area —</option>';
|
||||
data.forEach(a => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = a.id;
|
||||
opt.textContent = a.name;
|
||||
if (selectedAreaId && a.id === selectedAreaId) opt.selected = true;
|
||||
areaSel.appendChild(opt);
|
||||
});
|
||||
areaGroup.style.display = '';
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
areaGroup.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function loadFacilities(projectId, selectedFacilityId, selectedAreaId) {
|
||||
if (!projectId) return;
|
||||
spinner.classList.remove('d-none');
|
||||
emptyMsg.classList.add('d-none');
|
||||
facilitySel.disabled = true;
|
||||
areaGroup.style.display = 'none';
|
||||
|
||||
fetch(FACILITIES_URL + projectId)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
facilitySel.innerHTML = '';
|
||||
if (data.length === 0) {
|
||||
emptyMsg.classList.remove('d-none');
|
||||
facilitySel.innerHTML = '<option value="">— no facilities —</option>';
|
||||
} else {
|
||||
data.forEach(f => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = f.id;
|
||||
opt.textContent = f.name;
|
||||
if (selectedFacilityId && f.id === selectedFacilityId) opt.selected = true;
|
||||
facilitySel.appendChild(opt);
|
||||
});
|
||||
loadAreas(facilitySel.value, selectedAreaId);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
facilitySel.innerHTML = '<option value="">— error loading facilities —</option>';
|
||||
})
|
||||
.finally(() => {
|
||||
spinner.classList.add('d-none');
|
||||
facilitySel.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
projectSel.addEventListener('change', function () {
|
||||
loadFacilities(this.value, null, null);
|
||||
});
|
||||
|
||||
facilitySel.addEventListener('change', function () {
|
||||
loadAreas(this.value, null);
|
||||
});
|
||||
|
||||
// On page load: if project already selected (e.g. validation error or reinspect),
|
||||
// reload facility list preserving the currently selected facility and area values.
|
||||
const initProject = projectSel.value;
|
||||
const initFacility = facilitySel.value ? parseInt(facilitySel.value, 10) : null;
|
||||
const initArea = areaSel.value ? parseInt(areaSel.value, 10) : null;
|
||||
if (initProject) {
|
||||
const hasRealOptions = Array.from(facilitySel.options).some(o => parseInt(o.value, 10) > 0);
|
||||
if (!hasRealOptions) {
|
||||
loadFacilities(initProject, initFacility, initArea);
|
||||
} else {
|
||||
// Facilities already rendered server-side — just load areas for the selected facility
|
||||
if (initFacility) loadAreas(initFacility, initArea);
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,903 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Inspection #{{ inspection.id }} — Results{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { font-family:'DM Sans', sans-serif; background:#eef0f4; }
|
||||
.insp-wrap { max-width:1000px; margin:0 auto; padding:0 1rem 4rem; }
|
||||
.insp-header {
|
||||
background:#1a1d23; color:#fff; padding:1.1rem 1.75rem;
|
||||
border-radius:12px 12px 0 0;
|
||||
display:flex; align-items:center; justify-content:space-between; gap:1rem;
|
||||
}
|
||||
.insp-header h4 { margin:0; font-weight:600; font-size:1.05rem; }
|
||||
.insp-header .sub { font-size:.78rem; color:#94a3b8; margin-top:.2rem; }
|
||||
.score-badge {
|
||||
font-size:1.5rem; font-weight:700; padding:.4rem 1rem;
|
||||
border-radius:8px; min-width:80px; text-align:center;
|
||||
}
|
||||
.insp-body {
|
||||
background:#fff; border:1px solid #e2e8f0; border-top:none;
|
||||
border-radius:0 0 12px 12px; padding:1.75rem; padding-bottom:3rem; overflow-x:auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.meta-row { display:flex; flex-wrap:wrap; gap:1.5rem; margin-bottom:1.5rem; padding-bottom:1rem; border-bottom:1px solid #e2e8f0; }
|
||||
.meta-item { display:flex; flex-direction:column; }
|
||||
.meta-item .lbl { font-size:.72rem; color:#94a3b8; font-weight:500; text-transform:uppercase; letter-spacing:.04em; }
|
||||
.meta-item .val { font-size:.9rem; color:#0f172a; font-weight:500; margin-top:.1rem; }
|
||||
|
||||
/* ── Grid: 12 cols × 36px rows, tight 3px gap ── */
|
||||
.form-grid {
|
||||
display:grid;
|
||||
grid-template-columns: repeat(12, 72px);
|
||||
grid-auto-rows: minmax(36px, auto);
|
||||
gap: 3px 8px;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
/* iPad fluid grid override */
|
||||
@media (max-width: 1194px) {
|
||||
.form-grid {
|
||||
--_cell: calc((min(calc(100vw - 2rem), 900px) - 11 * 8px) / 12);
|
||||
grid-template-columns: repeat(12, var(--_cell));
|
||||
grid-auto-rows: minmax(calc(var(--_cell) * 0.5), auto);
|
||||
width: 100%;
|
||||
}
|
||||
.insp-body { padding: 1rem; }
|
||||
}
|
||||
.fg-cell {
|
||||
overflow:hidden; display:flex; flex-direction:column;
|
||||
padding:.1rem .4rem;
|
||||
}
|
||||
.fg-cell .field-lbl {
|
||||
font-size:.68rem; font-weight:600; color:#94a3b8;
|
||||
display:block; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
|
||||
flex-shrink:0; line-height:1.3;
|
||||
}
|
||||
.fg-cell .field-val {
|
||||
font-size:.78rem; color:#0f172a; background:#f8fafc;
|
||||
border:1px solid #e2e8f0; border-radius:4px;
|
||||
padding:.1rem .35rem; flex:1; min-height:0;
|
||||
overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
|
||||
display:flex; align-items:center;
|
||||
}
|
||||
.fg-cell .field-val.empty { color:#94a3b8; font-style:italic; }
|
||||
/* Textarea values: wrap text, scroll vertically, align to top */
|
||||
.fg-cell .field-val.multiline {
|
||||
white-space:pre-wrap; overflow-y:auto; overflow-x:hidden;
|
||||
text-overflow:clip; align-items:flex-start; word-break:break-word;
|
||||
min-height:calc(0.78rem * 1.5 * 4 + 0.2rem * 2); /* 4 lines × line-height + padding */
|
||||
flex:none;
|
||||
}
|
||||
|
||||
/* Section heading — spans full width, single row */
|
||||
.section-head {
|
||||
display:flex; align-items:flex-end; padding-bottom:.15rem;
|
||||
border-bottom:2px solid #e2e8f0; height:100%;
|
||||
}
|
||||
.section-head strong { font-weight:700; color:#374151; font-size:.88rem; }
|
||||
|
||||
/* Rating — inline stars */
|
||||
.rating-line { display:flex; align-items:center; gap:.28rem; flex:1; min-height:0; }
|
||||
.rating-stars-ro { color:#f59e0b; font-size:.9rem; letter-spacing:.02rem; line-height:1; white-space:nowrap; }
|
||||
.rating-score { font-size:.7rem; color:#64748b; white-space:nowrap; }
|
||||
|
||||
/* View Photo / Signature button */
|
||||
.btn-view-media {
|
||||
display:inline-flex; align-items:center; gap:.2rem;
|
||||
font-size:.68rem; padding:.1rem .42rem; border-radius:4px;
|
||||
border:1px solid #cbd5e1; background:#f8fafc; color:#374151;
|
||||
cursor:pointer; white-space:nowrap;
|
||||
transition:border-color .12s, background .12s;
|
||||
}
|
||||
.btn-view-media:hover { border-color:#2563eb; background:#eff6ff; color:#2563eb; }
|
||||
|
||||
/* Media lightbox */
|
||||
.media-backdrop {
|
||||
display:none; position:fixed; inset:0; z-index:1055;
|
||||
background:rgba(0,0,0,.65); align-items:center; justify-content:center;
|
||||
}
|
||||
.media-backdrop.open { display:flex; }
|
||||
.media-modal {
|
||||
background:#fff; border-radius:10px; overflow:hidden;
|
||||
max-width:min(92vw,700px); max-height:90vh;
|
||||
display:flex; flex-direction:column;
|
||||
box-shadow:0 8px 40px rgba(0,0,0,.35);
|
||||
}
|
||||
.media-modal-head {
|
||||
display:flex; align-items:center; justify-content:space-between;
|
||||
padding:.5rem .9rem; border-bottom:1px solid #e2e8f0;
|
||||
font-size:.8rem; font-weight:600; color:#374151;
|
||||
}
|
||||
.media-modal-close { border:none; background:none; font-size:1.1rem; color:#64748b; cursor:pointer; line-height:1; padding:0; }
|
||||
.media-modal-close:hover { color:#dc2626; }
|
||||
.media-modal img { display:block; max-width:100%; max-height:calc(90vh - 46px); object-fit:contain; }
|
||||
|
||||
/* Issues */
|
||||
.tbl-view { width:100%; border-collapse:collapse; font-size:.76rem; }
|
||||
.tbl-view th { background:#f1f5f9; font-weight:600; color:#374151; padding:.2rem .4rem; border:1px solid #e2e8f0; }
|
||||
.tbl-view td { border:1px solid #e2e8f0; padding:.15rem .4rem; color:#0f172a; }
|
||||
/* ── Print styles ── */
|
||||
@media print {
|
||||
|
||||
/* ── 1. Hide all screen-only chrome ── */
|
||||
nav, .navbar, header,
|
||||
.d-flex.justify-content-between.align-items-center.mb-3,
|
||||
.alert-warning,
|
||||
.modal, .modal-backdrop,
|
||||
.media-backdrop,
|
||||
a.btn, button,
|
||||
.btn { display: none !important; }
|
||||
|
||||
/* ── 2. Page / body reset ── */
|
||||
html, body { background: #fff !important;
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
font-size: 9pt; color: #1a1d23; }
|
||||
.insp-wrap { max-width: 100% !important;
|
||||
padding: 0 !important; margin: 0 !important; }
|
||||
@page { margin: 0.65in; size: letter; }
|
||||
|
||||
/* ── 3. Dark header band (matches PDF header) ── */
|
||||
.insp-header { background: #1a1d23 !important;
|
||||
color: #fff !important;
|
||||
border-radius: 0 !important;
|
||||
padding: 10pt 14pt !important;
|
||||
display: flex !important;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
page-break-after: avoid;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact; }
|
||||
.insp-header h4 { font-size: 12pt !important;
|
||||
color: #fff !important; margin: 0; }
|
||||
.insp-header .sub { font-size: 8pt !important;
|
||||
color: #94a3b8 !important; }
|
||||
/* Score badge in header */
|
||||
.score-badge { font-size: 13pt !important;
|
||||
font-weight: 700;
|
||||
padding: 4pt 10pt !important;
|
||||
border-radius: 6pt !important;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact; }
|
||||
|
||||
/* ── 4. Body panel ── */
|
||||
.insp-body { border: 1pt solid #e2e8f0 !important;
|
||||
border-top: none !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
padding: 12pt !important; }
|
||||
|
||||
/* ── 5. Meta row — render as a bordered grid (matches PDF _meta_table) ── */
|
||||
.meta-row { display: grid !important;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0 !important;
|
||||
border: 0.5pt solid #e2e8f0;
|
||||
background: #f1f5f9 !important;
|
||||
margin-bottom: 10pt !important;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact; }
|
||||
.meta-item { padding: 4pt 6pt !important;
|
||||
border: 0.25pt solid #e2e8f0 !important;
|
||||
background: #f1f5f9 !important; }
|
||||
.meta-item .lbl { font-size: 7pt !important;
|
||||
color: #64748b !important;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
display: block; }
|
||||
.meta-item .val { font-size: 9pt !important;
|
||||
color: #1a1d23 !important;
|
||||
font-weight: 700;
|
||||
display: block; margin-top: 1pt; }
|
||||
|
||||
/* ── 6. Score / status comparison alerts (parent/child links) ── */
|
||||
.alert-info, .alert-secondary { display: block !important;
|
||||
font-size: 7.5pt;
|
||||
border: 0.5pt solid #e2e8f0 !important;
|
||||
padding: 4pt 8pt !important;
|
||||
margin-bottom: 6pt !important;
|
||||
background: #f8fafc !important;
|
||||
border-radius: 0 !important; }
|
||||
|
||||
/* ── 7. Section headings (matches PDF SectionHead + HR rule) ── */
|
||||
.section-head { background: none !important;
|
||||
border-bottom: 1.5pt solid #e2e8f0 !important;
|
||||
padding: 0 0 2pt 0 !important;
|
||||
margin: 10pt 0 4pt 0 !important;
|
||||
page-break-after: avoid; }
|
||||
.section-head strong { font-size: 9.5pt !important;
|
||||
font-weight: 700 !important;
|
||||
color: #1a1d23 !important; }
|
||||
|
||||
/* ── 8. Form grid — keep CSS grid, scale columns to page width ──
|
||||
Letter page at 0.65in margins → ~7.7in usable.
|
||||
12 equal columns: each unit = 7.7in / 12 ≈ 0.642in.
|
||||
We replicate the iPad fluid override pattern but for print. ── */
|
||||
.form-grid { display: grid !important;
|
||||
--_cell: calc((100% - 11 * 6pt) / 12);
|
||||
grid-template-columns: repeat(12, var(--_cell)) !important;
|
||||
grid-auto-rows: minmax(28pt, auto) !important;
|
||||
gap: 3pt 6pt !important;
|
||||
width: 100% !important; }
|
||||
.fg-cell { overflow: visible !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
padding: 2pt 3pt !important;
|
||||
min-height: 0 !important; }
|
||||
|
||||
/* Field label (matches PDF FieldLabel: 7.5pt, slate) */
|
||||
.fg-cell .field-lbl { font-size: 7pt !important;
|
||||
color: #64748b !important;
|
||||
font-weight: 600;
|
||||
display: block !important;
|
||||
white-space: nowrap !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 1pt; }
|
||||
|
||||
/* Field value box (matches PDF: light border + #f8fafc bg) */
|
||||
.fg-cell .field-val { font-size: 8pt !important;
|
||||
color: #1a1d23 !important;
|
||||
background: #f8fafc !important;
|
||||
border: 0.5pt solid #e2e8f0 !important;
|
||||
border-radius: 2pt !important;
|
||||
padding: 2pt 4pt !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
white-space: nowrap !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
flex: 1 !important;
|
||||
min-height: 0 !important;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact; }
|
||||
.fg-cell .field-val.multiline { white-space: pre-wrap !important;
|
||||
overflow: auto !important; }
|
||||
.fg-cell .field-val.empty { color: #94a3b8 !important;
|
||||
font-style: italic; }
|
||||
|
||||
/* Rating stars */
|
||||
.rating-stars-ro { font-size: 9pt !important;
|
||||
color: #f59e0b !important;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact; }
|
||||
.rating-line { display: flex !important;
|
||||
align-items: center;
|
||||
gap: 4pt; }
|
||||
.rating-score { font-size: 7pt !important;
|
||||
color: #64748b !important; }
|
||||
|
||||
/* Images: show inline, constrained */
|
||||
.fg-cell img { max-width: 2.8in !important;
|
||||
max-height: 1.8in !important;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
margin-top: 3pt; }
|
||||
|
||||
/* Photo/signature view buttons — replace with static label */
|
||||
.btn-view-media { display: none !important; }
|
||||
|
||||
/* ── 9. Issues table (matches PDF dark header, alternating rows) ── */
|
||||
.tbl-view { width: 100%; border-collapse: collapse;
|
||||
font-size: 7.5pt; margin-top: 4pt; }
|
||||
.tbl-view thead th { background: #1a1d23 !important;
|
||||
color: #fff !important;
|
||||
font-weight: 700;
|
||||
padding: 3pt 5pt !important;
|
||||
border: 0.25pt solid #e2e8f0 !important;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact; }
|
||||
.tbl-view tbody tr:nth-child(even) td
|
||||
{ background: #f1f5f9 !important;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact; }
|
||||
.tbl-view td { padding: 3pt 5pt !important;
|
||||
border: 0.25pt solid #e2e8f0 !important;
|
||||
vertical-align: top; }
|
||||
/* Severity badge colours in issues table */
|
||||
.badge.bg-danger { color: #dc2626 !important;
|
||||
background: none !important;
|
||||
font-weight: 700; }
|
||||
.badge.bg-warning { color: #d97706 !important;
|
||||
background: none !important;
|
||||
font-weight: 700; }
|
||||
.badge.bg-secondary { color: #64748b !important;
|
||||
background: none !important; }
|
||||
.badge.bg-success { color: #16a34a !important;
|
||||
background: none !important;
|
||||
font-weight: 700; }
|
||||
|
||||
/* ── 10. Notes block (matches PDF #fffbeb background) ── */
|
||||
[style*="white-space:pre-wrap"]
|
||||
{ white-space: pre-wrap !important;
|
||||
background: #fffbeb !important;
|
||||
border: 0.5pt solid #e2e8f0 !important;
|
||||
padding: 5pt 7pt !important;
|
||||
font-size: 8.5pt !important;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact; }
|
||||
|
||||
/* ── 11. Print sign-off block ── */
|
||||
.print-signoff { display: block !important;
|
||||
margin-top: 24pt;
|
||||
page-break-inside: avoid; }
|
||||
|
||||
/* ── 12. Page-break hints ── */
|
||||
.insp-header { page-break-after: avoid; }
|
||||
.section-head { page-break-after: avoid; }
|
||||
hr { border-color: #e2e8f0 !important; }
|
||||
}
|
||||
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="insp-wrap mt-3">
|
||||
|
||||
{# Action bar #}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<a id="backToInspectionsBtn" href="{{ url_for('inspections.index') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Inspections
|
||||
</a>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('inspections.export_pdf', inspection_id=inspection.id) }}"
|
||||
class="btn btn-sm btn-danger">
|
||||
<i class="bi bi-file-earmark-pdf"></i> Export PDF
|
||||
</a>
|
||||
<button onclick="window.print()" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-printer"></i> Print
|
||||
</button>
|
||||
{% if current_user.role not in ['customer'] %}
|
||||
<a href="{{ url_for('inspections.reinspect', inspection_id=inspection.id) }}"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
title="Start a follow-up re-inspection with the same template and facility">
|
||||
<i class="bi bi-arrow-repeat"></i> Re-inspect
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if current_user.role in ['admin','director'] %}
|
||||
{% if not inspection.follow_up_required %}
|
||||
<button type="button" class="btn btn-sm btn-outline-warning"
|
||||
data-bs-toggle="modal" data-bs-target="#followupModal"
|
||||
title="Flag this inspection as requiring a follow-up">
|
||||
<i class="bi bi-flag"></i> Flag Follow-up
|
||||
</button>
|
||||
{% else %}
|
||||
<form method="post"
|
||||
action="{{ url_for('inspections.clear_followup', inspection_id=inspection.id) }}"
|
||||
class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-sm btn-warning">
|
||||
<i class="bi bi-flag-fill"></i> Clear Follow-up
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('inspections.delete', inspection_id=inspection.id) }}"
|
||||
onsubmit="return confirm('Delete this inspection permanently?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash3"></i> Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Follow-up required alert ── #}
|
||||
{% if inspection.follow_up_required %}
|
||||
<div class="alert alert-warning d-flex align-items-start gap-2 mb-3">
|
||||
<i class="bi bi-flag-fill mt-1"></i>
|
||||
<div>
|
||||
<strong>Follow-up Inspection Required</strong>
|
||||
{% if inspection.follow_up_note %}<br><span class="small">{{ inspection.follow_up_note }}</span>{% endif %}
|
||||
<div class="mt-2">
|
||||
<a href="{{ url_for('inspections.reinspect', inspection_id=inspection.id) }}"
|
||||
class="btn btn-sm btn-warning">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Start Re-inspection
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Parent/child inspection links ── #}
|
||||
{% if inspection.parent %}
|
||||
<div class="alert alert-info small mb-3">
|
||||
<i class="bi bi-arrow-up-circle me-1"></i>
|
||||
This is a re-inspection of
|
||||
<a href="{{ url_for('inspections.view', inspection_id=inspection.parent.id) }}"
|
||||
class="alert-link">Inspection #{{ inspection.parent.id }}</a>
|
||||
({{ inspection.parent.inspection_date.strftime('%Y-%m-%d') }},
|
||||
score: {{ inspection.parent.overall_score|round(1) if inspection.parent.overall_score else 'N/A' }}%).
|
||||
</div>
|
||||
{% endif %}
|
||||
{% set followups = inspection.follow_ups.all() %}
|
||||
{% if followups %}
|
||||
<div class="alert alert-secondary small mb-3">
|
||||
<i class="bi bi-arrow-down-circle me-1"></i>
|
||||
Follow-up inspection(s):
|
||||
{% for fu in followups %}
|
||||
<a href="{{ url_for('inspections.view', inspection_id=fu.id) }}" class="alert-link">
|
||||
#{{ fu.id }} ({{ fu.inspection_date.strftime('%Y-%m-%d') }},
|
||||
score: {{ fu.overall_score|round(1) if fu.overall_score else 'N/A' }}%)
|
||||
</a>{% if not loop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Score comparison card (re-inspections only) ── #}
|
||||
{% if comparison %}
|
||||
<div class="card shadow-sm mb-3 border-0">
|
||||
<div class="card-header d-flex justify-content-between align-items-center
|
||||
bg-{{ 'success' if comparison.score_delta and comparison.score_delta > 0
|
||||
else 'danger' if comparison.score_delta and comparison.score_delta < 0
|
||||
else 'secondary' }} text-white">
|
||||
<span class="fw-semibold">
|
||||
<i class="bi bi-arrow-left-right me-1"></i>
|
||||
Score Comparison vs. Inspection #{{ comparison.parent_id }}
|
||||
<span class="small opacity-75 ms-2">
|
||||
{{ comparison.parent_date.strftime('%Y-%m-%d') }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="d-flex gap-3 align-items-center">
|
||||
{# Overall delta badge #}
|
||||
{% if comparison.score_delta is not none %}
|
||||
{% if comparison.score_delta > 0 %}
|
||||
<span class="badge bg-white text-success fw-bold fs-6">
|
||||
<i class="bi bi-arrow-up-short"></i>+{{ comparison.score_delta }}%
|
||||
</span>
|
||||
{% elif comparison.score_delta < 0 %}
|
||||
<span class="badge bg-white text-danger fw-bold fs-6">
|
||||
<i class="bi bi-arrow-down-short"></i>{{ comparison.score_delta }}%
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-white text-secondary fw-bold fs-6">No change</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{# Score pills #}
|
||||
<span class="small opacity-75">
|
||||
{{ comparison.parent_score|round(1) if comparison.parent_score else '—' }}%
|
||||
→
|
||||
{{ comparison.current_score|round(1) if comparison.current_score else '—' }}%
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Header #}
|
||||
<div class="insp-header">
|
||||
<div>
|
||||
<h4><i class="bi bi-clipboard-check"></i> {{ inspection.template.name }}</h4>
|
||||
<div class="sub">
|
||||
{{ inspection.facility.name }}{% if inspection.area %} · {{ inspection.area.name }}{% endif %}
|
||||
· Inspector: <strong style="color:#e2e8f0;">{{ inspection.inspector.display_name }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge bg-{{ 'success' if inspection.status == 'completed' else 'danger' if inspection.status == 'flagged' else 'secondary' }} fs-6">
|
||||
{{ inspection.status|replace('_',' ')|title }}
|
||||
</span>
|
||||
{% if inspection.overall_score is not none %}
|
||||
<div class="score-badge bg-{{ 'success' if inspection.overall_score >= 90 else 'warning' if inspection.overall_score >= 70 else 'danger' }} text-white">
|
||||
{{ inspection.overall_score }}%
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="insp-body">
|
||||
|
||||
{# Meta row #}
|
||||
<div class="meta-row">
|
||||
<div class="meta-item">
|
||||
<span class="lbl">Start Date</span>
|
||||
<span class="val">{{ inspection.inspection_date.strftime('%B %d, %Y %H:%M') }}</span>
|
||||
</div>
|
||||
{% if inspection.completed_at %}
|
||||
<div class="meta-item">
|
||||
<span class="lbl">Completed</span>
|
||||
<span class="val">{{ inspection.completed_at.strftime('%B %d, %Y %H:%M') }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="meta-item">
|
||||
<span class="lbl">Template</span>
|
||||
<span class="val">{{ inspection.template.name }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="lbl">Frequency</span>
|
||||
<span class="val">{{ inspection.template.frequency|title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Submission GPS (admin / director only) ──────────────────────────── #}
|
||||
{% if current_user.role in ['admin', 'director'] and inspection.submit_latitude and inspection.submit_longitude %}
|
||||
{% set _lat = inspection.submit_latitude | float %}
|
||||
{% set _lng = inspection.submit_longitude | float %}
|
||||
<div class="mb-4">
|
||||
<div class="lbl mb-1" style="font-size:.72rem;color:#94a3b8;font-weight:500;text-transform:uppercase;letter-spacing:.04em;">
|
||||
<i class="bi bi-geo-alt-fill text-danger me-1"></i>Submission Location
|
||||
</div>
|
||||
<div style="border-radius:10px;overflow:hidden;border:1px solid #e2e8f0;max-width:420px;">
|
||||
<iframe
|
||||
src="https://maps.google.com/maps?q={{ _lat }},{{ _lng }}&z=15&output=embed"
|
||||
width="420" height="220"
|
||||
style="border:0;display:block;width:100%;"
|
||||
allowfullscreen="" loading="lazy"
|
||||
referrerpolicy="no-referrer-when-downgrade">
|
||||
</iframe>
|
||||
</div>
|
||||
<div class="mt-1" style="font-size:.8rem;color:#64748b;">
|
||||
{{ '%.6f' | format(_lat) }}, {{ '%.6f' | format(_lng) }}
|
||||
<a href="https://www.google.com/maps?q={{ _lat }},{{ _lng }}" target="_blank"
|
||||
class="ms-2 text-decoration-none small">
|
||||
<i class="bi bi-box-arrow-up-right"></i> Open in Maps
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Build the filtered field set ──────────────────────────────────────
|
||||
Strategy:
|
||||
1. Find all grid rows that contain at least one rated rating field.
|
||||
2. Collect every field whose grid row overlaps a rated row.
|
||||
3. Also collect section fields that immediately precede a rated group.
|
||||
4. Re-number rows sequentially (1-based) so there are no gaps.
|
||||
#}
|
||||
{% if form_fields %}
|
||||
|
||||
{# Pass 1: find all rated rows AND which label IDs have a rated field after them #}
|
||||
{% set ns = namespace(rated_rows=[], visible_label_ids=[]) %}
|
||||
|
||||
{# 1a: collect rows that have any answered/filled field (not just ratings) #}
|
||||
{% set _skip_types = ['label', 'section', 'button_submit', 'button_print', 'button_email'] %}
|
||||
{% for field in form_fields %}
|
||||
{% if field.type not in _skip_types %}
|
||||
{% set fval = form_data.get(field.id | string, '') %}
|
||||
{# A row is "visible" if: rating has score>0, OR any other field has a non-empty value #}
|
||||
{% if field.type == 'rating' %}
|
||||
{% set score = fval | int %}
|
||||
{% if score > 0 %}
|
||||
{% for r in range(field.row, field.row + field.rowSpan) %}
|
||||
{% if r not in ns.rated_rows %}{% set ns.rated_rows = ns.rated_rows + [r] %}{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% elif fval and fval != '' and fval != [] %}
|
||||
{% for r in range(field.row, field.row + field.rowSpan) %}
|
||||
{% if r not in ns.rated_rows %}{% set ns.rated_rows = ns.rated_rows + [r] %}{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{# 1b: walk fields in order; accumulate label IDs.
|
||||
When we hit any answered field (rating > 0, pass_fail answered, text filled, etc.),
|
||||
mark ALL accumulated label IDs as visible and clear the buffer. #}
|
||||
{% set lbuf = namespace(ids=[]) %}
|
||||
{% for field in form_fields %}
|
||||
{% if field.type == 'label' %}
|
||||
{% set lbuf.ids = lbuf.ids + [field.id] %}
|
||||
{% elif field.type not in _skip_types %}
|
||||
{% set fval = form_data.get(field.id | string, '') %}
|
||||
{% set answered = namespace(v=false) %}
|
||||
{% if field.type == 'rating' %}
|
||||
{% if fval | int > 0 %}{% set answered.v = true %}{% endif %}
|
||||
{% elif fval and fval != '' and fval != [] %}
|
||||
{% set answered.v = true %}
|
||||
{% endif %}
|
||||
{% if answered.v %}
|
||||
{% for lid in lbuf.ids %}
|
||||
{% if lid not in ns.visible_label_ids %}
|
||||
{% set ns.visible_label_ids = ns.visible_label_ids + [lid] %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% set lbuf.ids = [] %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{# Pass 2: render — labels only if in visible_label_ids,
|
||||
data fields only if row overlaps rated_rows, sections always buffered. #}
|
||||
{% set remap = namespace(out_row=1, last_orig_row=-1, pending_sec=none) %}
|
||||
|
||||
<div class="form-grid">
|
||||
|
||||
{% for field in form_fields %}
|
||||
{% set ftype = field.type %}
|
||||
|
||||
{# Track pending section #}
|
||||
{% if ftype == 'section' %}
|
||||
{% set remap.pending_sec = field %}
|
||||
|
||||
{% elif ftype in ('button_submit', 'button_print', 'button_email') %}
|
||||
{# skip — action buttons not shown in read-only view #}
|
||||
|
||||
{% elif ftype == 'label' %}
|
||||
{# Only show labels that are ancestors of rated fields #}
|
||||
{% if field.id in ns.visible_label_ids %}
|
||||
{# Advance row only when original row actually changes #}
|
||||
{% if field.row != remap.last_orig_row %}
|
||||
{% if remap.last_orig_row != -1 %}{% set remap.out_row = remap.out_row + 1 %}{% endif %}
|
||||
{# Flush pending section before the first field on this new row #}
|
||||
{% if remap.pending_sec is not none %}
|
||||
<div class="fg-cell" style="grid-column:1 / span 12; grid-row:{{ remap.out_row }} / span 1;">
|
||||
<div class="section-head"><strong>{{ remap.pending_sec.label }}</strong></div>
|
||||
</div>
|
||||
{% set remap.out_row = remap.out_row + 1 %}
|
||||
{% set remap.pending_sec = none %}
|
||||
{% endif %}
|
||||
{% set remap.last_orig_row = field.row %}
|
||||
{% endif %}
|
||||
{% set fs_map = {'small':'0.72rem','normal':'0.82rem','large':'0.96rem','x-large':'1.1rem'} %}
|
||||
<div class="fg-cell"
|
||||
style="grid-column: {{ field.col }} / span {{ field.colSpan }};
|
||||
grid-row: {{ remap.out_row }} / span 1;">
|
||||
<div style="font-size:{{ fs_map.get(field.font_size or 'normal','0.82rem') }};
|
||||
font-weight:{{ field.font_weight or 'normal' }};
|
||||
color:#374151; overflow:hidden; display:flex; align-items:center; height:100%;">
|
||||
{{ field.text_content or '' }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
{# Check if this field overlaps any rated row #}
|
||||
{% set vis = namespace(show=false) %}
|
||||
{% for r in range(field.row, field.row + field.rowSpan) %}
|
||||
{% if r in ns.rated_rows %}{% set vis.show = true %}{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if vis.show %}
|
||||
{# Advance row only when original row actually changes.
|
||||
Section flush is also guarded here so a label already placed on
|
||||
this row at the same out_row cannot be displaced. #}
|
||||
{% if field.row != remap.last_orig_row %}
|
||||
{% if remap.last_orig_row != -1 %}{% set remap.out_row = remap.out_row + 1 %}{% endif %}
|
||||
{% if remap.pending_sec is not none %}
|
||||
<div class="fg-cell" style="grid-column:1 / span 12; grid-row:{{ remap.out_row }} / span 1;">
|
||||
<div class="section-head"><strong>{{ remap.pending_sec.label }}</strong></div>
|
||||
</div>
|
||||
{% set remap.out_row = remap.out_row + 1 %}
|
||||
{% set remap.pending_sec = none %}
|
||||
{% endif %}
|
||||
{% set remap.last_orig_row = field.row %}
|
||||
{% endif %}
|
||||
|
||||
{# Render the field at its original col/colSpan but remapped row #}
|
||||
<div class="fg-cell"
|
||||
style="grid-column: {{ field.col }} / span {{ field.colSpan }};
|
||||
grid-row: {{ remap.out_row }} / span 1;">
|
||||
|
||||
{% set fid = field.id | string %}
|
||||
{% set val = form_data.get(fid, '') %}
|
||||
|
||||
{% if ftype == 'rating' %}
|
||||
<span class="field-lbl">{{ field.label }}</span>
|
||||
<div class="rating-line">
|
||||
{% set score = val | int %}
|
||||
{% if score > 0 %}
|
||||
<span class="rating-stars-ro">{% for i in range(1,6) %}{{ '★' if i <= score else '☆' }}{% endfor %}</span>
|
||||
<span class="rating-score">{{ score }}/5</span>
|
||||
{% else %}
|
||||
<span style="font-size:.72rem;color:#94a3b8;font-style:italic;">Not rated</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% elif ftype == 'image' %}
|
||||
<span class="field-lbl">{{ field.label }}</span>
|
||||
<div style="display:flex;align-items:center;flex:1;min-height:0;">
|
||||
{% if val %}
|
||||
<button type="button" class="btn-view-media"
|
||||
onclick="openMedia('{{ url_for('static', filename=val) }}','{{ field.label | e }}')">
|
||||
<i class="bi bi-image"></i> View Photo
|
||||
</button>
|
||||
{% else %}
|
||||
<span style="font-size:.72rem;color:#94a3b8;font-style:italic;">No photo</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% elif ftype == 'signature' %}
|
||||
<span class="field-lbl">{{ field.label }}</span>
|
||||
<div style="display:flex;align-items:center;flex:1;min-height:0;">
|
||||
{% if val and val.startswith('data:') %}
|
||||
<button type="button" class="btn-view-media"
|
||||
onclick="openMedia('{{ val }}','{{ field.label | e }}')">
|
||||
<i class="bi bi-pen"></i> View Signature
|
||||
</button>
|
||||
{% else %}
|
||||
<span style="font-size:.72rem;color:#94a3b8;font-style:italic;">No signature</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% elif ftype == 'pass_fail' %}
|
||||
<span class="field-lbl">{{ field.label }}</span>
|
||||
<div class="field-val">
|
||||
{% if val and val.lower() in ('pass','yes','ok','good','acceptable','compliant') %}
|
||||
<span class="text-success"><i class="bi bi-check-circle-fill"></i> {{ val }}</span>
|
||||
{% elif val %}
|
||||
<span class="text-danger"><i class="bi bi-x-circle-fill"></i> {{ val }}</span>
|
||||
{% else %}
|
||||
<span style="font-size:.72rem;color:#94a3b8;font-style:italic;">Not answered</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% elif ftype == 'checkbox' %}
|
||||
<span class="field-lbl">{{ field.label }}</span>
|
||||
<div class="field-val">
|
||||
{% if val == 'yes' %}<span class="text-success"><i class="bi bi-check-circle-fill"></i> Yes</span>
|
||||
{% else %}<span class="text-muted"><i class="bi bi-circle"></i> No</span>{% endif %}
|
||||
</div>
|
||||
|
||||
{% elif ftype == 'checkbox_group' %}
|
||||
<span class="field-lbl">{{ field.label }}</span>
|
||||
<div class="field-val" style="white-space:normal;overflow:auto;">
|
||||
{% if val and val is iterable and val is not string %}
|
||||
{% for item in val %}<span class="badge bg-primary me-1" style="font-size:.62rem;">{{ item }}</span>{% endfor %}
|
||||
{% else %}<span class="empty">None</span>{% endif %}
|
||||
</div>
|
||||
|
||||
{% elif ftype == 'table' %}
|
||||
<span class="field-lbl">{{ field.label }}</span>
|
||||
<div style="overflow:auto;flex:1;min-height:0;">
|
||||
{% if val and val is iterable and val is not string %}
|
||||
<table class="tbl-view">
|
||||
<thead><tr>{% for hdr in (field.col_headers or ['Col']) %}<th>{{ hdr }}</th>{% endfor %}</tr></thead>
|
||||
<tbody>{% for row in val %}<tr>{% for hdr in (field.col_headers or ['Col']) %}<td>{{ row.get(hdr,'') }}</td>{% endfor %}</tr>{% endfor %}</tbody>
|
||||
</table>
|
||||
{% else %}<span style="font-size:.72rem;color:#94a3b8;">No data</span>{% endif %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
{# text, number, date, email, radio, select, textarea #}
|
||||
<span class="field-lbl">{{ field.label }}</span>
|
||||
<div class="field-val {{ 'multiline' if ftype == 'textarea' }} {{ 'empty' if not val }}">{{ val or '—' }}</div>
|
||||
{% endif %}
|
||||
|
||||
</div>{# /fg-cell #}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
</div>{# /form-grid #}
|
||||
|
||||
{% if not ns.rated_rows %}
|
||||
<p class="text-muted">No rated fields in this inspection.</p>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<p class="text-muted">No form fields found for this template.</p>
|
||||
{% endif %}
|
||||
|
||||
{# Issues #}
|
||||
{% if issues %}
|
||||
<hr class="mt-4">
|
||||
<h6 class="text-danger"><i class="bi bi-exclamation-triangle"></i> Issues Logged ({{ issues|length }})</h6>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead class="table-light">
|
||||
<tr><th>Severity</th><th>Area</th><th>Description</th><th>Status</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for issue in issues %}
|
||||
<tr>
|
||||
<td><span class="badge bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning text-dark' if issue.severity == 'medium' else 'secondary' }}">{{ issue.severity|title }}</span></td>
|
||||
<td>{{ issue.area.name }}</td>
|
||||
<td>{{ issue.description[:80] }}{% if issue.description|length > 80 %}…{% endif %}</td>
|
||||
<td><span class="badge bg-{{ 'success' if issue.status == 'resolved' else 'warning text-dark' if issue.status == 'in_progress' else 'secondary' }}">{{ issue.status|replace('_',' ')|title }}</span></td>
|
||||
<td><a href="{{ url_for('issues.view', issue_id=issue.id) }}" class="btn btn-sm btn-outline-secondary">View</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Print-only sign-off block (hidden on screen, visible @media print) ── #}
|
||||
<div class="print-signoff" style="display:none; font-family:Helvetica,Arial,sans-serif;">
|
||||
|
||||
<table style="width:100%; border-collapse:collapse; font-size:8pt; color:#64748b;
|
||||
margin-top:10pt;">
|
||||
<tr>
|
||||
<td colspan="6" style="font-size:9.5pt; font-weight:700; color:#1a1d23;
|
||||
padding-bottom:6pt; border-bottom:0.5pt solid #e2e8f0;">
|
||||
Sign-off
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td colspan="6" style="height:18pt;"></td></tr>
|
||||
|
||||
{# Inspector row #}
|
||||
<tr>
|
||||
<td style="width:18%; border-top:0.75pt solid #1a1d23; padding-top:3pt;
|
||||
font-size:7.5pt; color:#64748b;">Inspector Signature</td>
|
||||
<td style="width:4%;"></td>
|
||||
<td style="width:38%; border-top:0.75pt solid #1a1d23; padding-top:3pt;
|
||||
font-size:7.5pt; color:#64748b;">Printed Name</td>
|
||||
<td style="width:4%;"></td>
|
||||
<td style="width:32%; border-top:0.75pt solid #1a1d23; padding-top:3pt;
|
||||
font-size:7.5pt; color:#64748b;">Date</td>
|
||||
<td style="width:4%;"></td>
|
||||
</tr>
|
||||
<tr><td colspan="6" style="height:22pt;"></td></tr>
|
||||
|
||||
{# Supervisor row #}
|
||||
<tr>
|
||||
<td style="border-top:0.75pt solid #1a1d23; padding-top:3pt;
|
||||
font-size:7.5pt; color:#64748b;">Supervisor Signature</td>
|
||||
<td></td>
|
||||
<td style="border-top:0.75pt solid #1a1d23; padding-top:3pt;
|
||||
font-size:7.5pt; color:#64748b;">Printed Name</td>
|
||||
<td></td>
|
||||
<td style="border-top:0.75pt solid #1a1d23; padding-top:3pt;
|
||||
font-size:7.5pt; color:#64748b;">Date</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
{# Media lightbox #}
|
||||
<div class="media-backdrop" id="mediaModal" onclick="if(event.target.id==='mediaModal')closeMedia()">
|
||||
<div class="media-modal">
|
||||
<div class="media-modal-head">
|
||||
<span id="mediaLabel">Photo</span>
|
||||
<button class="media-modal-close" onclick="closeMedia()">×</button>
|
||||
</div>
|
||||
<img id="mediaImg" src="" alt="">
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
(function () {
|
||||
var backUrl = sessionStorage.getItem('insp_list_back_url');
|
||||
if (backUrl) {
|
||||
var btn = document.getElementById('backToInspectionsBtn');
|
||||
if (btn) btn.href = backUrl;
|
||||
}
|
||||
}());
|
||||
</script>
|
||||
|
||||
<script>
|
||||
function openMedia(src, label) {
|
||||
document.getElementById('mediaImg').src = src;
|
||||
document.getElementById('mediaLabel').textContent = label;
|
||||
document.getElementById('mediaModal').classList.add('open');
|
||||
}
|
||||
function closeMedia() {
|
||||
document.getElementById('mediaModal').classList.remove('open');
|
||||
document.getElementById('mediaImg').src = '';
|
||||
}
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeMedia(); });
|
||||
</script>
|
||||
|
||||
{# ── Flag follow-up modal ── #}
|
||||
<div class="modal fade" id="followupModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form method="POST" action="{{ url_for('inspections.flag_followup', inspection_id=inspection.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-flag me-2"></i>Flag Follow-up Required</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="form-label fw-semibold">Reason / Notes <span class="text-muted small">(optional)</span></label>
|
||||
<textarea name="follow_up_note" class="form-control" rows="3"
|
||||
placeholder="Describe what needs to be addressed in the follow-up inspection…"></textarea>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-warning">
|
||||
<i class="bi bi-flag me-1"></i>Flag Follow-up
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,114 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h5 class="mb-0"><i class="bi bi-exclamation-triangle"></i> {{ title }}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
{# Contract selector — UI only; narrows the facility list via AJAX #}
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" for="contract_select">Contract</label>
|
||||
<select id="contract_select" class="form-select">
|
||||
<option value="">— Select Contract —</option>
|
||||
{% for p in projects %}
|
||||
<option value="{{ p.id }}">{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# Facility — populated by JS once a contract is chosen #}
|
||||
<div class="mb-3">
|
||||
{{ form.facility_id.label(class="form-label fw-semibold") }}
|
||||
{{ form.facility_id(class="form-select", id="facility_id") }}
|
||||
{% for e in form.facility_id.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Remaining fields — assigned_to hidden from customer role #}
|
||||
{% for field in [form.severity, form.description, form.photo] %}
|
||||
<div class="mb-3">
|
||||
{{ field.label(class="form-label fw-semibold") }}
|
||||
{{ field(class="form-select" if field.type == 'SelectField' else "form-control", rows=4 if field.type == 'TextAreaField' else none) }}
|
||||
{% for e in field.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if current_user.role != 'customer' %}
|
||||
<div class="mb-3">
|
||||
{{ form.assigned_to.label(class="form-label fw-semibold") }}
|
||||
{{ form.assigned_to(class="form-select") }}
|
||||
{% for e in form.assigned_to.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-danger">Log Issue</button>
|
||||
<a href="{{ url_for('issues.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var contractSel = document.getElementById('contract_select');
|
||||
var facilitySel = document.getElementById('facility_id');
|
||||
var FACILITIES_URL = '{{ url_for("inspections.facilities_for_project", project_id=0) }}'.replace('/0', '/');
|
||||
|
||||
// Values injected from the route (non-null only after a POST validation error)
|
||||
var preProjectId = {{ selected_project_id | tojson }};
|
||||
var preFacilityId = {{ form.facility_id.data | tojson }};
|
||||
|
||||
function setPlaceholder() {
|
||||
facilitySel.innerHTML = '<option value="">— Select a Contract first —</option>';
|
||||
facilitySel.disabled = true;
|
||||
}
|
||||
|
||||
function loadFacilities(projectId, restoreFacilityId) {
|
||||
facilitySel.disabled = true;
|
||||
facilitySel.innerHTML = '<option value="">Loading…</option>';
|
||||
fetch(FACILITIES_URL + projectId)
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
facilitySel.innerHTML = '<option value="">— Select Facility —</option>';
|
||||
data.forEach(function (f) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = f.id;
|
||||
opt.textContent = f.name;
|
||||
if (restoreFacilityId && f.id === restoreFacilityId) { opt.selected = true; }
|
||||
facilitySel.appendChild(opt);
|
||||
});
|
||||
facilitySel.disabled = false;
|
||||
})
|
||||
.catch(function () {
|
||||
facilitySel.innerHTML = '<option value="">Could not load facilities</option>';
|
||||
});
|
||||
}
|
||||
|
||||
contractSel.addEventListener('change', function () {
|
||||
if (this.value) {
|
||||
loadFacilities(this.value, null);
|
||||
} else {
|
||||
setPlaceholder();
|
||||
}
|
||||
});
|
||||
|
||||
// Restore state after a POST validation error
|
||||
if (preProjectId) {
|
||||
contractSel.value = String(preProjectId);
|
||||
loadFacilities(preProjectId, preFacilityId);
|
||||
} else {
|
||||
setPlaceholder();
|
||||
}
|
||||
}());
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,325 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Issues{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-exclamation-triangle"></i> Issues</h2>
|
||||
{% if current_user.role in ['admin','director','customer'] %}
|
||||
<a href="{{ url_for('issues.create') }}" class="btn btn-danger">
|
||||
<i class="bi bi-plus-circle"></i> Log Issue
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body py-2">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small mb-1">Issue #</label>
|
||||
<input type="number" name="issue_id" class="form-control form-control-sm"
|
||||
min="1" placeholder="ID" value="{{ issue_id_filter }}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1">Severity</label>
|
||||
<select name="severity" class="form-select form-select-sm">
|
||||
<option value="">All</option>
|
||||
{% for s in ['critical','high','medium','low'] %}
|
||||
<option value="{{ s }}" {{ 'selected' if severity_filter == s }}>{{ s|title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1">Status</label>
|
||||
<select name="status" class="form-select form-select-sm">
|
||||
<option value="">All</option>
|
||||
{% for s in ['open','in_progress','pending_verification','resolved'] %}
|
||||
<option value="{{ s }}" {{ 'selected' if status_filter == s }}>{{ s|replace('_',' ')|title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1">SLA</label>
|
||||
<select name="sla" class="form-select form-select-sm">
|
||||
<option value="">All</option>
|
||||
<option value="breached" {{ 'selected' if sla_filter == 'breached' }}>Breached</option>
|
||||
<option value="at_risk" {{ 'selected' if sla_filter == 'at_risk' }}>At Risk</option>
|
||||
<option value="ok" {{ 'selected' if sla_filter == 'ok' }}>OK</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1">Contract</label>
|
||||
<select name="contract_id" id="filter_contract_id" class="form-select form-select-sm">
|
||||
<option value="">All Contracts</option>
|
||||
{% for p in projects %}
|
||||
<option value="{{ p.id }}" {{ 'selected' if contract_filter == p.id|string }}>{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1">Facility</label>
|
||||
<select name="facility_id" id="filter_facility_id" class="form-select form-select-sm">
|
||||
<option value="">All Facilities</option>
|
||||
{% for f in facilities %}
|
||||
<option value="{{ f.id }}" {{ 'selected' if facility_filter == f.id|string }}>{{ f.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1">Reported From</label>
|
||||
<input type="date" name="date_from" class="form-control form-control-sm"
|
||||
value="{{ date_from_filter }}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1">Reported To</label>
|
||||
<input type="date" name="date_to" class="form-control form-control-sm"
|
||||
value="{{ date_to_filter }}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1">Reporter</label>
|
||||
<select name="reporter_id" class="form-select form-select-sm">
|
||||
<option value="">All Reporters</option>
|
||||
{% for u in reporters %}
|
||||
<option value="{{ u.id }}" {{ 'selected' if reporter_filter == u.id|string }}>{{ u.display_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto d-flex align-items-end gap-2 flex-wrap">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">Filter</button>
|
||||
<a href="{{ url_for('issues.index') }}" class="btn btn-sm btn-outline-secondary">Clear</a>
|
||||
<a href="{{ url_for('issues.export_list_pdf', **request.args) }}"
|
||||
class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi bi-file-earmark-pdf"></i> Export PDF
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
{% if issues.items %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Reported</th>
|
||||
<th>Severity</th>
|
||||
<th>Contract</th>
|
||||
<th>Facility / Area</th>
|
||||
<th>Description</th>
|
||||
<th>Status</th>
|
||||
<th>SLA</th>
|
||||
<th>Reporter</th>
|
||||
<th>Assigned</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for issue in issues.items %}
|
||||
{% set is_following = issue.id in followed_ids %}
|
||||
{% set sla = sla_status(issue) %}
|
||||
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
|
||||
<td><small class="text-muted">#{{ issue.id }}</small></td>
|
||||
<td><small>{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</small></td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning text-dark' if issue.severity == 'medium' else 'secondary' }}">
|
||||
{{ issue.severity|title }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% set _c = issue.resolved_facility.project if issue.resolved_facility else none %}
|
||||
<small>{{ _c.name if _c else '—' }}</small>
|
||||
</td>
|
||||
<td>
|
||||
{{ issue.resolved_facility.name if issue.resolved_facility else '—' }}<br>
|
||||
<small class="text-muted">{{ issue.area.name if issue.area else '—' }}</small>
|
||||
</td>
|
||||
<td>{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'success' if issue.status == 'resolved' else 'info text-dark' if issue.status == 'pending_verification' else 'warning text-dark' if issue.status == 'in_progress' else 'danger' }}">
|
||||
{{ issue.status|replace('_',' ')|title }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if sla == 'breached' %}
|
||||
<span class="badge bg-danger" title="SLA deadline has passed"><i class="bi bi-alarm me-1"></i>Breached</span>
|
||||
{% elif sla == 'at_risk' %}
|
||||
{% set hrs = sla_hours_remaining(issue) %}
|
||||
<span class="badge bg-warning text-dark" title="Over 75% of SLA window elapsed"><i class="bi bi-hourglass-split me-1"></i>{{ hrs|abs|round(1) }}h left</span>
|
||||
{% elif sla == 'ok' %}
|
||||
<span class="badge bg-secondary">OK</span>
|
||||
{% else %}
|
||||
<span class="text-muted small">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if issue.reporter %}
|
||||
<small>{{ issue.reporter.display_name }}</small>
|
||||
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if current_user.role in ['admin', 'director'] and issue.status != 'resolved' %}
|
||||
<div class="d-flex align-items-center gap-1 quick-assign-wrap" data-issue-id="{{ issue.id }}">
|
||||
<select class="form-select form-select-sm quick-assign-select" style="min-width:110px;font-size:.78rem;">
|
||||
<option value="">— Unassigned —</option>
|
||||
{% for u in staff %}
|
||||
<option value="{{ u.id }}" {{ 'selected' if issue.assigned_to == u.id }}>{{ u.display_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span class="quick-assign-spinner spinner-border spinner-border-sm text-secondary d-none" role="status"></span>
|
||||
</div>
|
||||
{% else %}
|
||||
{% if issue.assigned_user %}{{ issue.assigned_user.display_name }}
|
||||
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{# Following badge + inline unfollow #}
|
||||
{% if is_following %}
|
||||
<span class="badge bg-primary me-1" title="You are following this issue">
|
||||
<i class="bi bi-bell-fill"></i> Following
|
||||
</span>
|
||||
<form method="post"
|
||||
action="{{ url_for('issues.unfollow', issue_id=issue.id) }}"
|
||||
class="d-inline"
|
||||
title="Unfollow this issue">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="next" value="{{ url_for('issues.index', page=issues.page, issue_id=issue_id_filter, severity=severity_filter, status=status_filter, contract_id=contract_filter, facility_id=facility_filter, date_from=date_from_filter, date_to=date_to_filter, reporter_id=reporter_filter) }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary p-0 px-1 me-1"
|
||||
title="Unfollow">
|
||||
<i class="bi bi-bell-slash" style="font-size:.75rem;"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ url_for('issues.view', issue_id=issue.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
{% if current_user.role in ['admin','director'] or issue.assigned_to == current_user.id %}
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
{% else %}
|
||||
<i class="bi bi-eye"></i> View
|
||||
{% endif %}
|
||||
</a>
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<form method="POST" action="{{ url_for('issues.delete', issue_id=issue.id) }}"
|
||||
class="d-inline"
|
||||
onsubmit="return confirm('Permanently delete Issue #{{ issue.id }}? This cannot be undone.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger"
|
||||
title="Delete Issue #{{ issue.id }}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if issues.pages > 1 %}
|
||||
<div class="d-flex justify-content-center py-3">
|
||||
<nav><ul class="pagination pagination-sm mb-0">
|
||||
{% for p in issues.iter_pages(left_edge=1,right_edge=1,left_current=2,right_current=2) %}
|
||||
{% if p %}
|
||||
<li class="page-item {{ 'active' if p == issues.page }}">
|
||||
<a class="page-link"
|
||||
href="{{ url_for('issues.index', page=p, issue_id=issue_id_filter, severity=severity_filter, status=status_filter, sla=sla_filter, contract_id=contract_filter, facility_id=facility_filter, date_from=date_from_filter, date_to=date_to_filter, reporter_id=reporter_filter) }}">{{ p }}</a>
|
||||
</li>
|
||||
{% else %}<li class="page-item disabled"><span class="page-link">…</span></li>{% endif %}
|
||||
{% endfor %}
|
||||
</ul></nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="alert alert-info m-3"><i class="bi bi-info-circle"></i> No issues found.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var contractSel = document.getElementById('filter_contract_id');
|
||||
var facilitySel = document.getElementById('filter_facility_id');
|
||||
if (!contractSel || !facilitySel) return;
|
||||
|
||||
var FACILITIES_URL = '{{ url_for("inspections.facilities_for_project", project_id=0) }}'.replace('/0', '/');
|
||||
|
||||
contractSel.addEventListener('change', function () {
|
||||
var projectId = this.value;
|
||||
facilitySel.value = ''; // reset facility selection
|
||||
if (!projectId) {
|
||||
// No contract selected — restore all-facilities placeholder and submit
|
||||
// (server will return unfiltered facility list)
|
||||
facilitySel.innerHTML = '<option value="">All Facilities</option>';
|
||||
return;
|
||||
}
|
||||
facilitySel.disabled = true;
|
||||
facilitySel.innerHTML = '<option value="">Loading…</option>';
|
||||
fetch(FACILITIES_URL + projectId)
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
var html = '<option value="">All Facilities</option>';
|
||||
data.forEach(function (f) {
|
||||
html += '<option value="' + f.id + '">' + f.name + '</option>';
|
||||
});
|
||||
facilitySel.innerHTML = html;
|
||||
facilitySel.disabled = false;
|
||||
})
|
||||
.catch(function () { facilitySel.disabled = false; });
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
document.querySelectorAll('.quick-assign-select').forEach(function (sel) {
|
||||
sel.dataset.previous = sel.value;
|
||||
|
||||
sel.addEventListener('change', function () {
|
||||
const wrap = sel.closest('.quick-assign-wrap');
|
||||
const issueId = wrap.dataset.issueId;
|
||||
const spinner = wrap.querySelector('.quick-assign-spinner');
|
||||
const userId = sel.value || null;
|
||||
|
||||
sel.disabled = true;
|
||||
spinner.classList.remove('d-none');
|
||||
|
||||
fetch('/issues/' + issueId + '/quick-assign', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token() }}',
|
||||
},
|
||||
body: JSON.stringify({ user_id: userId ? parseInt(userId) : null }),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (!data.ok) {
|
||||
alert('Assignment failed: ' + (data.error || 'Unknown error'));
|
||||
sel.value = sel.dataset.previous;
|
||||
} else {
|
||||
sel.dataset.previous = sel.value;
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
alert('Network error — assignment not saved.');
|
||||
sel.value = sel.dataset.previous;
|
||||
})
|
||||
.finally(function () {
|
||||
sel.disabled = false;
|
||||
spinner.classList.add('d-none');
|
||||
});
|
||||
});
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,236 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Verification Queue{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2><i class="bi bi-patch-check text-info me-2"></i>Verification Queue</h2>
|
||||
<p class="text-muted mb-0">
|
||||
Issues awaiting director sign-off before they are fully closed.
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
{% if total_pending > 0 %}
|
||||
<span class="badge bg-info text-dark fs-6">{{ total_pending }} pending</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success fs-6"><i class="bi bi-check-all me-1"></i>All clear</span>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('issues.index') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> All Issues
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Bulk-verify toolbar — shown when at least one issue is pending ── #}
|
||||
{% if grouped %}
|
||||
<form method="POST" action="{{ url_for('issues.bulk_verify') }}" id="bulkVerifyForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{# Issue checkboxes are rendered inside the per-facility tables below;
|
||||
hidden inputs with their IDs are inserted here by JS on submission. #}
|
||||
<div class="card bg-light border-0 mb-3 p-2 d-flex flex-row align-items-center gap-3 flex-wrap" id="bulkToolbar">
|
||||
<div class="form-check mb-0">
|
||||
<input class="form-check-input" type="checkbox" id="selectAllIssues">
|
||||
<label class="form-check-label fw-semibold" for="selectAllIssues">Select all</label>
|
||||
</div>
|
||||
<span class="text-muted small" id="selectedCount">0 selected</span>
|
||||
<button type="submit" class="btn btn-success btn-sm" id="bulkVerifyBtn" disabled
|
||||
onclick="return injectBulkIds(this.form)">
|
||||
<i class="bi bi-patch-check-fill me-1"></i>Verify Selected
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if not grouped %}
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body text-center py-5 text-muted">
|
||||
<i class="bi bi-patch-check fs-1 d-block mb-3 opacity-25"></i>
|
||||
<p class="mb-0 fs-5">No issues are currently awaiting verification.</p>
|
||||
<p class="small mt-1">When inspectors request sign-off, their issues will appear here.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
{# ── Per-facility groups ── #}
|
||||
{% for facility, issues in grouped %}
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center bg-light">
|
||||
<span class="fw-semibold">
|
||||
<i class="bi bi-building me-1 text-muted"></i>{{ facility.name }}
|
||||
</span>
|
||||
<span class="badge bg-info text-dark">
|
||||
{{ issues|length }} issue{{ 's' if issues|length != 1 else '' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light" style="font-size:.82rem;">
|
||||
<tr>
|
||||
<th width="36"><span class="visually-hidden">Select</span></th>
|
||||
<th width="60">ID</th>
|
||||
<th width="90">Severity</th>
|
||||
<th>Area / Description</th>
|
||||
<th>Requested By</th>
|
||||
<th>Reported</th>
|
||||
<th>SLA</th>
|
||||
<th width="220">Verify</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for issue in issues %}
|
||||
{% set sla = sla_status(issue) %}
|
||||
{% set hrs = sla_hours_remaining(issue) %}
|
||||
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
|
||||
|
||||
{# Bulk-select checkbox #}
|
||||
<td class="align-middle text-center">
|
||||
<input class="form-check-input issue-checkbox" type="checkbox"
|
||||
value="{{ issue.id }}" aria-label="Select issue #{{ issue.id }}">
|
||||
</td>
|
||||
|
||||
{# ID #}
|
||||
<td class="text-muted small align-middle">
|
||||
<a href="{{ url_for('issues.view', issue_id=issue.id) }}"
|
||||
class="text-decoration-none fw-semibold">#{{ issue.id }}</a>
|
||||
</td>
|
||||
|
||||
{# Severity #}
|
||||
<td class="align-middle">
|
||||
<span class="badge bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning text-dark' if issue.severity == 'medium' else 'secondary' }}">
|
||||
{{ issue.severity|title }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{# Area / Description #}
|
||||
<td class="align-middle">
|
||||
<div class="fw-semibold small">{{ issue.area.name if issue.area else '—' }}</div>
|
||||
<div class="text-muted small">
|
||||
{{ issue.description[:80] }}{% if issue.description|length > 80 %}…{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{# Requested by / assignee #}
|
||||
<td class="align-middle small">
|
||||
{% if issue.assigned_user %}
|
||||
<i class="bi bi-person-circle text-muted me-1"></i>{{ issue.assigned_user.display_name }}
|
||||
{% else %}
|
||||
<span class="text-muted">— unassigned —</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
{# Reported date #}
|
||||
<td class="align-middle small text-muted">
|
||||
{{ issue.reported_at.strftime('%Y-%m-%d') }}<br>
|
||||
<span style="font-size:.75rem;">{{ issue.reported_at.strftime('%H:%M') }}</span>
|
||||
</td>
|
||||
|
||||
{# SLA indicator #}
|
||||
<td class="align-middle">
|
||||
{% if sla == 'breached' %}
|
||||
<span class="badge bg-danger">
|
||||
<i class="bi bi-alarm me-1"></i>Breached
|
||||
</span>
|
||||
{% elif sla == 'at_risk' %}
|
||||
<span class="badge bg-warning text-dark">
|
||||
<i class="bi bi-hourglass-split me-1"></i>
|
||||
{% if hrs is not none %}{{ hrs|abs|round(1) }}h left{% else %}At Risk{% endif %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">
|
||||
{% if hrs is not none %}{{ hrs|round(1) }}h left{% else %}OK{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
{# Inline verify form #}
|
||||
<td class="align-middle">
|
||||
<form method="POST"
|
||||
action="{{ url_for('issues.verify', issue_id=issue.id) }}"
|
||||
class="d-flex gap-1 align-items-center">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="text"
|
||||
name="verification_note"
|
||||
class="form-control form-control-sm"
|
||||
placeholder="Note (optional)"
|
||||
style="width:130px;">
|
||||
<button type="submit" class="btn btn-sm btn-success flex-shrink-0"
|
||||
title="Verify & close this issue"
|
||||
onclick="return confirm('Verify and close Issue #{{ issue.id }}?')">
|
||||
<i class="bi bi-patch-check"></i>
|
||||
</button>
|
||||
<a href="{{ url_for('issues.view', issue_id=issue.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary flex-shrink-0"
|
||||
title="View full issue">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{# ── Legend ── #}
|
||||
<div class="card border-0 bg-light mt-2">
|
||||
<div class="card-body py-2 px-3 d-flex gap-4 flex-wrap" style="font-size:.78rem;">
|
||||
<span><span class="badge bg-danger me-1">Breached</span>Past SLA deadline</span>
|
||||
<span><span class="badge bg-warning text-dark me-1">At Risk</span>>75% of SLA window elapsed</span>
|
||||
<span><span class="badge bg-secondary me-1">OK</span>Within SLA</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// ── Bulk-verify checkbox management ─────────────────────────────────────────
|
||||
(function () {
|
||||
const selectAll = document.getElementById('selectAllIssues');
|
||||
const countEl = document.getElementById('selectedCount');
|
||||
const verifyBtn = document.getElementById('bulkVerifyBtn');
|
||||
|
||||
function updateToolbar() {
|
||||
const checked = document.querySelectorAll('.issue-checkbox:checked');
|
||||
const n = checked.length;
|
||||
if (countEl) countEl.textContent = n + ' selected';
|
||||
if (verifyBtn) verifyBtn.disabled = n === 0;
|
||||
if (selectAll) selectAll.indeterminate = n > 0 && n < document.querySelectorAll('.issue-checkbox').length;
|
||||
if (selectAll) selectAll.checked = n > 0 && n === document.querySelectorAll('.issue-checkbox').length;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.issue-checkbox').forEach(cb => {
|
||||
cb.addEventListener('change', updateToolbar);
|
||||
});
|
||||
|
||||
if (selectAll) {
|
||||
selectAll.addEventListener('change', function () {
|
||||
document.querySelectorAll('.issue-checkbox').forEach(cb => { cb.checked = this.checked; });
|
||||
updateToolbar();
|
||||
});
|
||||
}
|
||||
|
||||
updateToolbar();
|
||||
}());
|
||||
|
||||
// ── Inject checked issue IDs into the bulk-verify form before submit ─────────
|
||||
function injectBulkIds(form) {
|
||||
// Remove any previously injected inputs
|
||||
form.querySelectorAll('input[name="issue_ids"]').forEach(el => el.remove());
|
||||
const checked = document.querySelectorAll('.issue-checkbox:checked');
|
||||
if (!checked.length) { alert('Please select at least one issue.'); return false; }
|
||||
if (!confirm('Verify and close ' + checked.length + ' selected issue(s)?')) return false;
|
||||
checked.forEach(cb => {
|
||||
const hidden = document.createElement('input');
|
||||
hidden.type = 'hidden';
|
||||
hidden.name = 'issue_ids';
|
||||
hidden.value = cb.value;
|
||||
form.appendChild(hidden);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,482 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Issue #{{ issue.id }}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.comment-avatar {
|
||||
width: 38px; height: 38px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; font-size: .9rem; color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.comment-bubble {
|
||||
background: #f8f9fa; border: 1px solid #e9ecef;
|
||||
border-radius: .5rem; padding: .75rem 1rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% set can_edit = current_user.role in ['admin','director'] or issue.assigned_to == current_user.id %}
|
||||
|
||||
<div class="row">
|
||||
{# ══════════════════════════════════ LEFT COLUMN ══════════════════════════════════ #}
|
||||
<div class="col-lg-8">
|
||||
|
||||
{# ── Issue details ──────────────────────────────────────────────────── #}
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center
|
||||
bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning' if issue.severity == 'medium' else 'secondary' }}
|
||||
text-{{ 'white' if issue.severity in ['critical','high','low'] else 'dark' }}">
|
||||
<h5 class="mb-0"><i class="bi bi-exclamation-triangle"></i> Issue #{{ issue.id }} — {{ issue.severity|title }} Severity</h5>
|
||||
<span class="badge bg-{{ 'info text-dark' if issue.status == 'pending_verification' else 'light text-dark' }}">
|
||||
{{ issue.status|replace('_',' ')|title }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-3">Reported</dt>
|
||||
<dd class="col-sm-9">{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</dd>
|
||||
|
||||
<dt class="col-sm-3">Reported By</dt>
|
||||
<dd class="col-sm-9">
|
||||
{% if issue.reporter %}
|
||||
{{ issue.reporter.display_name }}
|
||||
{% if issue.reporter.role == 'customer' %}
|
||||
<span class="badge bg-info text-dark ms-1">Customer</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary ms-1">{{ issue.reporter.role|replace('_',' ')|title }}</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-3">Contract</dt>
|
||||
<dd class="col-sm-9">
|
||||
{% if issue.resolved_facility and issue.resolved_facility.project %}
|
||||
{{ issue.resolved_facility.project.name }}
|
||||
{% else %}—{% endif %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-3">Facility</dt>
|
||||
<dd class="col-sm-9">{{ issue.resolved_facility.name if issue.resolved_facility else '—' }}</dd>
|
||||
|
||||
<dt class="col-sm-3">Area</dt>
|
||||
<dd class="col-sm-9">{{ issue.area.name if issue.area else '—' }}</dd>
|
||||
|
||||
{% if issue.inspection %}
|
||||
<dt class="col-sm-3">Inspection</dt>
|
||||
<dd class="col-sm-9">
|
||||
<a href="{{ url_for('inspections.view', inspection_id=issue.inspection_id) }}">#{{ issue.inspection_id }}</a>
|
||||
</dd>
|
||||
{% endif %}
|
||||
|
||||
<dt class="col-sm-3">Assigned To</dt>
|
||||
<dd class="col-sm-9">{{ issue.assigned_user.display_name if issue.assigned_user else '— Unassigned —' }}</dd>
|
||||
|
||||
{% if issue.vendor_name %}
|
||||
<dt class="col-sm-3">Contractor</dt>
|
||||
<dd class="col-sm-9">
|
||||
<i class="bi bi-person-gear text-secondary me-1"></i>
|
||||
<strong>{{ issue.vendor_name }}</strong>
|
||||
{% if issue.vendor_contact %}
|
||||
<span class="text-muted ms-2">{{ issue.vendor_contact }}</span>
|
||||
{% endif %}
|
||||
{% if issue.vendor_notes %}
|
||||
<div class="text-muted small mt-1" style="white-space:pre-wrap;">{{ issue.vendor_notes }}</div>
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if issue.resolved_at %}
|
||||
<dt class="col-sm-3">Resolved</dt>
|
||||
<dd class="col-sm-9">{{ issue.resolved_at.strftime('%Y-%m-%d %H:%M') }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
|
||||
<hr>
|
||||
<h6>Description</h6>
|
||||
<p class="mb-0" style="white-space:pre-wrap;">{{ issue.description }}</p>
|
||||
|
||||
{% if issue.photo_path or issue.mobile_photo_paths %}
|
||||
<hr>
|
||||
<h6>Photo Evidence</h6>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% if issue.photo_path %}
|
||||
<a href="{{ url_for('static', filename=issue.photo_path) }}" target="_blank">
|
||||
<img src="{{ url_for('static', filename=issue.photo_path) }}"
|
||||
class="img-fluid rounded" style="max-height:300px; max-width:100%;">
|
||||
</a>
|
||||
{% endif %}
|
||||
{% for photo in (issue.mobile_photo_paths or []) %}
|
||||
<a href="{{ url_for('static', filename=photo) }}" target="_blank">
|
||||
<img src="{{ url_for('static', filename=photo) }}"
|
||||
class="rounded border" style="max-height:300px; max-width:100%; object-fit:cover;">
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if issue.result_notes or issue.result_photos %}
|
||||
<hr>
|
||||
<h6><i class="bi bi-clipboard2-check text-success"></i> Resolution Details</h6>
|
||||
{% if issue.result_notes %}
|
||||
<p class="mb-2" style="white-space:pre-wrap;">{{ issue.result_notes }}</p>
|
||||
{% endif %}
|
||||
{% if issue.result_photos %}
|
||||
<div class="d-flex flex-wrap gap-2 mt-2">
|
||||
{% for photo in issue.result_photos %}
|
||||
<a href="{{ url_for('static', filename=photo) }}" target="_blank">
|
||||
<img src="{{ url_for('static', filename=photo) }}"
|
||||
class="rounded border" style="max-height:120px; max-width:160px; object-fit:cover;"
|
||||
alt="Result photo">
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{# ── Verification panel ── #}
|
||||
{% if issue.verified_at %}
|
||||
<hr>
|
||||
<div class="alert alert-success py-2 mb-0">
|
||||
<i class="bi bi-patch-check-fill me-1"></i>
|
||||
<strong>Verified</strong> by {{ issue.verifier.display_name if issue.verifier else 'unknown' }}
|
||||
on {{ issue.verified_at.strftime('%Y-%m-%d %H:%M') }}.
|
||||
{% if issue.verification_note %}<br><span class="small">{{ issue.verification_note }}</span>{% endif %}
|
||||
</div>
|
||||
{% elif issue.status == 'pending_verification' %}
|
||||
<hr>
|
||||
<div class="alert alert-info py-2 mb-0">
|
||||
<i class="bi bi-hourglass-split me-1"></i>
|
||||
<strong>Awaiting director verification.</strong>
|
||||
{% if current_user.role in ['admin','director'] %}
|
||||
<form method="POST" action="{{ url_for('issues.verify', issue_id=issue.id) }}" class="mt-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-2">
|
||||
<input type="text" name="verification_note" class="form-control form-control-sm"
|
||||
placeholder="Verification note (optional)">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm btn-success">
|
||||
<i class="bi bi-patch-check me-1"></i>Verify & Close
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Comments ───────────────────────────────────────────────────────── #}
|
||||
<div class="card shadow-sm mb-4" id="comments-section">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-chat-left-text me-1"></i>Comments
|
||||
<span class="badge bg-secondary rounded-pill ms-1">{{ comments|length }}</span>
|
||||
</h6>
|
||||
</div>
|
||||
|
||||
{# Comment list #}
|
||||
{% if comments %}
|
||||
<div class="p-3 pb-0" id="comments-list">
|
||||
{% set avatar_colors = ['#4f46e5','#0891b2','#059669','#d97706','#dc2626','#7c3aed','#db2777'] %}
|
||||
{% for c in comments %}
|
||||
{% set avatar_color = avatar_colors[c.author.id % (avatar_colors | length)] %}
|
||||
<div class="d-flex gap-3 mb-3">
|
||||
<div class="comment-avatar" style="background:{{ avatar_color }};">
|
||||
{{ c.author.display_name[0] | upper }}
|
||||
</div>
|
||||
<div class="comment-bubble">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-1 mb-1">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="fw-semibold" style="font-size:.9rem;">{{ c.author.display_name }}</span>
|
||||
{% if c.author.role == 'customer' %}
|
||||
<span class="badge bg-info text-dark" style="font-size:.65rem;">Customer</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary" style="font-size:.65rem;">{{ c.author.role|replace('_',' ')|title }}</span>
|
||||
{% endif %}
|
||||
{# Visibility indicator — staff only #}
|
||||
{% if current_user.role != 'customer' %}
|
||||
{% if c.is_customer_visible %}
|
||||
<span class="badge bg-success bg-opacity-10 text-success border border-success"
|
||||
style="font-size:.6rem;" title="Customer can see this comment">
|
||||
<i class="bi bi-eye me-1"></i>Customer visible
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary bg-opacity-10 text-secondary border border-secondary"
|
||||
style="font-size:.6rem;" title="Hidden from customer">
|
||||
<i class="bi bi-eye-slash me-1"></i>Staff only
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge bg-{{ 'success' if c.status_at_time == 'resolved' else 'info text-dark' if c.status_at_time == 'pending_verification' else 'warning text-dark' if c.status_at_time == 'in_progress' else 'danger' }}"
|
||||
style="font-size:.65rem;">
|
||||
{{ c.status_at_time|replace('_',' ')|title }}
|
||||
</span>
|
||||
<small class="text-muted">{{ c.created_at.strftime('%b %d, %Y %H:%M') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mb-0" style="white-space:pre-wrap; font-size:.9rem;">{{ c.body }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<hr class="mx-3 my-0">
|
||||
{% elif current_user.role == 'customer' %}
|
||||
<div class="px-3 pt-3 pb-0">
|
||||
<p class="text-muted small"><i class="bi bi-chat-left me-1"></i>No comments yet.</p>
|
||||
</div>
|
||||
<hr class="mx-3 my-0">
|
||||
{% endif %}
|
||||
|
||||
{# ── Add Comment form ─────────────────────────────────────────────── #}
|
||||
{% set can_customer_comment = current_user.role == 'customer' and (is_following or issue.reported_by == current_user.id) %}
|
||||
|
||||
{% if can_edit %}
|
||||
{# Staff comment form with visibility checkbox #}
|
||||
<div class="card-body">
|
||||
<p class="fw-semibold small mb-2">Add Comment</p>
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="status" value="{{ issue.status }}">
|
||||
<input type="hidden" name="assigned_to" value="{{ issue.assigned_to or 0 }}">
|
||||
<div class="mb-2">
|
||||
<textarea name="update_notes" class="form-control" rows="3"
|
||||
placeholder="Write a comment…" required></textarea>
|
||||
</div>
|
||||
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||
<div class="form-check form-check-inline mb-0">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
name="is_customer_visible" id="is_customer_visible" value="1">
|
||||
<label class="form-check-label small text-muted" for="is_customer_visible">
|
||||
<i class="bi bi-eye me-1"></i>Share with customer
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-send me-1"></i>Post Comment
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% elif can_customer_comment %}
|
||||
{# Customer comment form — visible to all by design #}
|
||||
<div class="card-body">
|
||||
<p class="fw-semibold small mb-2">Add Comment</p>
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-2">
|
||||
<textarea name="update_notes" class="form-control" rows="3"
|
||||
placeholder="Write a comment…" required></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-send me-1"></i>Post Comment
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% elif current_user.role == 'customer' %}
|
||||
<div class="card-body py-2">
|
||||
<p class="text-muted small mb-0">
|
||||
<i class="bi bi-bell me-1"></i>Follow this issue to add comments.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="card-body py-2">
|
||||
<p class="text-muted small mb-0">
|
||||
<i class="bi bi-lock me-1"></i>Only assigned staff can add comments.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>{# /col-lg-8 #}
|
||||
|
||||
{# ══════════════════════════════════ RIGHT COLUMN ═════════════════════════════════ #}
|
||||
<div class="col-lg-4">
|
||||
|
||||
{# ── Follow / Unfollow ──────────────────────────────────────────────── #}
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body d-flex align-items-center justify-content-between py-2">
|
||||
<div>
|
||||
<i class="bi bi-bell{{ '-fill text-primary' if is_following else ' text-muted' }} me-1"></i>
|
||||
<span class="fw-semibold" style="font-size:.9rem;">
|
||||
{% if is_following %}Following{% else %}Not following{% endif %}
|
||||
</span>
|
||||
<span class="text-muted ms-2" style="font-size:.8rem;">
|
||||
{{ issue.followers.count() }} follower{{ 's' if issue.followers.count() != 1 else '' }}
|
||||
</span>
|
||||
</div>
|
||||
{% if is_following %}
|
||||
<form method="post" action="{{ url_for('issues.unfollow', issue_id=issue.id) }}" class="mb-0">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-bell-slash"></i> Unfollow
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('issues.follow', issue_id=issue.id) }}" class="mb-0">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-bell"></i> Follow
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Update Form ────────────────────────────────────────────────────── #}
|
||||
{% if can_edit %}
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light"><h6 class="mb-0">Update Issue</h6></div>
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
{{ form.status.label(class="form-label fw-semibold") }}
|
||||
{{ form.status(class="form-select") }}
|
||||
</div>
|
||||
{% if current_user.role in ['admin','director'] %}
|
||||
<div class="mb-3">
|
||||
{{ form.assigned_to.label(class="form-label fw-semibold") }}
|
||||
{{ form.assigned_to(class="form-select") }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="mb-3">
|
||||
{{ form.result_notes.label(class="form-label fw-semibold") }}
|
||||
{{ form.result_notes(class="form-control", rows=3,
|
||||
placeholder="Describe what was done to resolve this issue…",
|
||||
value=issue.result_notes or '') }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Result Photos</label>
|
||||
<input type="file" name="result_photos" id="result_photos"
|
||||
class="form-control" accept="image/*" multiple>
|
||||
<div class="form-text">Attach one or more photos showing the resolution.</div>
|
||||
{% if issue.result_photos %}
|
||||
<div class="mt-2">
|
||||
<small class="text-muted">{{ issue.result_photos|length }} photo(s) already uploaded</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if current_user.role in ['admin','director','project_manager'] %}
|
||||
<hr class="my-3">
|
||||
<p class="fw-semibold small mb-2">
|
||||
<i class="bi bi-person-gear me-1 text-secondary"></i>External Contractor
|
||||
</p>
|
||||
<div class="mb-2">
|
||||
{{ form.vendor_name.label(class="form-label small fw-semibold mb-1") }}
|
||||
{{ form.vendor_name(class="form-control form-control-sm",
|
||||
placeholder="Contractor or vendor name",
|
||||
value=issue.vendor_name or '') }}
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
{{ form.vendor_contact.label(class="form-label small fw-semibold mb-1") }}
|
||||
{{ form.vendor_contact(class="form-control form-control-sm",
|
||||
placeholder="Phone or email",
|
||||
value=issue.vendor_contact or '') }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.vendor_notes.label(class="form-label small fw-semibold mb-1") }}
|
||||
{{ form.vendor_notes(class="form-control form-control-sm", rows=2,
|
||||
placeholder="Notes about what the contractor is handling…") }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<button type="submit" class="btn btn-primary w-100">Save Update</button>
|
||||
</form>
|
||||
{% if issue.status in ['in_progress', 'resolved'] and current_user.role not in ['customer'] %}
|
||||
<form method="POST"
|
||||
action="{{ url_for('issues.request_verification', issue_id=issue.id) }}"
|
||||
class="mt-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-outline-info w-100"
|
||||
onclick="return confirm('Mark this issue as pending director verification?')">
|
||||
<i class="bi bi-hourglass-split me-1"></i> Request Verification
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>{# /col-lg-4 #}
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center gap-2 mt-2">
|
||||
<a href="{{ url_for('issues.index') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left"></i> Back to Issues
|
||||
</a>
|
||||
<a href="{{ url_for('issues.export_pdf', issue_id=issue.id) }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-file-earmark-pdf"></i> Export PDF
|
||||
</a>
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<button type="button" class="btn btn-outline-danger btn-sm"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteIssueModal">
|
||||
<i class="bi bi-trash"></i> Delete Issue
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<div class="modal fade" id="deleteIssueModal" tabindex="-1" aria-labelledby="deleteIssueModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-danger text-white">
|
||||
<h5 class="modal-title" id="deleteIssueModalLabel">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i> Confirm Deletion
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>You are about to permanently delete:</p>
|
||||
<p class="fw-bold">Issue #{{ issue.id }} — {{ issue.severity|title }} severity in {{ issue.area.name if issue.area else '—' }}</p>
|
||||
<div class="alert alert-warning mb-0">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i>
|
||||
This action is <strong>irreversible</strong>. All comments, photos, and
|
||||
follower records associated with this issue will also be deleted.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="bi bi-x-circle"></i> Cancel
|
||||
</button>
|
||||
<form method="POST" action="{{ url_for('issues.delete', issue_id=issue.id) }}" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-trash-fill"></i> Delete Permanently
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Refresh bell badge after a successful update
|
||||
if (document.querySelector('.alert-success')) {
|
||||
if (typeof fetchNotifications === 'function') {
|
||||
fetchNotifications();
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll to bottom of comments list after posting a comment
|
||||
if (document.querySelector('.alert-success')) {
|
||||
var section = document.getElementById('comments-section');
|
||||
if (section) {
|
||||
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,48 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Login - Janitorial QC{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-md-5 col-lg-4">
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-header bg-primary text-white text-center py-4">
|
||||
<h3><i class="bi bi-clipboard-check-fill"></i> Janitorial QC</h3>
|
||||
<p class="mb-0">Quality Control System</p>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<form method="POST" action="{{ url_for('auth.login') }}">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.username.label(class="form-label") }}
|
||||
{{ form.username(class="form-control form-control-lg", placeholder="Enter username") }}
|
||||
{% if form.username.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for error in form.username.errors %}{{ error }}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
{{ form.password.label(class="form-label") }}
|
||||
{{ form.password(class="form-control form-control-lg", placeholder="Enter password") }}
|
||||
{% if form.password.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for error in form.password.errors %}{{ error }}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100">
|
||||
<i class="bi bi-box-arrow-in-right"></i> Login
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-footer text-center text-muted small">
|
||||
© 2025 Janitorial QC System
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,164 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Notifications{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3 align-items-center">
|
||||
<div class="col">
|
||||
<h4 class="mb-0">
|
||||
<i class="bi bi-bell-fill text-primary me-2"></i>Notifications
|
||||
{% if unread_count > 0 %}
|
||||
<span class="badge bg-danger ms-1" style="font-size:.6rem;vertical-align:middle;">
|
||||
{{ unread_count }} unread
|
||||
</span>
|
||||
{% endif %}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="col-auto d-flex gap-2">
|
||||
<a href="{{ url_for('notifications.preferences') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-gear"></i> Preferences
|
||||
</a>
|
||||
{% if unread_count > 0 %}
|
||||
<form method="post" action="{{ url_for('notifications.mark_all_read') }}" class="mb-0">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-check2-all"></i> Mark all read
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Filter tabs ── #}
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if filter_read == 'all' %}active{% endif %}"
|
||||
href="{{ url_for('notifications.index', filter='all') }}">All</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if filter_read == 'unread' %}active{% endif %}"
|
||||
href="{{ url_for('notifications.index', filter='unread') }}">Unread</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if filter_read == 'read' %}active{% endif %}"
|
||||
href="{{ url_for('notifications.index', filter='read') }}">Read</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{# ── Notification list ── #}
|
||||
{% if notifications.items %}
|
||||
<div class="card shadow-sm">
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for n in notifications.items %}
|
||||
<li class="list-group-item px-3 py-3
|
||||
{% if not n.is_read %}list-group-item-light border-start border-primary border-3{% endif %}">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-items-center gap-2 mb-1">
|
||||
{% if not n.is_read %}
|
||||
<span class="badge bg-primary" style="font-size:.65rem;">New</span>
|
||||
{% endif %}
|
||||
<span class="fw-semibold" style="font-size:.9rem;">{{ n.title }}</span>
|
||||
</div>
|
||||
<p class="mb-1 text-secondary" style="font-size:.85rem;">{{ n.body }}</p>
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-clock me-1"></i>{{ n.created_at.strftime('%b %d, %Y %I:%M %p') }}
|
||||
</small>
|
||||
</div>
|
||||
<div class="d-flex gap-2 ms-3 flex-shrink-0">
|
||||
{% if n.link %}
|
||||
<a href="{{ n.link }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-arrow-right"></i> View
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if not n.is_read %}
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-secondary mark-read-btn"
|
||||
data-notif-id="{{ n.id }}"
|
||||
data-url="{{ url_for('notifications.mark_read', notif_id=n.id) }}"
|
||||
title="Mark as read">
|
||||
<i class="bi bi-check2"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{# ── Pagination ── #}
|
||||
{% if notifications.pages > 1 %}
|
||||
<nav class="mt-3">
|
||||
<ul class="pagination pagination-sm justify-content-center">
|
||||
<li class="page-item {% if not notifications.has_prev %}disabled{% endif %}">
|
||||
<a class="page-link"
|
||||
href="{{ url_for('notifications.index', page=notifications.prev_num, filter=filter_read) }}">
|
||||
« Prev
|
||||
</a>
|
||||
</li>
|
||||
{% for p in notifications.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
||||
{% if p %}
|
||||
<li class="page-item {% if p == notifications.page %}active{% endif %}">
|
||||
<a class="page-link"
|
||||
href="{{ url_for('notifications.index', page=p, filter=filter_read) }}">{{ p }}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<li class="page-item {% if not notifications.has_next %}disabled{% endif %}">
|
||||
<a class="page-link"
|
||||
href="{{ url_for('notifications.index', page=notifications.next_num, filter=filter_read) }}">
|
||||
Next »
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="bi bi-bell-slash fs-1 d-block mb-3"></i>
|
||||
<p class="mb-0">No notifications found.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.querySelectorAll('.mark-read-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var url = btn.dataset.url;
|
||||
var csrfToken = {{ csrf_token() | tojson }};
|
||||
btn.disabled = true;
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': csrfToken, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'csrf_token=' + encodeURIComponent(csrfToken),
|
||||
})
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(data) {
|
||||
if (data.ok) {
|
||||
var li = btn.closest('li');
|
||||
li.classList.remove('list-group-item-light', 'border-start', 'border-primary', 'border-3');
|
||||
var badge = li.querySelector('.badge.bg-primary');
|
||||
if (badge) badge.remove();
|
||||
btn.remove();
|
||||
var unreadBadge = document.querySelector('.badge.bg-danger');
|
||||
if (unreadBadge) {
|
||||
var count = parseInt(unreadBadge.textContent) - 1;
|
||||
if (count <= 0) {
|
||||
unreadBadge.remove();
|
||||
var markAllForm = document.querySelector('form[action*="mark-all-read"]');
|
||||
if (markAllForm) markAllForm.closest('.col-auto') && markAllForm.remove();
|
||||
} else {
|
||||
unreadBadge.textContent = count + ' unread';
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(function() { btn.disabled = false; });
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,347 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Notification Preferences{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3 align-items-center">
|
||||
<div class="col">
|
||||
<h4 class="mb-0">
|
||||
<i class="bi bi-gear-fill text-secondary me-2"></i>Notification Preferences
|
||||
</h4>
|
||||
<p class="text-muted mb-0 mt-1" style="font-size:.85rem;">
|
||||
Control how and when you receive notifications for each event type.
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="{{ url_for('notifications.index') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-bell"></i> View Notifications
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
{# ── Pause all emails banner ── #}
|
||||
{% set any_email_on = prefs_map.values() | selectattr('email_enabled') | list | length > 0
|
||||
or prefs_map | length == 0 %}
|
||||
<div class="alert alert-light border d-flex align-items-center justify-content-between py-2 mb-3">
|
||||
<div>
|
||||
<i class="bi bi-pause-circle me-2 text-secondary"></i>
|
||||
<strong>Pause all email notifications</strong>
|
||||
<span class="text-muted ms-2" style="font-size:.85rem;">— in-app notifications are unaffected</span>
|
||||
</div>
|
||||
<button type="button" id="pauseAllBtn" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-pause-fill me-1"></i>Pause All Emails
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light">
|
||||
<div class="row fw-semibold text-muted" style="font-size:.8rem;">
|
||||
<div class="col-md-4">Event</div>
|
||||
<div class="col-md-2 text-center">Email Alerts</div>
|
||||
<div class="col-md-2 text-center">Digest Mode</div>
|
||||
<div class="col-md-3 text-center">Digest Frequency</div>
|
||||
<div class="col-md-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Internal staff events ── #}
|
||||
{% set internal_events = [
|
||||
'issue_assigned', 'issue_status', 'issue_comment',
|
||||
'issue_follow_update', 'inspection_completed', 'sla_alert'
|
||||
] %}
|
||||
{# ── Customer portal events ── #}
|
||||
{% set customer_events = [
|
||||
'customer_inspection_completed', 'customer_issue_updated'
|
||||
] %}
|
||||
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item bg-light py-1 px-3">
|
||||
<small class="text-muted fw-semibold text-uppercase" style="font-size:.7rem;">
|
||||
<i class="bi bi-people-fill me-1"></i>Internal Events
|
||||
</small>
|
||||
</li>
|
||||
{% for event_type, label in event_types.items() if event_type in internal_events %}
|
||||
{% set pref = prefs_map.get(event_type) %}
|
||||
{% set email_on = pref.email_enabled if pref else True %}
|
||||
{% set digest_on = pref.digest_mode if pref else False %}
|
||||
{% set freq = pref.digest_frequency if pref else 'daily' %}
|
||||
|
||||
<li class="list-group-item px-3 py-3" id="row-{{ event_type }}">
|
||||
<div class="row align-items-center">
|
||||
|
||||
{# Event label #}
|
||||
<div class="col-md-4">
|
||||
<span class="fw-semibold" style="font-size:.9rem;">{{ label }}</span>
|
||||
</div>
|
||||
|
||||
{# Email toggle #}
|
||||
<div class="col-md-2 text-center">
|
||||
<div class="form-check form-switch d-inline-block">
|
||||
<input class="form-check-input email-toggle"
|
||||
type="checkbox"
|
||||
name="email_{{ event_type }}"
|
||||
id="email_{{ event_type }}"
|
||||
value="1"
|
||||
data-event="{{ event_type }}"
|
||||
{% if email_on %}checked{% endif %}>
|
||||
<label class="form-check-label visually-hidden"
|
||||
for="email_{{ event_type }}">Email</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Digest mode toggle #}
|
||||
<div class="col-md-2 text-center">
|
||||
<div class="form-check form-switch d-inline-block">
|
||||
<input class="form-check-input digest-toggle"
|
||||
type="checkbox"
|
||||
name="digest_{{ event_type }}"
|
||||
id="digest_{{ event_type }}"
|
||||
value="1"
|
||||
data-event="{{ event_type }}"
|
||||
{% if digest_on %}checked{% endif %}
|
||||
{% if not email_on %}disabled{% endif %}>
|
||||
<label class="form-check-label visually-hidden"
|
||||
for="digest_{{ event_type }}">Digest</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Digest frequency #}
|
||||
<div class="col-md-3 text-center">
|
||||
<select class="form-select form-select-sm freq-select"
|
||||
name="freq_{{ event_type }}"
|
||||
id="freq_{{ event_type }}"
|
||||
style="width:auto;margin:auto;"
|
||||
{% if not email_on or not digest_on %}disabled{% endif %}>
|
||||
<option value="hourly" {% if freq == 'hourly' %}selected{% endif %}>Hourly</option>
|
||||
<option value="daily" {% if freq == 'daily' %}selected{% endif %}>Daily</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# Status label #}
|
||||
<div class="col-md-1 text-end">
|
||||
<span class="badge status-badge
|
||||
{% if not email_on %}bg-secondary
|
||||
{% elif digest_on %}bg-warning text-dark
|
||||
{% else %}bg-success{% endif %}"
|
||||
style="font-size:.65rem;"
|
||||
id="badge-{{ event_type }}">
|
||||
{% if not email_on %}Off
|
||||
{% elif digest_on %}Digest
|
||||
{% else %}Live{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
<li class="list-group-item bg-light py-1 px-3">
|
||||
<small class="text-muted fw-semibold text-uppercase" style="font-size:.7rem;">
|
||||
<i class="bi bi-building me-1"></i>Customer Portal Events
|
||||
</small>
|
||||
</li>
|
||||
{% for event_type, label in event_types.items() if event_type in customer_events %}
|
||||
{% set pref = prefs_map.get(event_type) %}
|
||||
{% set email_on = pref.email_enabled if pref else True %}
|
||||
{% set digest_on = pref.digest_mode if pref else False %}
|
||||
{% set freq = pref.digest_frequency if pref else 'daily' %}
|
||||
|
||||
<li class="list-group-item px-3 py-3" id="row-{{ event_type }}">
|
||||
<div class="row align-items-center">
|
||||
|
||||
{# Event label #}
|
||||
<div class="col-md-4">
|
||||
<span class="fw-semibold" style="font-size:.9rem;">{{ label }}</span>
|
||||
<span class="badge bg-success ms-1" style="font-size:.65rem;">Portal</span>
|
||||
</div>
|
||||
|
||||
{# Email toggle #}
|
||||
<div class="col-md-2 text-center">
|
||||
<div class="form-check form-switch d-inline-block">
|
||||
<input class="form-check-input email-toggle"
|
||||
type="checkbox"
|
||||
name="email_{{ event_type }}"
|
||||
id="email_{{ event_type }}"
|
||||
value="1"
|
||||
data-event="{{ event_type }}"
|
||||
{% if email_on %}checked{% endif %}>
|
||||
<label class="form-check-label visually-hidden"
|
||||
for="email_{{ event_type }}">Email</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Digest mode toggle #}
|
||||
<div class="col-md-2 text-center">
|
||||
<div class="form-check form-switch d-inline-block">
|
||||
<input class="form-check-input digest-toggle"
|
||||
type="checkbox"
|
||||
name="digest_{{ event_type }}"
|
||||
id="digest_{{ event_type }}"
|
||||
value="1"
|
||||
data-event="{{ event_type }}"
|
||||
{% if digest_on %}checked{% endif %}
|
||||
{% if not email_on %}disabled{% endif %}>
|
||||
<label class="form-check-label visually-hidden"
|
||||
for="digest_{{ event_type }}">Digest</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Digest frequency #}
|
||||
<div class="col-md-3 text-center">
|
||||
<select class="form-select form-select-sm freq-select"
|
||||
name="freq_{{ event_type }}"
|
||||
id="freq_{{ event_type }}"
|
||||
style="width:auto;margin:auto;"
|
||||
{% if not email_on or not digest_on %}disabled{% endif %}>
|
||||
<option value="hourly" {% if freq == 'hourly' %}selected{% endif %}>Hourly</option>
|
||||
<option value="daily" {% if freq == 'daily' %}selected{% endif %}>Daily</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# Status label #}
|
||||
<div class="col-md-1 text-end">
|
||||
<span class="badge status-badge
|
||||
{% if not email_on %}bg-secondary
|
||||
{% elif digest_on %}bg-warning text-dark
|
||||
{% else %}bg-success{% endif %}"
|
||||
style="font-size:.65rem;"
|
||||
id="badge-{{ event_type }}">
|
||||
{% if not email_on %}Off
|
||||
{% elif digest_on %}Digest
|
||||
{% else %}Live{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check2-circle me-1"></i>Save Preferences
|
||||
</button>
|
||||
<a href="{{ url_for('notifications.index') }}" class="btn btn-outline-secondary">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card mt-4 border-0 bg-light">
|
||||
<div class="card-body py-2 px-3">
|
||||
<p class="mb-1" style="font-size:.8rem;"><strong>Email Alerts:</strong>
|
||||
Send an immediate email every time this event occurs.</p>
|
||||
<p class="mb-1" style="font-size:.8rem;"><strong>Digest Mode:</strong>
|
||||
Hold notifications and deliver them in a single batched email on your chosen schedule.</p>
|
||||
<p class="mb-0" style="font-size:.8rem;"><strong>Off:</strong>
|
||||
In-app notifications still appear in the bell — only email is suppressed.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
document.querySelectorAll('.email-toggle').forEach(function (emailChk) {
|
||||
emailChk.addEventListener('change', function () {
|
||||
var event = this.dataset.event;
|
||||
var digestChk = document.getElementById('digest_' + event);
|
||||
var freqSelect = document.getElementById('freq_' + event);
|
||||
var badge = document.getElementById('badge_' + event) ||
|
||||
document.getElementById('badge-' + event);
|
||||
|
||||
var emailOn = this.checked;
|
||||
var digestOn = digestChk.checked;
|
||||
|
||||
// Cascade: disabling email disables digest and frequency
|
||||
digestChk.disabled = !emailOn;
|
||||
freqSelect.disabled = !emailOn || !digestOn;
|
||||
|
||||
if (!emailOn) {
|
||||
digestChk.checked = false;
|
||||
digestOn = false;
|
||||
}
|
||||
|
||||
updateBadge(badge, emailOn, digestOn);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.digest-toggle').forEach(function (digestChk) {
|
||||
digestChk.addEventListener('change', function () {
|
||||
var event = this.dataset.event;
|
||||
var freqSelect = document.getElementById('freq_' + event);
|
||||
var badge = document.getElementById('badge_' + event) ||
|
||||
document.getElementById('badge-' + event);
|
||||
var emailChk = document.getElementById('email_' + event);
|
||||
|
||||
var emailOn = emailChk.checked;
|
||||
var digestOn = this.checked;
|
||||
|
||||
freqSelect.disabled = !emailOn || !digestOn;
|
||||
|
||||
updateBadge(badge, emailOn, digestOn);
|
||||
});
|
||||
});
|
||||
|
||||
function updateBadge(badge, emailOn, digestOn) {
|
||||
if (!badge) return;
|
||||
badge.className = badge.className
|
||||
.replace(/bg-\S+/g, '')
|
||||
.replace(/text-\S+/g, '')
|
||||
.trim();
|
||||
|
||||
if (!emailOn) {
|
||||
badge.classList.add('bg-secondary');
|
||||
badge.textContent = 'Off';
|
||||
} else if (digestOn) {
|
||||
badge.classList.add('bg-warning', 'text-dark');
|
||||
badge.textContent = 'Digest';
|
||||
} else {
|
||||
badge.classList.add('bg-success');
|
||||
badge.textContent = 'Live';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pause all emails button ──────────────────────────────────────────────
|
||||
var pauseBtn = document.getElementById('pauseAllBtn');
|
||||
if (pauseBtn) {
|
||||
pauseBtn.addEventListener('click', function () {
|
||||
var allEmailToggles = document.querySelectorAll('.email-toggle');
|
||||
var anyOn = Array.from(allEmailToggles).some(function (c) { return c.checked; });
|
||||
|
||||
allEmailToggles.forEach(function (emailChk) {
|
||||
var event = emailChk.dataset.event;
|
||||
var digestChk = document.getElementById('digest_' + event);
|
||||
var freqSelect = document.getElementById('freq_' + event);
|
||||
var badge = document.getElementById('badge-' + event);
|
||||
|
||||
if (anyOn) {
|
||||
// Pause: turn everything off
|
||||
emailChk.checked = false;
|
||||
digestChk.checked = false;
|
||||
digestChk.disabled = true;
|
||||
freqSelect.disabled = true;
|
||||
updateBadge(badge, false, false);
|
||||
} else {
|
||||
// Resume: re-enable email (digest stays off until user opts back in)
|
||||
emailChk.checked = true;
|
||||
digestChk.disabled = false;
|
||||
updateBadge(badge, true, false);
|
||||
}
|
||||
});
|
||||
|
||||
// Update button label to reflect new state
|
||||
var nowPaused = !anyOn ? false : true;
|
||||
pauseBtn.innerHTML = nowPaused
|
||||
? '<i class="bi bi-play-fill me-1"></i>Resume All Emails'
|
||||
: '<i class="bi bi-pause-fill me-1"></i>Pause All Emails';
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,51 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-6 offset-md-3">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0">
|
||||
<i class="bi bi-person-plus"></i> {{ title }}
|
||||
</h4>
|
||||
<small class="text-white-50">Contract: {{ project.name }}</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.user_id.label(class="form-label") }}
|
||||
{{ form.user_id(class="form-select") }}
|
||||
{% if form.user_id.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for e in form.user_id.errors %}{{ e }}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-text">Only active users with the Customer role are listed.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
{{ form.facility_id.label(class="form-label") }}
|
||||
{{ form.facility_id(class="form-select") }}
|
||||
<div class="form-text">
|
||||
Select "All facilities in contract" to grant access to every facility
|
||||
within this contract, or choose a specific facility to restrict access.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-person-check"></i> Assign Customer
|
||||
</button>
|
||||
<a href="{{ url_for('projects.view', project_id=project.id) }}" class="btn btn-secondary">
|
||||
<i class="bi bi-x-circle"></i> Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,56 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0">{{ title }}</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.name.label(class="form-label") }}
|
||||
{{ form.name(class="form-control") }}
|
||||
{% if form.name.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for e in form.name.errors %}{{ e }}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.description.label(class="form-label") }}
|
||||
{{ form.description(class="form-control", rows=3) }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.project_manager_id.label(class="form-label") }}
|
||||
{{ form.project_manager_id(class="form-select") }}
|
||||
<div class="form-text">Only users with the Project Manager role are listed.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="form-check">
|
||||
{{ form.active(class="form-check-input") }}
|
||||
{{ form.active.label(class="form-check-label") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-save"></i> Save Contract
|
||||
</button>
|
||||
<a href="{{ url_for('projects.index') }}" class="btn btn-secondary">
|
||||
<i class="bi bi-x-circle"></i> Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,168 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Import Contracts & Facilities{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2><i class="bi bi-file-earmark-excel text-success me-2"></i>Import Contracts & Facilities</h2>
|
||||
<p class="text-muted mb-0">Bulk-create contracts and their facilities from an Excel workbook.</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('projects.import_template') }}" class="btn btn-sm btn-outline-success">
|
||||
<i class="bi bi-download me-1"></i>Download Template
|
||||
</a>
|
||||
<a href="{{ url_for('projects.index') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to Contracts
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Format guide ── #}
|
||||
<div class="card border-0 bg-light mb-4">
|
||||
<div class="card-body py-3 px-4">
|
||||
<h6 class="fw-semibold mb-2"><i class="bi bi-info-circle me-1 text-primary"></i>Excel Format — two sheets</h6>
|
||||
<div class="row g-3 small">
|
||||
<div class="col-md-4">
|
||||
<strong>Sheet: <code>Contracts</code></strong>
|
||||
<ul class="mb-0 mt-1 ps-3">
|
||||
<li><code>contract_name</code> <span class="text-danger">*required</span></li>
|
||||
<li><code>description</code> — optional</li>
|
||||
<li><code>active</code> — yes/no (default: yes)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<strong>Sheet: <code>Facilities</code></strong>
|
||||
<ul class="mb-0 mt-1 ps-3">
|
||||
<li><code>contract_name</code> <span class="text-danger">*required</span> — must match a row in Contracts sheet</li>
|
||||
<li><code>facility_name</code> <span class="text-danger">*required</span></li>
|
||||
<li><code>address</code>, <code>contact_person</code>, <code>contact_phone</code> — optional</li>
|
||||
<li><code>active</code> — yes/no (default: yes)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<strong>Tips</strong>
|
||||
<ul class="mb-0 mt-1 ps-3">
|
||||
<li>Contracts that already exist (by name) are reused — not duplicated</li>
|
||||
<li>Facilities that already exist within the same contract are skipped</li>
|
||||
<li>The Facilities sheet is optional — you can import contracts only</li>
|
||||
<li>Download the template to see the expected structure</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Upload form (Phase 1) ── #}
|
||||
{% if not preview_rows %}
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-success text-white fw-semibold">
|
||||
<i class="bi bi-file-earmark-arrow-up me-1"></i> Upload Excel File
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Select .xlsx File</label>
|
||||
<input type="file" name="xlsx_file" accept=".xlsx,.xlsm" class="form-control" required>
|
||||
<div class="form-text">Maximum recommended file size: 2 MB.</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="bi bi-search me-1"></i> Parse & Preview
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
{# ── Preview results (Phase 1 response) ── #}
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center
|
||||
bg-{{ 'danger' if has_errors else 'success' }} text-white">
|
||||
<span class="fw-semibold">
|
||||
<i class="bi bi-{{ 'x-circle' if has_errors else 'check-circle' }} me-1"></i>
|
||||
Preview — {{ preview_rows|length }} row(s) parsed
|
||||
</span>
|
||||
<span>
|
||||
<span class="badge bg-white text-success">{{ valid_count }} ready</span>
|
||||
{% set exists_count = preview_rows | selectattr('status', 'equalto', 'exists') | list | length %}
|
||||
{% if exists_count > 0 %}
|
||||
<span class="badge bg-white text-warning ms-1">{{ exists_count }} existing</span>
|
||||
{% endif %}
|
||||
{% set err_count = preview_rows | selectattr('status', 'equalto', 'error') | list | length %}
|
||||
{% if err_count > 0 %}
|
||||
<span class="badge bg-white text-danger ms-1">{{ err_count }} error(s)</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0" style="font-size:.85rem;">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th width="50">Row</th>
|
||||
<th width="100">Sheet</th>
|
||||
<th>Contract Name</th>
|
||||
<th>Facility Name</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in preview_rows %}
|
||||
<tr class="{{ 'table-danger' if row.status == 'error' else ('table-warning' if row.status == 'exists' else '') }}">
|
||||
<td class="text-muted">{{ row.row }}</td>
|
||||
<td><span class="badge bg-secondary">{{ row.sheet }}</span></td>
|
||||
<td>{{ row.contract_name or '—' }}</td>
|
||||
<td>{{ row.facility_name or '—' }}</td>
|
||||
<td>
|
||||
{% if row.status == 'ok' %}
|
||||
<span class="badge bg-success">Ready</span>
|
||||
{% elif row.status == 'exists' %}
|
||||
<span class="badge bg-warning text-dark">Exists</span>
|
||||
<span class="text-muted ms-1" style="font-size:.78rem;">{{ row.note }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">Error</span>
|
||||
<ul class="mb-0 ps-3 text-danger" style="font-size:.78rem;">
|
||||
{% for e in row.errors %}<li>{{ e }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Action buttons (Phase 2 trigger) ── #}
|
||||
{% if has_errors %}
|
||||
<div class="alert alert-danger">
|
||||
<i class="bi bi-exclamation-triangle-fill me-1"></i>
|
||||
<strong>Errors found.</strong> Fix the issues above and re-upload.
|
||||
{% if valid_count > 0 %}
|
||||
You may still import the {{ valid_count }} valid row(s) by clicking below.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="d-flex gap-3 align-items-center">
|
||||
{% if valid_count > 0 %}
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="confirmed" value="1">
|
||||
<input type="hidden" name="rows_json" value="{{ rows_json }}">
|
||||
<button type="submit" class="btn btn-success"
|
||||
onclick="return confirm('Proceed with importing {{ valid_count }} valid row(s)?')">
|
||||
<i class="bi bi-check-circle me-1"></i>
|
||||
Import {{ valid_count }} Valid Row{{ 's' if valid_count != 1 else '' }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('projects.bulk_import') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-counterclockwise me-1"></i> Upload Different File
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,84 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Contracts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4 align-items-center">
|
||||
<div class="col">
|
||||
<h2><i class="bi bi-folder2-open"></i> Contracts</h2>
|
||||
</div>
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<div class="col-auto d-flex gap-2">
|
||||
<a href="{{ url_for('projects.bulk_import') }}" class="btn btn-outline-success">
|
||||
<i class="bi bi-file-earmark-excel"></i> Import
|
||||
</a>
|
||||
<a href="{{ url_for('projects.create') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> New Contract
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
{% for project in projects %}
|
||||
<div class="col-md-6 col-lg-4 mb-4">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h5 class="card-title mb-0">
|
||||
<a href="{{ url_for('projects.view', project_id=project.id) }}" class="text-decoration-none">
|
||||
{{ project.name }}
|
||||
</a>
|
||||
</h5>
|
||||
{% if not project.active %}
|
||||
<span class="badge bg-secondary ms-2">Inactive</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success ms-2">Active</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if project.description %}
|
||||
<p class="card-text text-muted small">{{ project.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-3 d-flex gap-3">
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-building"></i> {{ project.facilities.count() }} facilities
|
||||
</small>
|
||||
{% if project.project_manager %}
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-person-badge"></i> {{ project.project_manager.display_name }}
|
||||
</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent d-flex gap-2">
|
||||
<a href="{{ url_for('projects.view', project_id=project.id) }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i> View
|
||||
</a>
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<a href="{{ url_for('projects.edit', project_id=project.id) }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if current_user.role == 'admin' %}
|
||||
<form method="POST" action="{{ url_for('projects.delete', project_id=project.id) }}"
|
||||
class="ms-auto d-inline"
|
||||
onsubmit="return confirm('Delete contract \'{{ project.name }}\'? This cannot be undone.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-12">
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle"></i> No contracts have been created yet.
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,169 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ project.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4 align-items-center">
|
||||
<div class="col">
|
||||
<h2>
|
||||
<i class="bi bi-folder2-open"></i> {{ project.name }}
|
||||
{% if not project.active %}
|
||||
<span class="badge bg-secondary ms-2 fs-6">Inactive</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
{% if project.description %}
|
||||
<p class="text-muted">{{ project.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-auto d-flex gap-2">
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<a href="{{ url_for('projects.edit', project_id=project.id) }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('projects.index') }}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-arrow-left"></i> All Contracts
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
|
||||
{# ── Contract Info ── #}
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-light fw-semibold">
|
||||
<i class="bi bi-info-circle"></i> Contract Details
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-5 text-muted small">Status</dt>
|
||||
<dd class="col-7">
|
||||
<span class="badge bg-{{ 'success' if project.active else 'secondary' }}">
|
||||
{{ 'Active' if project.active else 'Inactive' }}
|
||||
</span>
|
||||
</dd>
|
||||
<dt class="col-5 text-muted small">Contract Manager</dt>
|
||||
<dd class="col-7 small">
|
||||
{{ project.project_manager.display_name if project.project_manager else '—' }}
|
||||
</dd>
|
||||
<dt class="col-5 text-muted small">Created</dt>
|
||||
<dd class="col-7 small">{{ project.created_at.strftime('%Y-%m-%d') }}</dd>
|
||||
<dt class="col-5 text-muted small">Facilities</dt>
|
||||
<dd class="col-7 small">{{ facilities|length }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Facilities ── #}
|
||||
<div class="col-md-8">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<span class="fw-semibold"><i class="bi bi-building"></i> Facilities</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if facilities %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Address</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for f in facilities %}
|
||||
<tr>
|
||||
<td><strong>{{ f.name }}</strong></td>
|
||||
<td class="text-muted small">{{ f.address or '—' }}</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'success' if f.active else 'secondary' }}">
|
||||
{{ 'Active' if f.active else 'Inactive' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('facilities.view_facility', facility_id=f.id) }}"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-3 text-muted small">No facilities linked to this contract yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Customer Assignments ── #}
|
||||
{% if current_user.role == 'admin' %}
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<span class="fw-semibold"><i class="bi bi-people"></i> Customer Assignments</span>
|
||||
<a href="{{ url_for('projects.add_assignment', project_id=project.id) }}"
|
||||
class="btn btn-sm btn-primary">
|
||||
<i class="bi bi-person-plus"></i> Add Customer
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if assignments %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Customer Username</th>
|
||||
<th>Email</th>
|
||||
<th>Facility Scope</th>
|
||||
<th>Assigned</th>
|
||||
<th width="80"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for a in assignments %}
|
||||
<tr>
|
||||
<td><strong>{{ a.user.username }}</strong></td>
|
||||
<td class="text-muted small">{{ a.user.email }}</td>
|
||||
<td>
|
||||
{% if a.facility %}
|
||||
<span class="badge bg-info text-dark">{{ a.facility.name }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">All facilities</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="small text-muted">{{ a.created_at.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
<form method="POST"
|
||||
action="{{ url_for('projects.remove_assignment', assignment_id=a.id) }}"
|
||||
onsubmit="return confirm('Remove assignment for {{ a.user.username }}?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger"
|
||||
title="Remove assignment">
|
||||
<i class="bi bi-person-dash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-3 text-muted small">
|
||||
No customer users assigned yet.
|
||||
<a href="{{ url_for('projects.add_assignment', project_id=project.id) }}">Add one now.</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,44 @@
|
||||
<ul class="nav nav-pills mb-4 flex-wrap gap-1">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'reports.index' else '' }}"
|
||||
href="{{ url_for('reports.index') }}">
|
||||
<i class="bi bi-bar-chart me-1"></i>Overview & Trends
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'reports.issues_aging' else '' }}"
|
||||
href="{{ url_for('reports.issues_aging') }}">
|
||||
<i class="bi bi-clock-history me-1"></i>Issues Aging
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'reports.sla_compliance' else '' }}"
|
||||
href="{{ url_for('reports.sla_compliance') }}">
|
||||
<i class="bi bi-shield-check me-1"></i>SLA Compliance
|
||||
</a>
|
||||
</li>
|
||||
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'reports.followup_closure' else '' }}"
|
||||
href="{{ url_for('reports.followup_closure') }}">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Follow-up Closure
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'reports.inspector_performance' else '' }}"
|
||||
href="{{ url_for('reports.inspector_performance') }}">
|
||||
<i class="bi bi-person-lines-fill me-1"></i>Inspector Performance
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('scheduled_reports.') else '' }}"
|
||||
href="{{ url_for('scheduled_reports.index') }}">
|
||||
<i class="bi bi-calendar-check me-1"></i>Scheduled Reports
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
@@ -0,0 +1,156 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ facility.name }} — Facility Report{% endblock %}
|
||||
{% block extra_css %}
|
||||
<style>.chart-container { position:relative; height:240px; }</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-start mb-4">
|
||||
<div>
|
||||
<h2><i class="bi bi-building"></i> {{ facility.name }}</h2>
|
||||
<p class="text-muted mb-0">{{ start.strftime('%b %d, %Y') }} — {{ end.strftime('%b %d, %Y') }}</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('reports.export_inspections', start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}"
|
||||
class="btn btn-sm btn-outline-success"><i class="bi bi-download"></i> Export CSV</a>
|
||||
<a href="{{ url_for('reports.index') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Reports
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# KPI row #}
|
||||
{% set completed = inspections | selectattr('status','eq','completed') | list %}
|
||||
{% set avg = (completed | map(attribute='overall_score') | select | list) %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card shadow-sm text-center h-100">
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-1">Total Inspections</p>
|
||||
<h3 class="fw-bold">{{ inspections|length }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card shadow-sm text-center h-100">
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-1">Completed</p>
|
||||
<h3 class="fw-bold text-success">{{ completed|length }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card shadow-sm text-center h-100">
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-1">Open Issues</p>
|
||||
<h3 class="fw-bold text-danger">{{ open_issues|length }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card shadow-sm text-center h-100">
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-1">Avg Score</p>
|
||||
{% if avg %}
|
||||
{% set avg_val = (avg | map('float') | sum) / avg|length %}
|
||||
<h3 class="fw-bold text-{{ 'success' if avg_val >= 90 else 'warning' if avg_val >= 70 else 'danger' }}">
|
||||
{{ '%.1f'|format(avg_val) }}%
|
||||
</h3>
|
||||
{% else %}<h3 class="text-muted">—</h3>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Area scores chart #}
|
||||
{% if area_scores %}
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-light"><h6 class="mb-0">Avg Score by Area</h6></div>
|
||||
<div class="card-body"><div class="chart-container"><canvas id="areaChart"></canvas></div></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Inspection history #}
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-light"><h6 class="mb-0">Inspection History</h6></div>
|
||||
<div class="table-responsive">
|
||||
{% if inspections %}
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr><th>Date</th><th>Area</th><th>Template</th><th>Inspector</th><th>Score</th><th>Status</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ins in inspections %}
|
||||
<tr>
|
||||
<td><small>{{ ins.inspection_date.strftime('%Y-%m-%d %H:%M') }}</small></td>
|
||||
<td>{{ ins.area.name if ins.area else '—' }}</td>
|
||||
<td>{{ ins.template.name }}</td>
|
||||
<td>{{ ins.inspector.display_name }}</td>
|
||||
<td>
|
||||
{% if ins.overall_score %}
|
||||
<span class="badge bg-{{ 'success' if ins.overall_score >= 90 else 'warning text-dark' if ins.overall_score >= 70 else 'danger' }}">
|
||||
{{ ins.overall_score }}%
|
||||
</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td><span class="badge bg-{{ 'success' if ins.status == 'completed' else 'danger' if ins.status == 'flagged' else 'secondary' }}">
|
||||
{{ ins.status|replace('_',' ')|title }}</span></td>
|
||||
<td><a href="{{ url_for('inspections.view', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-secondary">View</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="card-body"><p class="text-muted mb-0">No inspections in this date range.</p></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Open issues #}
|
||||
{% if open_issues %}
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-danger text-white"><h6 class="mb-0">Open Issues</h6></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light"><tr><th>Severity</th><th>Area</th><th>Description</th><th>Reported</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for issue in open_issues %}
|
||||
<tr>
|
||||
<td><span class="badge bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning text-dark' if issue.severity == 'medium' else 'secondary' }}">{{ issue.severity|title }}</span></td>
|
||||
<td>{{ issue.area.name }}</td>
|
||||
<td>{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}</td>
|
||||
<td><small>{{ issue.reported_at.strftime('%Y-%m-%d') }}</small></td>
|
||||
<td><a href="{{ url_for('issues.view', issue_id=issue.id) }}" class="btn btn-sm btn-outline-secondary">View</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{% if area_scores %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
new Chart(document.getElementById('areaChart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: {{ area_scores | map(attribute='name') | list | tojson }},
|
||||
datasets: [{
|
||||
label: 'Avg Score (%)',
|
||||
data: {{ area_scores | map(attribute='avg_score') | list | tojson }},
|
||||
backgroundColor: {{ area_scores | map(attribute='avg_score') | list | tojson }}
|
||||
.map(s => s >= 90 ? '#198754' : s >= 70 ? '#ffc107' : '#dc3545'),
|
||||
borderRadius: 4,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
scales: { y: { min: 0, max: 100, ticks: { callback: v => v + '%' } } },
|
||||
plugins: { legend: { display: false } }
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,186 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Follow-up Closure Rate{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{# ── Sub-nav ── #}
|
||||
{% include 'reports/_subnav.html' %}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-start mb-4">
|
||||
<div>
|
||||
<h2><i class="bi bi-arrow-repeat text-purple me-2" style="color:#7c3aed;"></i>Follow-up Closure Rate</h2>
|
||||
<p class="text-muted mb-0">
|
||||
{{ start.strftime('%b %d, %Y') }} — {{ end.strftime('%b %d, %Y') }}
|
||||
· Inspections flagged for follow-up and whether a re-inspection was completed.
|
||||
</p>
|
||||
</div>
|
||||
<a href="{{ url_for('reports.export_followup_closure', start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}"
|
||||
class="btn btn-sm btn-outline-success">
|
||||
<i class="bi bi-file-earmark-excel me-1"></i>Export Excel
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# ── Date filter ── #}
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body py-2">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1">From</label>
|
||||
<input type="date" name="start" class="form-control form-control-sm" value="{{ start.strftime('%Y-%m-%d') }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1">To</label>
|
||||
<input type="date" name="end" class="form-control form-control-sm" value="{{ end.strftime('%Y-%m-%d') }}">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-sm btn-primary">Apply</button>
|
||||
<a href="{{ url_for('reports.followup_closure') }}" class="btn btn-sm btn-outline-secondary">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── KPI row ── #}
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-md-4">
|
||||
<div class="card shadow-sm h-100" style="border-left:5px solid #7c3aed;">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small fw-semibold mb-1">Flagged for Follow-up</div>
|
||||
<div class="fs-1 fw-bold" style="color:#7c3aed;">{{ total }}</div>
|
||||
<div class="text-muted small">inspections in period</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-4">
|
||||
<div class="card shadow-sm h-100" style="border-left:5px solid #16a34a;">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small fw-semibold mb-1">Re-inspected</div>
|
||||
<div class="fs-1 fw-bold text-success">{{ closed }}</div>
|
||||
<div class="text-muted small">follow-up completed</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-4">
|
||||
<div class="card shadow-sm h-100"
|
||||
style="border-left:5px solid {{ '#16a34a' if rate and rate >= 80 else '#d97706' if rate and rate >= 50 else '#dc2626' }};">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small fw-semibold mb-1">Closure Rate</div>
|
||||
<div class="fs-1 fw-bold
|
||||
{{ 'text-success' if rate and rate >= 80 else 'text-warning' if rate and rate >= 50 else 'text-danger' if rate is not none else 'text-muted' }}">
|
||||
{{ rate|round(1) if rate is not none else '—' }}{% if rate is not none %}%{% endif %}
|
||||
</div>
|
||||
<div class="text-muted small">of flagged inspections re-done</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if total == 0 %}
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle me-2"></i>No inspections were flagged for follow-up in this period.
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
{# ── By facility ── #}
|
||||
{% if by_facility %}
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-light fw-semibold">
|
||||
<i class="bi bi-building me-1"></i>Closure Rate by Facility
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Facility</th>
|
||||
<th class="text-center">Flagged</th>
|
||||
<th class="text-center">Re-inspected</th>
|
||||
<th class="text-center">Closure Rate</th>
|
||||
<th>Progress</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in by_facility %}
|
||||
<tr>
|
||||
<td class="fw-semibold">{{ row.name }}</td>
|
||||
<td class="text-center">{{ row.total }}</td>
|
||||
<td class="text-center">{{ row.closed }}</td>
|
||||
<td class="text-center">
|
||||
<span class="badge bg-{{ 'success' if row.rate >= 80 else 'warning text-dark' if row.rate >= 50 else 'danger' }} px-3">
|
||||
{{ row.rate }}%
|
||||
</span>
|
||||
</td>
|
||||
<td style="min-width:120px;">
|
||||
<div class="progress" style="height:8px;">
|
||||
<div class="progress-bar bg-{{ 'success' if row.rate >= 80 else 'warning' if row.rate >= 50 else 'danger' }}"
|
||||
style="width:{{ row.rate }}%;"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Detail table ── #}
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light d-flex justify-content-between">
|
||||
<span class="fw-semibold"><i class="bi bi-list-ul me-1"></i>Inspection Detail</span>
|
||||
<span class="text-muted small">{{ total }} flagged</span>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Date</th>
|
||||
<th>Facility</th>
|
||||
<th>Template</th>
|
||||
<th>Inspector</th>
|
||||
<th class="text-center">Score</th>
|
||||
<th>Note</th>
|
||||
<th class="text-center">Re-inspected?</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for insp in flagged %}
|
||||
<tr class="{{ '' if insp._has_followup else 'table-warning' }}">
|
||||
<td class="text-muted small">#{{ insp.id }}</td>
|
||||
<td class="small">{{ insp.inspection_date.strftime('%Y-%m-%d') if insp.inspection_date else '—' }}</td>
|
||||
<td class="small">{{ insp.facility.name if insp.facility else '—' }}</td>
|
||||
<td class="small">{{ insp.template.name if insp.template else '—' }}</td>
|
||||
<td class="small">{{ insp.inspector.display_name if insp.inspector else '—' }}</td>
|
||||
<td class="text-center">
|
||||
{% if insp.overall_score %}
|
||||
<span class="badge bg-{{ 'success' if insp.overall_score >= 80 else 'warning text-dark' if insp.overall_score >= 60 else 'danger' }}">
|
||||
{{ insp.overall_score|round(1) }}%
|
||||
</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="small text-muted">
|
||||
{{ insp.follow_up_note[:60] if insp.follow_up_note else '—' }}
|
||||
{% if insp.follow_up_note and insp.follow_up_note|length > 60 %}…{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if insp._has_followup %}
|
||||
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Yes</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>No</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('inspections.view', inspection_id=insp.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endif %}{# end total == 0 #}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,319 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Reports & Analytics{% endblock %}
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.stat-card { border-left: 4px solid; }
|
||||
.stat-card.primary { border-color: #0d6efd; }
|
||||
.stat-card.success { border-color: #198754; }
|
||||
.stat-card.danger { border-color: #dc3545; }
|
||||
.stat-card.info { border-color: #0dcaf0; }
|
||||
.chart-container { position:relative; height:280px; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{# ── Sub-nav ── #}
|
||||
{% include 'reports/_subnav.html' %}
|
||||
|
||||
{# ── Header + date filter ── #}
|
||||
<div class="d-flex justify-content-between align-items-start mb-4">
|
||||
<div>
|
||||
<h2><i class="bi bi-graph-up"></i> Reports & Analytics</h2>
|
||||
<p class="text-muted mb-0">{{ start.strftime('%b %d, %Y') }} — {{ end.strftime('%b %d, %Y') }}</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('reports.export_inspections', start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}"
|
||||
class="btn btn-sm btn-outline-success"><i class="bi bi-download"></i> Export Inspections CSV</a>
|
||||
<a href="{{ url_for('reports.export_issues', start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}"
|
||||
class="btn btn-sm btn-outline-danger"><i class="bi bi-download"></i> Export Issues CSV</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body py-2">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1">From</label>
|
||||
<input type="date" name="start" class="form-control form-control-sm"
|
||||
value="{{ start.strftime('%Y-%m-%d') }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1">To</label>
|
||||
<input type="date" name="end" class="form-control form-control-sm"
|
||||
value="{{ end.strftime('%Y-%m-%d') }}">
|
||||
</div>
|
||||
{% if inspectors %}
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1">Inspector</label>
|
||||
<select name="inspector_id" class="form-select form-select-sm">
|
||||
<option value="">All Inspectors</option>
|
||||
{% for u in inspectors %}
|
||||
<option value="{{ u.id }}" {% if inspector_filter == u.id %}selected{% endif %}>
|
||||
{{ u.display_name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-sm btn-primary">Apply</button>
|
||||
<a href="{{ url_for('reports.index') }}" class="btn btn-sm btn-outline-secondary">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── KPI cards ── #}
|
||||
<div class="row mb-4">
|
||||
{% for label, value, color, icon in [
|
||||
('Total Inspections', total_inspections, 'primary', 'bi-clipboard-data'),
|
||||
('Completed', completed, 'success', 'bi-check-circle'),
|
||||
('Open Issues', flagged, 'danger', 'bi-flag'),
|
||||
('Avg Score', (avg_score|string + '%') if avg_score else '—', 'info', 'bi-graph-up'),
|
||||
] %}
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card shadow-sm stat-card {{ color }} h-100">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<i class="bi {{ icon }} text-{{ color }}" style="font-size:2rem;"></i>
|
||||
<div>
|
||||
<p class="text-muted small mb-0">{{ label }}</p>
|
||||
<h3 class="mb-0 fw-bold">{{ value }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# ── Charts row 1 ── #}
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-8 mb-3">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-light"><h6 class="mb-0"><i class="bi bi-graph-up-arrow"></i> Score Trend</h6></div>
|
||||
<div class="card-body"><div class="chart-container"><canvas id="trendChart"></canvas></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 mb-3">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-light"><h6 class="mb-0"><i class="bi bi-exclamation-triangle"></i> Issues by Severity</h6></div>
|
||||
<div class="card-body"><div class="chart-container"><canvas id="severityChart"></canvas></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Charts row 2 ── #}
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-8 mb-3">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-light"><h6 class="mb-0"><i class="bi bi-building"></i> Avg Score by Facility</h6></div>
|
||||
<div class="card-body"><div class="chart-container"><canvas id="facilityChart"></canvas></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 mb-3">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-light"><h6 class="mb-0"><i class="bi bi-pie-chart"></i> Issue Status</h6></div>
|
||||
<div class="card-body"><div class="chart-container"><canvas id="statusChart"></canvas></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Facility period-over-period comparison table ── #}
|
||||
{% if facility_scores %}
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-light d-flex align-items-center gap-2">
|
||||
<h6 class="mb-0"><i class="bi bi-building"></i> Facility Score Comparison</h6>
|
||||
<span class="text-muted small">vs. prior equal-length period</span>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Facility</th>
|
||||
<th class="text-end">Current Period</th>
|
||||
<th class="text-end">Prior Period</th>
|
||||
<th class="text-end">Change</th>
|
||||
<th class="text-end">Inspections</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in facility_scores %}
|
||||
<tr>
|
||||
<td class="fw-semibold">{{ row.name }}</td>
|
||||
<td class="text-end">
|
||||
<span class="badge bg-{{ 'success' if row.avg_score >= 90 else 'warning text-dark' if row.avg_score >= 70 else 'danger' }}">
|
||||
{{ '%.1f'|format(row.avg_score|float) }}%
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end text-muted">
|
||||
{% if row.prior_avg is not none %}
|
||||
{{ '%.1f'|format(row.prior_avg|float) }}%
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
{% if row.delta is not none %}
|
||||
{% if row.delta > 0 %}
|
||||
<span class="badge bg-success-subtle text-success border border-success fw-semibold">
|
||||
<i class="bi bi-arrow-up-short"></i>+{{ '%.1f'|format(row.delta|float) }}
|
||||
</span>
|
||||
{% elif row.delta < 0 %}
|
||||
<span class="badge bg-danger-subtle text-danger border border-danger fw-semibold">
|
||||
<i class="bi bi-arrow-down-short"></i>{{ '%.1f'|format(row.delta|float) }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary-subtle text-secondary border fw-semibold">
|
||||
<i class="bi bi-dash"></i> 0.0
|
||||
</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted small">No prior data</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end text-muted small">{{ row.count }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Top inspectors table ── #}
|
||||
{% if top_inspectors %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light"><h6 class="mb-0"><i class="bi bi-person-check"></i> Top Inspectors</h6></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light"><tr><th>Inspector</th><th>Inspections</th><th>Avg Score</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in top_inspectors %}
|
||||
<tr>
|
||||
<td>{{ row.display_name }}</td>
|
||||
<td>{{ row.count }}</td>
|
||||
<td>
|
||||
{% if row.avg_score %}
|
||||
<span class="badge bg-{{ 'success' if row.avg_score >= 90 else 'warning text-dark' if row.avg_score >= 70 else 'danger' }}">
|
||||
{{ '%.1f'|format(row.avg_score|float) }}%
|
||||
</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Critical / High open issues ── #}
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-danger text-white"><h6 class="mb-0"><i class="bi bi-fire"></i> Open Critical / High Issues</h6></div>
|
||||
{% if critical_issues %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light"><tr><th>Severity</th><th>Area</th><th>Description</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for issue in critical_issues %}
|
||||
<tr>
|
||||
<td><span class="badge bg-danger">{{ issue.severity|title }}</span></td>
|
||||
<td>{{ issue.area.name }}</td>
|
||||
<td>{{ issue.description[:50] }}{% if issue.description|length > 50 %}…{% endif %}</td>
|
||||
<td><a href="{{ url_for('issues.view', issue_id=issue.id) }}" class="btn btn-sm btn-outline-secondary">View</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-body"><p class="text-muted mb-0">No open critical or high issues. 🎉</p></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
Chart.defaults.font.family = "'Segoe UI', system-ui, sans-serif";
|
||||
Chart.defaults.color = '#6c757d';
|
||||
|
||||
const BLUE = '#0d6efd', GREEN = '#198754', RED = '#dc3545',
|
||||
AMBER = '#ffc107', TEAL = '#0dcaf0', GRAY = '#adb5bd';
|
||||
|
||||
// ── Trend chart ──────────────────────────────────────────────────────────────
|
||||
new Chart(document.getElementById('trendChart'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: {{ daily_scores | map(attribute='day') | map('string') | list | tojson }},
|
||||
datasets: [{
|
||||
label: 'Avg Score (%)',
|
||||
data: {{ daily_scores | map(attribute='avg') | list | tojson }},
|
||||
borderColor: BLUE, backgroundColor: 'rgba(13,110,253,.1)',
|
||||
tension: .3, fill: true, pointRadius: 4,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
scales: { y: { min: 0, max: 100, ticks: { callback: v => v + '%' } } },
|
||||
plugins: { legend: { display: false } }
|
||||
}
|
||||
});
|
||||
|
||||
// ── Facility bar chart ────────────────────────────────────────────────────────
|
||||
new Chart(document.getElementById('facilityChart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: {{ facility_scores | map(attribute='name') | list | tojson }},
|
||||
datasets: [{
|
||||
label: 'Avg Score (%)',
|
||||
data: {{ facility_scores | map(attribute='avg_score') | list | tojson }},
|
||||
backgroundColor: {{ facility_scores | map(attribute='avg_score') | list | tojson }}
|
||||
.map(s => s >= 90 ? GREEN : s >= 70 ? AMBER : RED),
|
||||
borderRadius: 4,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
scales: { y: { min: 0, max: 100, ticks: { callback: v => v + '%' } } },
|
||||
plugins: { legend: { display: false } }
|
||||
}
|
||||
});
|
||||
|
||||
// ── Severity doughnut ─────────────────────────────────────────────────────────
|
||||
const sevData = {{ issue_severity | tojson }};
|
||||
new Chart(document.getElementById('severityChart'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: sevData.map(r => r.severity ? r.severity.charAt(0).toUpperCase() + r.severity.slice(1) : 'Unknown'),
|
||||
datasets: [{
|
||||
data: sevData.map(r => r.count),
|
||||
backgroundColor: sevData.map(r => ({critical:RED,high:'#fd7e14',medium:AMBER,low:GRAY}[r.severity] || GRAY)),
|
||||
}]
|
||||
},
|
||||
options: { responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'bottom' } } }
|
||||
});
|
||||
|
||||
// ── Status pie ────────────────────────────────────────────────────────────────
|
||||
const stData = {{ issue_status | tojson }};
|
||||
new Chart(document.getElementById('statusChart'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: stData.map(r => r.status ? r.status.replace('_',' ').replace(/\b\w/g,c=>c.toUpperCase()) : 'Unknown'),
|
||||
datasets: [{
|
||||
data: stData.map(r => r.count),
|
||||
backgroundColor: stData.map(r => ({open:RED, in_progress:AMBER, resolved:GREEN}[r.status] || GRAY)),
|
||||
}]
|
||||
},
|
||||
options: { responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'bottom' } } }
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,395 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Inspector Performance{% endblock %}
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.stat-card { border-left: 4px solid; }
|
||||
.stat-card.primary { border-color: #0d6efd; }
|
||||
.stat-card.success { border-color: #198754; }
|
||||
.stat-card.danger { border-color: #dc3545; }
|
||||
.stat-card.info { border-color: #0dcaf0; }
|
||||
.stat-card.warning { border-color: #ffc107; }
|
||||
.chart-container { position:relative; height:280px; }
|
||||
.inspector-row { cursor:pointer; }
|
||||
.inspector-row.table-active td { background-color: #e8f0fe !important; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{# ── Sub-nav ── #}
|
||||
{% include 'reports/_subnav.html' %}
|
||||
|
||||
{# ── Header ── #}
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<h2><i class="bi bi-person-lines-fill"></i> Inspector Performance</h2>
|
||||
<p class="text-muted mb-0">{{ start.strftime('%b %d, %Y') }} — {{ end.strftime('%b %d, %Y') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('reports.export_inspector_performance',
|
||||
start=start.strftime('%Y-%m-%d'),
|
||||
end=end.strftime('%Y-%m-%d'),
|
||||
**({'inspector_id': selected_id} if selected_id else {})) }}"
|
||||
class="btn btn-sm btn-success">
|
||||
<i class="bi bi-file-earmark-spreadsheet me-1"></i>Export Excel
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Date filter ── #}
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body py-2">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1">From</label>
|
||||
<input type="date" name="start" class="form-control form-control-sm"
|
||||
value="{{ start.strftime('%Y-%m-%d') }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1">To</label>
|
||||
<input type="date" name="end" class="form-control form-control-sm"
|
||||
value="{{ end.strftime('%Y-%m-%d') }}">
|
||||
</div>
|
||||
{% if selected_id %}
|
||||
<input type="hidden" name="inspector_id" value="{{ selected_id }}">
|
||||
{% endif %}
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-sm btn-primary">Apply</button>
|
||||
<a href="{{ url_for('reports.inspector_performance') }}" class="btn btn-sm btn-outline-secondary">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not inspector_stats %}
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
No inspections found for the selected date range.
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
{# ── Comparison charts ── #}
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-7 mb-3">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-light"><h6 class="mb-0"><i class="bi bi-bar-chart"></i> Avg Score by Inspector</h6></div>
|
||||
<div class="card-body"><div class="chart-container"><canvas id="scoreChart"></canvas></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5 mb-3">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-light"><h6 class="mb-0"><i class="bi bi-clipboard-data"></i> Inspections Completed</h6></div>
|
||||
<div class="card-body"><div class="chart-container"><canvas id="countChart"></canvas></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Summary table ── #}
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0"><i class="bi bi-table me-1"></i>All Inspectors — Summary</h6>
|
||||
<span class="badge bg-secondary">{{ inspector_stats|length }} inspector{{ 's' if inspector_stats|length != 1 }}</span>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Inspector</th>
|
||||
<th class="text-center">Total</th>
|
||||
<th class="text-center">Completed</th>
|
||||
<th class="text-center">Completion Rate</th>
|
||||
<th class="text-center">Avg Score</th>
|
||||
<th class="text-center">
|
||||
vs. Avg
|
||||
{% if team_avg_score %}<span class="text-muted fw-normal small">({{ '%.1f'|format(team_avg_score) }}%)</span>{% endif %}
|
||||
</th>
|
||||
<th class="text-center">Avg Time</th>
|
||||
<th class="text-center">Issues Flagged</th>
|
||||
<th class="text-center">Follow-ups</th>
|
||||
<th class="text-center">Facilities</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in inspector_stats %}
|
||||
<tr class="inspector-row {% if selected_id == s.id %}table-active{% endif %}"
|
||||
data-inspector-id="{{ s.id }}">
|
||||
<td class="fw-semibold">
|
||||
{{ s.display_name }}
|
||||
</td>
|
||||
<td class="text-center">{{ s.total }}</td>
|
||||
<td class="text-center">{{ s.completed }}</td>
|
||||
<td class="text-center">
|
||||
<div class="d-flex align-items-center justify-content-center gap-2">
|
||||
<div class="progress flex-grow-1" style="height:6px;max-width:60px;">
|
||||
<div class="progress-bar bg-{{ 'success' if s.completion_rate >= 90 else 'warning' if s.completion_rate >= 70 else 'danger' }}"
|
||||
style="width:{{ s.completion_rate }}%;"></div>
|
||||
</div>
|
||||
<span class="small">{{ s.completion_rate }}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if s.avg_score %}
|
||||
<span class="badge bg-{{ 'success' if s.avg_score >= 90 else 'warning text-dark' if s.avg_score >= 70 else 'danger' }}">
|
||||
{{ '%.1f'|format(s.avg_score) }}%
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if s.vs_avg is not none %}
|
||||
{% if s.vs_avg > 0 %}
|
||||
<span class="badge bg-success">+{{ '%.1f'|format(s.vs_avg) }}%</span>
|
||||
{% elif s.vs_avg < 0 %}
|
||||
<span class="badge bg-danger">{{ '%.1f'|format(s.vs_avg) }}%</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">0.0%</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="small text-muted">{{ s.avg_time or '—' }}</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if s.issues_flagged > 0 %}
|
||||
<span class="badge bg-danger">{{ s.issues_flagged }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">0</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if s.follow_ups > 0 %}
|
||||
<span class="badge bg-warning text-dark">{{ s.follow_ups }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">0</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center">{{ s.facilities }}</td>
|
||||
<td>
|
||||
{% if selected_id == s.id %}
|
||||
<a href="{{ url_for('reports.inspector_performance', start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}"
|
||||
class="btn btn-sm btn-outline-secondary">Close</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('reports.inspector_performance', inspector_id=s.id, start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}"
|
||||
class="btn btn-sm btn-outline-primary">Details</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Individual inspector drill-down ── #}
|
||||
{% if selected_inspector and selected_kpis %}
|
||||
<div class="card shadow-sm border-primary mb-4" id="inspectorDetail">
|
||||
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-person-circle me-2"></i>{{ selected_inspector.display_name }}
|
||||
</h6>
|
||||
<a href="{{ url_for('reports.inspector_performance', start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}"
|
||||
class="btn btn-sm btn-light text-primary">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
{# KPI stat row #}
|
||||
<div class="row g-3 mb-4">
|
||||
{% for label, value, color, icon in [
|
||||
('Total Inspections', selected_kpis.total, 'primary', 'bi-clipboard-data'),
|
||||
('Completed', selected_kpis.completed, 'success', 'bi-check-circle'),
|
||||
('Avg Score', (('%.1f'|format(selected_kpis.avg_score)) + '%') if selected_kpis.avg_score else '—', 'info', 'bi-graph-up'),
|
||||
('Avg Completion Time',selected_kpis.avg_time or '—', 'info', 'bi-stopwatch'),
|
||||
('Issues Flagged', selected_kpis.issues_flagged, 'danger', 'bi-flag'),
|
||||
('Follow-ups Req.', selected_kpis.follow_ups, 'warning', 'bi-arrow-repeat'),
|
||||
('Facilities Covered', selected_kpis.facilities, 'primary', 'bi-building'),
|
||||
] %}
|
||||
<div class="col-6 col-md-4 col-lg-3 col-xl-auto" style="min-width:130px;">
|
||||
<div class="card shadow-sm stat-card {{ color }} h-100">
|
||||
<div class="card-body py-2 px-3">
|
||||
<p class="text-muted small mb-1">{{ label }}</p>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="bi {{ icon }} text-{{ color }}"></i>
|
||||
<span class="fw-bold">{{ value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Score trend chart #}
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light">
|
||||
<h6 class="mb-0"><i class="bi bi-graph-up-arrow me-1"></i>Score Trend — {{ selected_inspector.display_name }}</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if trend_data %}
|
||||
<div class="chart-container"><canvas id="trendChart"></canvas></div>
|
||||
{% else %}
|
||||
<p class="text-muted text-center py-3 mb-0">No completed inspections with scores in this period.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Recent inspections #}
|
||||
{% if recent_inspections %}
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0"><i class="bi bi-list-ul me-1"></i>Recent Inspections</h6>
|
||||
<span class="badge bg-secondary">{{ recent_inspections|length }}</span>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Facility</th>
|
||||
<th>Area</th>
|
||||
<th>Template</th>
|
||||
<th class="text-center">Score</th>
|
||||
<th class="text-center">Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ins in recent_inspections %}
|
||||
<tr>
|
||||
<td class="small">{{ ins.inspection_date.strftime('%b %d, %Y') }}</td>
|
||||
<td class="small">{{ ins.facility.name if ins.facility else '—' }}</td>
|
||||
<td class="small">{{ ins.area.name if ins.area else '—' }}</td>
|
||||
<td class="small">{{ ins.template.name if ins.template else '—' }}</td>
|
||||
<td class="text-center">
|
||||
{% if ins.overall_score %}
|
||||
<span class="badge bg-{{ 'success' if ins.overall_score >= 90 else 'warning text-dark' if ins.overall_score >= 70 else 'danger' }}">
|
||||
{{ '%.1f'|format(ins.overall_score|float) }}%
|
||||
</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% set sc = ins.status %}
|
||||
<span class="badge bg-{{ 'success' if sc == 'completed' else 'warning text-dark' if sc == 'flagged' else 'secondary' }}">
|
||||
{{ 'Submitted' if sc == 'completed' else sc.replace('_',' ')|title }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('inspections.view', inspection_id=ins.id) }}"
|
||||
class="btn btn-xs btn-outline-secondary" style="font-size:.75rem;padding:2px 8px;">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}{# end if inspector_stats #}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
Chart.defaults.font.family = "'Segoe UI', system-ui, sans-serif";
|
||||
Chart.defaults.color = '#6c757d';
|
||||
|
||||
const BLUE = '#0d6efd', GREEN = '#198754', RED = '#dc3545',
|
||||
AMBER = '#ffc107', GRAY = '#adb5bd';
|
||||
|
||||
function scoreColor(s) { return s >= 90 ? GREEN : s >= 70 ? AMBER : RED; }
|
||||
|
||||
{% if inspector_stats %}
|
||||
const stats = {{ inspector_stats | tojson }};
|
||||
|
||||
// ── Avg score bar chart ───────────────────────────────────────────────────────
|
||||
new Chart(document.getElementById('scoreChart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: stats.map(s => s.display_name),
|
||||
datasets: [{
|
||||
label: 'Avg Score (%)',
|
||||
data: stats.map(s => s.avg_score),
|
||||
backgroundColor: stats.map(s => s.avg_score ? scoreColor(s.avg_score) : GRAY),
|
||||
borderRadius: 4,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
scales: { y: { min: 0, max: 100, ticks: { callback: v => v + '%' } } },
|
||||
plugins: { legend: { display: false } }
|
||||
}
|
||||
});
|
||||
|
||||
// ── Inspection count chart ────────────────────────────────────────────────────
|
||||
new Chart(document.getElementById('countChart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: stats.map(s => s.display_name),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Completed',
|
||||
data: stats.map(s => s.completed),
|
||||
backgroundColor: GREEN,
|
||||
borderRadius: 4,
|
||||
stack: 'a',
|
||||
},
|
||||
{
|
||||
label: 'Other',
|
||||
data: stats.map(s => s.total - s.completed),
|
||||
backgroundColor: GRAY,
|
||||
borderRadius: 4,
|
||||
stack: 'a',
|
||||
},
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } },
|
||||
plugins: { legend: { position: 'bottom' } }
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
{% if trend_data %}
|
||||
// ── Individual inspector score trend ─────────────────────────────────────────
|
||||
new Chart(document.getElementById('trendChart'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: {{ trend_data | map(attribute='day') | list | tojson }},
|
||||
datasets: [{
|
||||
label: 'Avg Score (%)',
|
||||
data: {{ trend_data | map(attribute='avg') | list | tojson }},
|
||||
borderColor: BLUE, backgroundColor: 'rgba(13,110,253,.1)',
|
||||
tension: .3, fill: true, pointRadius: 4,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
scales: { y: { min: 0, max: 100, ticks: { callback: v => v + '%' } } },
|
||||
plugins: { legend: { display: false } }
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
{% if selected_id %}
|
||||
// Scroll to detail panel on load
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var el = document.getElementById('inspectorDetail');
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,175 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Issues Aging{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{# ── Sub-nav ── #}
|
||||
{% include 'reports/_subnav.html' %}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-start mb-4">
|
||||
<div>
|
||||
<h2><i class="bi bi-clock-history text-danger me-2"></i>Issues Aging</h2>
|
||||
<p class="text-muted mb-0">All currently open issues grouped by how long they have been waiting.</p>
|
||||
</div>
|
||||
<a href="{{ url_for('reports.export_issues_aging', severity=severity_filter, facility_id=facility_id_filter or '') }}"
|
||||
class="btn btn-sm btn-outline-success">
|
||||
<i class="bi bi-file-earmark-excel me-1"></i>Export Excel
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# ── Filters ── #}
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body py-2">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1">Severity</label>
|
||||
<select name="severity" class="form-select form-select-sm">
|
||||
<option value="">All</option>
|
||||
{% for s in ['critical','high','medium','low'] %}
|
||||
<option value="{{ s }}" {{ 'selected' if severity_filter == s }}>{{ s|title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small mb-1">Facility</label>
|
||||
<select name="facility_id" class="form-select form-select-sm">
|
||||
<option value="">All Facilities</option>
|
||||
{% for f in facilities %}
|
||||
<option value="{{ f.id }}" {{ 'selected' if facility_id_filter == f.id }}>{{ f.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-sm btn-primary">Apply</button>
|
||||
<a href="{{ url_for('reports.issues_aging') }}" class="btn btn-sm btn-outline-secondary">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── KPI row ── #}
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-md-4">
|
||||
<div class="card shadow-sm border-0 h-100" style="background:#fff7ed;">
|
||||
<div class="card-body">
|
||||
<div class="small fw-semibold text-muted mb-1">Total Open</div>
|
||||
<div class="fs-1 fw-bold" style="color:#ea580c;">{{ total }}</div>
|
||||
<div class="small text-muted">unresolved issues</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-4">
|
||||
<div class="card shadow-sm border-0 h-100" style="background:#fef2f2;">
|
||||
<div class="card-body">
|
||||
<div class="small fw-semibold text-muted mb-1">SLA Breached</div>
|
||||
<div class="fs-1 fw-bold text-danger">{{ sla_breached }}</div>
|
||||
<div class="small text-muted">past resolution deadline</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-4">
|
||||
<div class="card shadow-sm border-0 h-100" style="background:#fefce8;">
|
||||
<div class="card-body">
|
||||
<div class="small fw-semibold text-muted mb-1">SLA At Risk</div>
|
||||
<div class="fs-1 fw-bold text-warning">{{ sla_at_risk }}</div>
|
||||
<div class="small text-muted">approaching deadline</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Bucket accordions ── #}
|
||||
<div class="accordion" id="agingAccordion">
|
||||
{% for label in bucket_labels %}
|
||||
{% set bucket = buckets[label] %}
|
||||
{% set bucket_id = 'bucket-' ~ loop.index %}
|
||||
{% set is_danger = label in ['>4 weeks', '1–4 weeks'] %}
|
||||
{% set is_warning = label == '3–7 days' %}
|
||||
<div class="accordion-item mb-2 shadow-sm border-0">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button {{ 'collapsed' if loop.index > 1 else '' }} fw-semibold"
|
||||
type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#{{ bucket_id }}">
|
||||
<span class="badge rounded-pill me-2
|
||||
{{ 'bg-danger' if is_danger else 'bg-warning text-dark' if is_warning else 'bg-secondary' }}">
|
||||
{{ bucket|length }}
|
||||
</span>
|
||||
{{ label }}
|
||||
{% if is_danger and bucket|length > 0 %}
|
||||
<span class="ms-2 badge bg-danger bg-opacity-25 text-danger" style="font-size:.7rem;">Needs attention</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
</h2>
|
||||
<div id="{{ bucket_id }}" class="accordion-collapse collapse {{ 'show' if loop.index == 1 else '' }}"
|
||||
data-bs-parent="#agingAccordion">
|
||||
<div class="accordion-body p-0">
|
||||
{% if bucket %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Age</th>
|
||||
<th>Severity</th>
|
||||
<th>Facility / Area</th>
|
||||
<th>Description</th>
|
||||
<th>Status</th>
|
||||
<th>SLA</th>
|
||||
<th>Assigned</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in bucket %}
|
||||
{% set issue = item.issue %}
|
||||
{% set sla = item.sla %}
|
||||
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
|
||||
<td class="text-muted small">#{{ issue.id }}</td>
|
||||
<td class="text-nowrap small">{{ item.age_h }}h</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning text-dark' if issue.severity == 'medium' else 'secondary' }}">
|
||||
{{ issue.severity|title }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="small">
|
||||
{{ issue.resolved_facility.name if issue.resolved_facility else '—' }}<br>
|
||||
<span class="text-muted">{{ issue.area.name if issue.area else '—' }}</span>
|
||||
</td>
|
||||
<td class="small">{{ issue.description[:70] }}{% if issue.description|length > 70 %}…{% endif %}</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'info text-dark' if issue.status == 'pending_verification' else 'warning text-dark' if issue.status == 'in_progress' else 'secondary' }}">
|
||||
{{ issue.status|replace('_',' ')|title }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if sla == 'breached' %}<span class="badge bg-danger"><i class="bi bi-alarm me-1"></i>Breached</span>
|
||||
{% elif sla == 'at_risk' %}<span class="badge bg-warning text-dark">At Risk</span>
|
||||
{% elif sla == 'ok' %}<span class="badge bg-secondary">OK</span>
|
||||
{% else %}<span class="text-muted small">—</span>{% endif %}
|
||||
</td>
|
||||
<td class="small">{{ issue.assigned_user.display_name if issue.assigned_user else '—' }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('issues.view', issue_id=issue.id) }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-3 text-muted small">No issues in this age range.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if total == 0 %}
|
||||
<div class="alert alert-success mt-3">
|
||||
<i class="bi bi-check-circle me-2"></i>No open issues match the selected filters.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,279 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ facility.name }} — Scorecard{% endblock %}
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.kpi-card { border-left: 4px solid; }
|
||||
.kpi-blue { border-color: #2563eb; }
|
||||
.kpi-green { border-color: #16a34a; }
|
||||
.kpi-yellow { border-color: #d97706; }
|
||||
.kpi-red { border-color: #dc2626; }
|
||||
.chart-container { position: relative; height: 260px; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-start mb-4">
|
||||
<div>
|
||||
<h2><i class="bi bi-speedometer2 text-primary me-2"></i>{{ facility.name }}</h2>
|
||||
<p class="text-muted mb-0">Facility Scorecard — last {{ days }} days</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
{# Period selector #}
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
{% for d, label in [(30,'30d'),(60,'60d'),(90,'90d'),(180,'180d'),(365,'1yr')] %}
|
||||
<a href="{{ url_for('reports.facility_scorecard', facility_id=facility.id, days=d) }}"
|
||||
class="btn btn-outline-secondary {{ 'active' if days == d else '' }}">{{ label }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<a href="{{ url_for('reports.facility_report', facility_id=facility.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-file-text"></i> Full Report
|
||||
</a>
|
||||
<a href="{{ url_for('reports.facility_summary_pdf', facility_id=facility.id, days=days) }}"
|
||||
class="btn btn-sm btn-outline-danger" title="Download customer-facing PDF summary">
|
||||
<i class="bi bi-file-earmark-pdf"></i> PDF Summary
|
||||
</a>
|
||||
<a href="{{ url_for('reports.index') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Reports
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── KPI row ── #}
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm kpi-card kpi-blue h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small fw-semibold mb-1">Total Inspections</div>
|
||||
<div class="fs-2 fw-bold">{{ total_inspections }}</div>
|
||||
<div class="text-muted small">{{ completed_insp }} completed</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm kpi-card {{ 'kpi-green' if avg_score and avg_score >= 80 else 'kpi-yellow' if avg_score and avg_score >= 60 else 'kpi-red' }} h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small fw-semibold mb-1">Avg Score</div>
|
||||
<div class="fs-2 fw-bold">{{ avg_score|round(1) if avg_score else '—' }}{% if avg_score %}%{% endif %}</div>
|
||||
<div class="text-muted small">{{ days }}-day average</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm kpi-card {{ 'kpi-green' if sla_pct and sla_pct >= 90 else 'kpi-yellow' if sla_pct else 'kpi-red' }} h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small fw-semibold mb-1">SLA Compliance</div>
|
||||
<div class="fs-2 fw-bold">{{ sla_pct|round(1) if sla_pct is not none else '—' }}{% if sla_pct is not none %}%{% endif %}</div>
|
||||
<div class="text-muted small">{{ sla_met }}/{{ sla_total }} closed on time</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm kpi-card {{ 'kpi-red' if open_issues|length > 0 else 'kpi-green' }} h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small fw-semibold mb-1">Open Issues</div>
|
||||
<div class="fs-2 fw-bold">{{ open_issues|length }}</div>
|
||||
<div class="text-muted small">
|
||||
{% if pending_verification > 0 %}
|
||||
<span class="text-warning">{{ pending_verification }} pending verification</span>
|
||||
{% else %}
|
||||
across all severities
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
|
||||
{# ── Score trend chart ── #}
|
||||
<div class="col-md-8">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light fw-semibold">
|
||||
<i class="bi bi-graph-up me-1"></i> Score Trend
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if trend_labels %}
|
||||
<div class="chart-container">
|
||||
<canvas id="trendChart"></canvas>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted text-center py-4 mb-0">No completed inspections in this period.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Issue severity breakdown ── #}
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-light fw-semibold">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i> Open Issues by Severity
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% for sev, color in [('critical','danger'),('high','danger'),('medium','warning'),('low','secondary')] %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span class="badge bg-{{ color }} {{ 'text-dark' if color == 'warning' else '' }}">
|
||||
{{ sev|title }}
|
||||
</span>
|
||||
<span class="fw-bold fs-5">{{ sev_counts.get(sev, 0) }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if pending_verification > 0 %}
|
||||
<hr class="my-2">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="badge bg-info text-dark">Pending Verification</span>
|
||||
<span class="fw-bold fs-5">{{ pending_verification }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Area scores ── #}
|
||||
{% if area_scores %}
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light fw-semibold">
|
||||
<i class="bi bi-building me-1"></i> Score by Area
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr><th>Area</th><th class="text-center">Avg Score</th><th class="text-center">Inspections</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for a in area_scores %}
|
||||
<tr>
|
||||
<td>{{ a.name }}</td>
|
||||
<td class="text-center">
|
||||
<span class="badge bg-{{ 'success' if a.avg >= 80 else 'warning text-dark' if a.avg >= 60 else 'danger' }}">
|
||||
{{ a.avg|round(1) }}%
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-center text-muted small">{{ a.count }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Follow-up required ── #}
|
||||
{% if followup_required %}
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm border-warning">
|
||||
<div class="card-header bg-warning text-dark fw-semibold">
|
||||
<i class="bi bi-arrow-repeat me-1"></i> Follow-up Required
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr><th>Inspection</th><th>Date</th><th>Score</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for insp in followup_required %}
|
||||
<tr>
|
||||
<td>#{{ insp.id }} — {{ insp.template.name }}</td>
|
||||
<td class="text-muted small">{{ insp.inspection_date.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
{% if insp.overall_score %}
|
||||
<span class="badge bg-{{ 'success' if insp.overall_score >= 80 else 'warning text-dark' if insp.overall_score >= 60 else 'danger' }}">
|
||||
{{ insp.overall_score|round(1) }}%
|
||||
</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('inspections.view', inspection_id=insp.id) }}"
|
||||
class="btn btn-xs btn-outline-secondary btn-sm">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Open issues list ── #}
|
||||
{% if open_issues %}
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light fw-semibold">
|
||||
<i class="bi bi-bug me-1"></i> Open Issues
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr><th>ID</th><th>Severity</th><th>Area</th><th>Description</th><th>Status</th><th>Reported</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for issue in open_issues %}
|
||||
<tr>
|
||||
<td class="text-muted small">#{{ issue.id }}</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning text-dark' if issue.severity == 'medium' else 'secondary' }}">
|
||||
{{ issue.severity|title }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="small">{{ issue.area.name }}</td>
|
||||
<td class="small">{{ issue.description[:80] }}{% if issue.description|length > 80 %}…{% endif %}</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'info text-dark' if issue.status == 'pending_verification' else 'warning text-dark' if issue.status == 'in_progress' else 'secondary' }}">
|
||||
{{ issue.status|replace('_',' ')|title }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-muted small">{{ issue.reported_at.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('issues.view', issue_id=issue.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
{% if trend_labels %}
|
||||
(function () {
|
||||
const ctx = document.getElementById('trendChart').getContext('2d');
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: {{ trend_labels | tojson }},
|
||||
datasets: [{
|
||||
label: 'Avg Score (%)',
|
||||
data: {{ trend_data | tojson }},
|
||||
borderColor: '#2563eb',
|
||||
backgroundColor: 'rgba(37,99,235,.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
y: { min: 0, max: 100, ticks: { callback: v => v + '%' } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}());
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user