Files
JQC_multi_tenant/app/__init__.py
T

326 lines
16 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
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
from app.tenancy.routing import RoutingSession
db = SQLAlchemy(session_options={'class_': RoutingSession})
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])
# Unwrap X-Forwarded-For / X-Forwarded-Proto set by Nginx so Flask sees
# the real client IP (needed for rate limiting and fail2ban logging) and
# the real scheme (needed for HTTPS URL generation in emails).
from werkzeug.middleware.proxy_fix import ProxyFix
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()
# ── Multi-tenant resolution (MT-1) ───────────────────────────────────────
# Registers the before_request Host→tenant resolver. Inert (no-op) unless
# config MULTI_TENANT_ENABLED is True, so the single-tenant deployment is
# unaffected until tenants are provisioned and the flag is flipped.
from app.tenancy.middleware import init_tenancy
init_tenancy(app)
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
def _hex_to_rgb(hex_color):
"""Convert #rrggbb → 'r,g,b' string for CSS rgb() / Bootstrap var."""
h = hex_color.lstrip('#')
if len(h) != 6:
return '26,86,219'
try:
return ','.join(str(int(h[i:i+2], 16)) for i in (0, 2, 4))
except ValueError:
return '26,86,219'
app.jinja_env.filters['hex_to_rgb'] = _hex_to_rgb
# 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():
try:
if current_user.is_authenticated:
from app.models.notification import Notification
from app.models.issue import Issue
unread = Notification.query.filter_by(
user_id=current_user.id, is_read=False
).count()
# Pending verification count — only computed for director+ roles
pv_count = 0
if current_user.role in ('admin', 'director'):
pv_count = Issue.query.filter_by(
status='pending_verification'
).count()
# Open support tickets — admin/director only
open_support = 0
if current_user.role in ('admin', 'director'):
from app.models.support import SupportTicket
open_support = SupportTicket.query.filter_by(status='open').count()
return {
'unread_notification_count': unread,
'pending_verification_count': pv_count,
'open_support_tickets_count': open_support,
}
except Exception:
pass
return {'unread_notification_count': 0, 'pending_verification_count': 0, 'open_support_tickets_count': 0}
@app.context_processor
def inject_tenant_branding():
"""MT-7: push tenant branding into every template as `tenant_branding`."""
try:
from app.models.tenant_settings import TenantSettings
settings = TenantSettings.get_or_default()
return {'tenant_branding': settings}
except Exception:
pass
return {'tenant_branding': None}
@app.context_processor
def inject_billing_context():
"""MT-8: push billing state into every template."""
try:
from flask import g
from app.utils.time_utils import now_eastern
billing_warning = getattr(g, 'billing_warning', None)
tenant = getattr(g, 'tenant', None)
subscription_status = tenant.subscription_status if tenant else None
trial_ends_at = tenant.trial_ends_at if tenant else None
trial_days_remaining = None
if trial_ends_at is not None:
delta = trial_ends_at - now_eastern()
trial_days_remaining = max(0, delta.days)
return {
'billing_enabled': app.config.get('BILLING_ENABLED', False),
'billing_warning': billing_warning,
'subscription_status': subscription_status,
'trial_ends_at': trial_ends_at,
'trial_days_remaining': trial_days_remaining,
}
except Exception:
pass
return {
'billing_enabled': False,
'billing_warning': None,
'subscription_status': None,
'trial_ends_at': None,
'trial_days_remaining': None,
}
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 inspection_schedules # phase34 — recurring inspections
from app.routes import work_orders # phase36 — vendor work orders (public tokenized)
from app.routes import facility_qr # phase38 — facility QR scan page (public tokenized)
from app.routes import support # Support chat + admin tickets
from app.routes import broadcast # Admin broadcast messages
from app.routes import devices # Admin device management
from app.routes import tenant_settings # MT-7 — tenant self-service
from app.routes import signup # MT-8+ — public self-service signup
from app.routes import landing # Public apex marketing/landing page
from app.billing import bp as billing_bp # MT-8 — Stripe billing
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(inspection_schedules.bp)
app.register_blueprint(work_orders.bp)
app.register_blueprint(facility_qr.bp)
app.register_blueprint(support.bp)
app.register_blueprint(broadcast.bp)
app.register_blueprint(devices.bp)
app.register_blueprint(tenant_settings.bp)
app.register_blueprint(signup.bp)
app.register_blueprint(landing.bp)
# Billing blueprint is CSRF-exempt: /billing/webhook receives raw POST from
# Stripe and cannot carry a CSRF token. Subscribe/portal are GET redirects
# which Flask-WTF does not protect anyway (CSRF only applies to unsafe methods).
csrf.exempt(billing_bp)
app.register_blueprint(billing_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):
from flask import request as _request
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; "
# Hardening directives that don't affect existing inline scripts/styles:
# block plugins, injected <base> tags, and cross-origin form posts.
"object-src 'none'; base-uri 'self'; form-action 'self'; "
"frame-ancestors 'none';"
)
# HSTS — advertise only over HTTPS (Nginx terminates TLS and forwards
# X-Forwarded-Proto). includeSubDomains is deliberately OMITTED: a tenant
# custom domain may run unrelated subdomains that are not yet HTTPS, and
# this header must never force-upgrade one of those.
if _request.is_secure or _request.headers.get('X-Forwarded-Proto', '') == 'https':
response.headers.setdefault('Strict-Transport-Security', 'max-age=31536000')
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