Files
LT_Janitorial_Quality_Control/config.py
T

165 lines
8.4 KiB
Python

import os
from datetime import timedelta
from dotenv import load_dotenv
from sqlalchemy.pool import StaticPool
# Load .env from the project root (only takes effect locally; no-op in production
# if variables are already set in the environment)
load_dotenv()
basedir = os.path.abspath(os.path.dirname(__file__))
def _require_env(key: str) -> str:
"""Return the value of a required environment variable, raising if absent."""
value = os.environ.get(key)
if not value:
raise RuntimeError(
f"Required environment variable '{key}' is not set. "
f"Add it to your .env file (development) or server environment (production)."
)
return value
class Config:
# ── Security ────────────────────────────────────────────────────────────
# SECRET_KEY must be set externally — no insecure fallback.
SECRET_KEY = _require_env('SECRET_KEY')
# ── Database ────────────────────────────────────────────────────────────
# DATABASE_URL must be set externally — no hardcoded credentials.
SQLALCHEMY_DATABASE_URI = _require_env('DATABASE_URL')
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ECHO = False
# ── File uploads ────────────────────────────────────────────────────────
UPLOAD_FOLDER = os.path.join(basedir, 'app/static/uploads')
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50 MB
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
# ── Storage backend (R2 migration) ──────────────────────────────────────
# 'local' (default) = files under app/static/uploads, served via url_for('static').
# 's3' = Cloudflare R2 / S3-compatible (added Phase 2). Flip via env only.
STORAGE_BACKEND = os.environ.get('STORAGE_BACKEND', 'local')
# Cloudflare R2 — only used when STORAGE_BACKEND=s3.
R2_ENDPOINT_URL = os.environ.get('R2_ENDPOINT_URL')
R2_ACCESS_KEY_ID = os.environ.get('R2_ACCESS_KEY_ID')
R2_SECRET_ACCESS_KEY = os.environ.get('R2_SECRET_ACCESS_KEY')
R2_BUCKET = os.environ.get('R2_BUCKET')
R2_PRESIGN_TTL = int(os.environ.get('R2_PRESIGN_TTL', '86400')) # seconds (24h)
# When true, media_url HEADs the object and falls back to a local static URL
# if it's missing (belt-and-suspenders during an early/partial cutover).
# Off by default — cutover is gated on full verification, so objects exist.
R2_MEDIA_FALLBACK = os.environ.get('R2_MEDIA_FALLBACK', 'false').lower() == 'true'
# ── Photo capture-time / geo overlay ────────────────────────────────────
# When true (default), POST /api/v1/photos/upload burns a timestamp + GPS
# bar into the image before storing it. Set false to store raw uploads.
PHOTO_STAMP_ENABLED = os.environ.get('PHOTO_STAMP_ENABLED', 'true').lower() == 'true'
# ── Web portal design (phase50 — Aug 2026) ───────────────────────────────
# The design a user sees when they have never chosen one. phase48 shipped
# the modern design as an opt-in A/B test with 'classic' as the default;
# phase50 promotes 'modern' to the default now that the test is settled.
#
# This is the FALLBACK only — a stored users.ui_theme always wins, so anyone
# who switches keeps their choice. Set DEFAULT_UI_THEME=classic to revert
# the default without touching a single saved preference.
DEFAULT_UI_THEME = os.environ.get('DEFAULT_UI_THEME', 'modern')
# ── Issue comment visibility (TEMPORARY — Aug 2026) ──────────────────────
# True = every comment on an issue is visible to everyone, customers
# included; the per-comment is_customer_visible flag is ignored
# when READING.
# False = phase22 behaviour — customers see only comments explicitly shared
# with them.
#
# The flag is still WRITTEN on every comment, so flipping this back to
# 'false' restores the old behaviour exactly, with no data to repair.
# Set COMMENTS_VISIBLE_TO_ALL=false in the environment to revert.
COMMENTS_VISIBLE_TO_ALL = os.environ.get(
'COMMENTS_VISIBLE_TO_ALL', 'true').lower() == 'true'
# ── Session / cookies ───────────────────────────────────────────────────
PERMANENT_SESSION_LIFETIME = timedelta(hours=24)
# Secure by default — subclasses must explicitly opt out for local dev.
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
# ── Mail ────────────────────────────────────────────────────────────────
# ── Application base URL (used in email links) ─────────────────────────
APP_BASE_URL = os.environ.get('APP_BASE_URL', '')
MAIL_DEFAULT_SENDER = os.environ.get('MAIL_DEFAULT_SENDER', 'noreply@janitorialqc.local')
# ── Digest email secret token (used to authenticate cron trigger) ────────
DIGEST_SECRET = os.environ.get('DIGEST_SECRET')
# ── Photo retention (data minimization — GDPR Art. 5(1)(e)) ─────────────
# Unset (None) by default: no automatic photo deletion happens unless the
# operator opts in. When set, /notifications/purge-old-photos deletes
# photo files (not the issue record) for RESOLVED issues older than this
# many days.
PHOTO_RETENTION_DAYS = int(os.environ['PHOTO_RETENTION_DAYS']) if os.environ.get('PHOTO_RETENTION_DAYS') else None
# ── Google Maps (used for GPS map on inspection view) ────────────────────
GOOGLE_MAPS_API_KEY = os.environ.get('GOOGLE_MAPS_API_KEY', '')
MAIL_SERVER = os.environ.get('MAIL_SERVER')
MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
# ── SSL vs STARTTLS selection ────────────────────────────────────────────
# Port 465 = implicit SSL → MAIL_USE_SSL=True, MAIL_USE_TLS=False
# Port 587 = STARTTLS → MAIL_USE_SSL=False, MAIL_USE_TLS=True
# The two flags are mutually exclusive; setting both True breaks Flask-Mail.
_mail_port = int(os.environ.get('MAIL_PORT') or 587)
MAIL_PORT = _mail_port
MAIL_USE_SSL = _mail_port == 465
MAIL_USE_TLS = not MAIL_USE_SSL # STARTTLS only when NOT using implicit SSL
class DevelopmentConfig(Config):
DEBUG = True
SQLALCHEMY_ECHO = True
# Allow HTTP cookies during local development (HTTP, not HTTPS)
SESSION_COOKIE_SECURE = False
class ProductionConfig(Config):
DEBUG = False
# Inherits SESSION_COOKIE_SECURE = True from Config — no override needed.
class TestingConfig(Config):
"""Config for the automated test suite (pytest).
Uses an in-memory SQLite database with a StaticPool so the single
connection — and therefore the schema created by db.create_all() — persists
across every request the test client makes within one app instance. CSRF and
rate limiting are disabled so tests can POST directly, and mail is
suppressed. DEBUG=True short-circuits the file-logging block in create_app()
so the suite never writes to logs/jqc.log.
"""
TESTING = True
DEBUG = True
SQLALCHEMY_DATABASE_URI = 'sqlite://' # in-memory
SQLALCHEMY_ENGINE_OPTIONS = {
'connect_args': {'check_same_thread': False},
'poolclass': StaticPool,
}
WTF_CSRF_ENABLED = False
RATELIMIT_ENABLED = False
MAIL_SUPPRESS_SEND = True
SESSION_COOKIE_SECURE = False
config = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'testing': TestingConfig,
'default': DevelopmentConfig,
}