First commit

This commit is contained in:
2026-06-26 09:04:34 -04:00
commit 77678ed724
166 changed files with 34842 additions and 0 deletions
+11
View File
@@ -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
View File
@@ -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
+1041
View File
File diff suppressed because it is too large Load Diff
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<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>.
+312
View File
@@ -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
View File
@@ -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
+40
View File
@@ -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.")
+53
View File
@@ -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
View File
@@ -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})
+187
View File
@@ -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})
+109
View File
@@ -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
+87
View File
@@ -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)
+194
View File
@@ -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)})
+601
View File
@@ -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
+435
View File
@@ -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)})
+74
View File
@@ -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
+165
View File
@@ -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})
+100
View File
@@ -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})
+178
View File
@@ -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,
})
+149
View File
@@ -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)})
+8
View File
@@ -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
+132
View File
@@ -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}>'
+35
View File
@@ -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}>')
+28
View File
@@ -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}>'
+39
View File
@@ -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}>'
+99
View File
@@ -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}>'
+25
View File
@@ -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}>'
+118
View File
@@ -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}>'
+108
View File
@@ -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}>')
+228
View File
@@ -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()
+63
View File
@@ -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}>')
+69
View File
@@ -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}>'
+24
View File
@@ -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}>'
+41
View File
@@ -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}>'
+100
View File
@@ -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}>'
View File
+150
View File
@@ -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'))
+519
View File
@@ -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)
+140
View File
@@ -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'))
+785
View File
@@ -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])
+427
View File
@@ -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)
+238
View File
@@ -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
+1081
View File
File diff suppressed because it is too large Load Diff
+308
View File
@@ -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})
+668
View File
@@ -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
+524
View File
@@ -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})
+371
View File
@@ -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()
+443
View File
@@ -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})
+317
View File
@@ -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));
}
}
+300
View File
@@ -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; }
+27
View File
@@ -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 %}
+225
View File
@@ -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 %}
+254
View File
@@ -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) }}">
&laquo; 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 &raquo;
</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 %}
+111
View File
@@ -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 &amp; 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 %}
+79
View File
@@ -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 %}
+334
View File
@@ -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 &mdash; 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">
&copy; <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 %}
+218
View File
@@ -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 %}
+224
View File
@@ -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 %}
+151
View File
@@ -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 &amp; 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>
+130
View File
@@ -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 %}
+99
View File
@@ -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 %}
+463
View File
@@ -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,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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>
+122
View File
@@ -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 %}
+160
View File
@@ -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 &amp; 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 %}
+152
View File
@@ -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') }}
&nbsp;
<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 %}
+75
View File
@@ -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 &amp; 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 %}
+249
View File
@@ -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 %}
+179
View File
@@ -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;">
3100 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 &amp; 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>
+483
View File
@@ -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 &amp; 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 &gt;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 %}
+39
View File
@@ -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 %}
+63
View File
@@ -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 %}
+194
View File
@@ -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 %}
+210
View File
@@ -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
+44
View File
@@ -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 %}
+286
View File
@@ -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 %}
+153
View File
@@ -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 %}
+903
View File
@@ -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 '—' }}%
&rarr;
{{ 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 %}
&nbsp;·&nbsp; 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()">&times;</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 %}
+114
View File
@@ -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 %}
+325
View File
@@ -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 &amp; 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>&gt;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 %}
+482
View File
@@ -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 &amp; 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 %}
+48
View File
@@ -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">
&copy; 2025 Janitorial QC System
</div>
</div>
</div>
</div>
{% endblock %}
+164
View File
@@ -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) }}">
&laquo; 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 &raquo;
</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 %}
+56
View File
@@ -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 %}
+168
View File
@@ -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 &amp; 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 &amp; 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 %}
+84
View File
@@ -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 %}
+169
View File
@@ -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 %}
+44
View File
@@ -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 &amp; 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>
+156
View File
@@ -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 %}
+186
View File
@@ -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') }}
&nbsp;·&nbsp; 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 %}
+319
View File
@@ -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 %}
+175
View File
@@ -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', '14 weeks'] %}
{% set is_warning = label == '37 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 %}
+279
View File
@@ -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