Files
LT_Janitorial_Quality_Control/app/__init__.py
T

393 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from flask import Flask, request
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 werkzeug.middleware.proxy_fix import ProxyFix
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://'),
)
# ── Design A/B test: per-request template overrides (phase48) ────────────────
# A user on the 'modern' design gets templates/modern/<name>.html in place of
# templates/<name>.html whenever that override exists; otherwise the normal
# template is used and only the layout shell + CSS differ.
#
# The rewrite happens in get_template() (not in the loader) so Jinja's template
# cache is keyed on the REWRITTEN name — a cached modern template can never be
# served to a classic user, or vice versa.
from flask.templating import Environment as _FlaskJinjaEnvironment
class ThemedEnvironment(_FlaskJinjaEnvironment):
"""Jinja environment that redirects template names to modern/<name>."""
# Populated once in create_app() by scanning templates/modern/.
jqc_modern_templates: set = set()
def get_template(self, name, parent=None, globals=None):
if (isinstance(name, str)
and self.jqc_modern_templates
and not name.startswith('modern/')):
candidate = 'modern/' + name
if candidate in self.jqc_modern_templates:
from flask import g, has_request_context
if has_request_context() and getattr(g, 'jqc_theme', 'classic') == 'modern':
name = candidate
return super().get_template(name, parent, globals)
def create_app(config_name='default'):
app = Flask(__name__)
# Must be assigned BEFORE app.jinja_env is first touched (it is a cached
# property), so the themed subclass is the one actually instantiated.
app.jinja_environment = ThemedEnvironment
app.config.from_object(config[config_name])
# ── Reverse-proxy awareness (Nginx) ──────────────────────────────────────
# Nginx terminates TLS and forwards requests over the loopback interface,
# setting X-Forwarded-For / X-Forwarded-Proto / X-Forwarded-Host. Without
# ProxyFix, request.remote_addr is always 127.0.0.1, which collapses every
# Flask-Limiter key into a single shared bucket (rate limits become global
# instead of per-client) and makes url_for(_external) emit http:// links.
# x_for=1 trusts exactly one proxy hop — our own Nginx.
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
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
# Photo URL resolver — routes through the active storage backend so templates
# work unchanged when the backend flips from local to R2 (see utils/storage.py).
from app.utils import storage as _storage
app.jinja_env.globals['media_url'] = _storage.media_url
# ── Design A/B test wiring (phase48) ──────────────────────────────────
# Index the modern/ override templates once at boot, so get_template()
# never has to touch the filesystem per request.
_modern_root = os.path.join(app.template_folder or 'templates', 'modern')
if not os.path.isabs(_modern_root):
_modern_root = os.path.join(app.root_path, _modern_root)
_modern_set = set()
if os.path.isdir(_modern_root):
for _dirpath, _dirnames, _filenames in os.walk(_modern_root):
for _fn in _filenames:
if _fn.endswith('.html'):
_rel = os.path.relpath(os.path.join(_dirpath, _fn), _modern_root)
_modern_set.add('modern/' + _rel.replace(os.sep, '/'))
ThemedEnvironment.jqc_modern_templates = _modern_set
app.logger.info('UI themes | modern overrides indexed: %s', len(_modern_set))
from flask import g
@app.before_request
def resolve_ui_theme():
"""Stash the active design on `g` for ThemedEnvironment.get_template()."""
# The mobile API renders no templates and authenticates by JWT — skip it
# so this never touches the Flask-Login session loader on API traffic.
if request.path.startswith('/api/'):
# The API renders no templates; 'classic' here only means "never
# rewrite a template name" (see ThemedEnvironment.get_template).
g.jqc_theme = 'classic'
return
from flask_login import current_user as _cu
# phase50 — the fallback is configurable and now defaults to 'modern'.
# A stored users.ui_theme still wins, so an explicit choice is kept.
default = app.config.get('DEFAULT_UI_THEME', 'modern')
theme = default
try:
if _cu.is_authenticated:
theme = _cu.ui_theme or default
except Exception: # DB column missing (migration not yet run)
theme = default
g.jqc_theme = theme if theme in ('classic', 'modern') else default
@app.context_processor
def inject_ui_theme():
"""Give base.html the shell to extend."""
from app.utils.time_utils import now_eastern
theme = getattr(g, 'jqc_theme',
app.config.get('DEFAULT_UI_THEME', 'modern'))
_now = now_eastern()
return {
'jqc_theme': theme,
'jqc_layout': 'layouts/modern.html' if theme == 'modern'
else 'layouts/classic.html',
# Long-form date shown in the modern dashboard header. The day is
# interpolated rather than formatted with '%-d' — that flag is a
# glibc extension and raises ValueError on Windows, which would
# 500 every page (this context processor runs on both themes).
'now_display': f'{_now.strftime("%A, %B")} {_now.day}, {_now.year}',
}
# ── 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
from app.routes import devices # Admin device registry
from app.routes import public # Public facility QR pages (no login)
from app.routes import scheduled_inspections # Planned/recurring inspections
from app.routes import ui # phase48 — design A/B test + new pages
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)
app.register_blueprint(devices.bp)
app.register_blueprint(public.bp)
app.register_blueprint(scheduled_inspections.bp)
app.register_blueprint(ui.bp)
# ── Enrollment form (self-contained — see app/enrollment/__init__.py) ────
# Deliberately NOT part of the app's data model: it owns its own templates
# and stores submissions as JSON files, so it touches no table and needs no
# migration. Registered last because nothing else depends on it.
from app.enrollment import register_enrollment
register_enrollment(app)
# ── 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
from app.api.scheduled import bp as _api_scheduled_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)
csrf.exempt(_api_scheduled_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.
# Allow R2 presigned photo URLs in the CSP img-src when the s3 storage
# backend is configured. Derived from R2_ENDPOINT_URL (the presigned URL
# host is the same R2 account endpoint), so nothing is hardcoded and the
# local backend is unaffected.
_r2_img_src = ''
_r2_endpoint = app.config.get('R2_ENDPOINT_URL')
if _r2_endpoint:
from urllib.parse import urlparse
_r2_host = urlparse(_r2_endpoint).netloc
if _r2_host:
_r2_img_src = f' https://{_r2_host}'
@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')
# Only asserted over an actual HTTPS request — ProxyFix (x_proto=1) makes
# request.is_secure reflect the real client-facing scheme behind Nginx,
# so this never fires for plain-HTTP local/dev requests.
if request.is_secure:
response.headers.setdefault(
'Strict-Transport-Security', 'max-age=31536000; includeSubDomains'
)
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; "
f"img-src 'self' data: blob: https://maps.gstatic.com https://maps.googleapis.com{_r2_img_src}; "
"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