199 lines
7.8 KiB
Python
199 lines
7.8 KiB
Python
import logging
|
|
import os
|
|
from flask import Flask
|
|
from app.config import config
|
|
from app.extensions import db, login_manager, migrate, csrf, limiter
|
|
from app.utils.formatters import format_currency, format_percent, format_large_number
|
|
|
|
|
|
def _setup_logging(app):
|
|
"""
|
|
Configure the 'app' namespace logger to write to a rotating file AND stderr.
|
|
|
|
Uses a pipe-delimited format so the log viewer can parse each field easily:
|
|
2026-06-01 12:00:00|INFO|app.services.teller_service|message text
|
|
|
|
The file path comes from LOG_FILE_PATH config (defaults to logs/app.log
|
|
next to the project root). The directory is created automatically.
|
|
"""
|
|
import sys
|
|
from logging.handlers import RotatingFileHandler
|
|
|
|
app_log = logging.getLogger('app')
|
|
if app_log.handlers:
|
|
return # already configured (avoids duplicate handlers on reload)
|
|
|
|
app_log.setLevel(logging.INFO)
|
|
app_log.propagate = False # don't double-emit through the root logger
|
|
|
|
fmt = logging.Formatter(
|
|
'%(asctime)s|%(levelname)s|%(name)s|%(message)s',
|
|
datefmt='%Y-%m-%d %H:%M:%S',
|
|
)
|
|
|
|
# --- rotating file handler (primary — always on) ---
|
|
log_file = app.config.get('LOG_FILE_PATH', '')
|
|
if log_file:
|
|
try:
|
|
os.makedirs(os.path.dirname(log_file), exist_ok=True)
|
|
fh = RotatingFileHandler(
|
|
log_file,
|
|
maxBytes=app.config.get('LOG_MAX_BYTES', 10 * 1024 * 1024),
|
|
backupCount=app.config.get('LOG_BACKUP_COUNT', 5),
|
|
encoding='utf-8',
|
|
)
|
|
fh.setLevel(logging.INFO)
|
|
fh.setFormatter(fmt)
|
|
app_log.addHandler(fh)
|
|
except Exception as exc:
|
|
# Can't open file (permissions, bad path) — fall through to stderr only
|
|
print(f'[pfm] WARNING: could not open log file {log_file!r}: {exc}', file=sys.stderr)
|
|
|
|
# --- stderr handler (secondary — also always on so Gunicorn captures it) ---
|
|
sh = logging.StreamHandler(sys.stderr)
|
|
sh.setLevel(logging.INFO)
|
|
sh.setFormatter(fmt)
|
|
app_log.addHandler(sh)
|
|
|
|
app_log.info('Logging initialised — file=%s', log_file or '(none)')
|
|
|
|
|
|
def create_app(config_name=None):
|
|
if config_name is None:
|
|
config_name = os.environ.get('FLASK_ENV', 'development')
|
|
config_name = 'production' if config_name == 'production' else 'development'
|
|
|
|
app = Flask(__name__)
|
|
app.config.from_object(config[config_name])
|
|
|
|
# Wire app.* loggers into Gunicorn's error log (or stderr in dev).
|
|
# basicConfig is a no-op under Gunicorn because Gunicorn already
|
|
# installed root handlers before our app is imported — so we must
|
|
# attach handlers explicitly.
|
|
_setup_logging(app)
|
|
|
|
db.init_app(app)
|
|
login_manager.init_app(app)
|
|
migrate.init_app(app, db)
|
|
csrf.init_app(app)
|
|
limiter.init_app(app)
|
|
|
|
# ── Sentry (optional) ────────────────────────────────────────────────────
|
|
sentry_dsn = app.config.get('SENTRY_DSN', '')
|
|
if sentry_dsn:
|
|
import sentry_sdk
|
|
from sentry_sdk.integrations.flask import FlaskIntegration
|
|
sentry_sdk.init(
|
|
dsn=sentry_dsn,
|
|
integrations=[FlaskIntegration()],
|
|
traces_sample_rate=0.1, # 10 % of requests traced
|
|
send_default_pii=False, # no personal data in error reports
|
|
)
|
|
logging.getLogger('app').info('Sentry initialised')
|
|
|
|
from app.routes.auth import auth_bp
|
|
from app.routes.dashboard import dashboard_bp
|
|
from app.routes.accounts import accounts_bp
|
|
from app.routes.categories import categories_bp
|
|
from app.routes.transactions import transactions_bp
|
|
from app.routes.budgets import budgets_bp
|
|
from app.routes.goals import goals_bp
|
|
from app.routes.investments import investments_bp
|
|
from app.routes.ai import ai_bp
|
|
from app.routes.reports import reports_bp
|
|
from app.routes.settings import settings_bp
|
|
from app.routes.teller import teller_bp
|
|
from app.routes.schwab import schwab_bp
|
|
from app.routes.logs import logs_bp
|
|
from app.routes.bank_import import bank_import_bp
|
|
|
|
app.register_blueprint(auth_bp)
|
|
app.register_blueprint(dashboard_bp)
|
|
app.register_blueprint(accounts_bp)
|
|
app.register_blueprint(categories_bp)
|
|
app.register_blueprint(transactions_bp)
|
|
app.register_blueprint(budgets_bp)
|
|
app.register_blueprint(goals_bp)
|
|
app.register_blueprint(investments_bp)
|
|
app.register_blueprint(ai_bp)
|
|
app.register_blueprint(reports_bp)
|
|
app.register_blueprint(settings_bp)
|
|
app.register_blueprint(teller_bp)
|
|
app.register_blueprint(schwab_bp)
|
|
app.register_blueprint(logs_bp)
|
|
app.register_blueprint(bank_import_bp)
|
|
|
|
with app.app_context():
|
|
from app.models import (
|
|
User, Account, Category, Receipt, RecurringRule,
|
|
Transaction, Budget, Goal, GoalContribution,
|
|
Investment, InvestmentTransaction, NetWorthSnapshot,
|
|
AiInsight, FxRate
|
|
)
|
|
from app.models.teller_enrollment import TellerEnrollment, TellerAccount
|
|
from app.models.schwab_connection import SchwabConnection, SchwabAccount
|
|
|
|
# ── Session idle timeout ──────────────────────────────────────────────────
|
|
from flask import session as _session, request as _request
|
|
from flask_login import current_user as _cu
|
|
from datetime import timedelta, datetime as _dt
|
|
import logging as _logging
|
|
_log = _logging.getLogger('app.auth')
|
|
SESSION_IDLE_MINUTES = app.config.get('SESSION_IDLE_MINUTES', 60)
|
|
|
|
@app.before_request
|
|
def check_session_timeout():
|
|
# Skip static files and unauthenticated sessions
|
|
if _request.endpoint and _request.endpoint.startswith('static'):
|
|
return
|
|
if not _cu.is_authenticated:
|
|
return
|
|
last = _session.get('_last_active')
|
|
now = _dt.utcnow().isoformat()
|
|
if last:
|
|
idle = (_dt.utcnow() - _dt.fromisoformat(last)).total_seconds() / 60
|
|
if idle > SESSION_IDLE_MINUTES:
|
|
from flask_login import logout_user as _lu
|
|
_lu()
|
|
_session.clear()
|
|
from flask import redirect, url_for, flash
|
|
_log.info('[auth] session expired after %.0f min idle', idle)
|
|
flash('Your session expired due to inactivity. Please log in again.', 'warning')
|
|
return redirect(url_for('auth.login'))
|
|
_session['_last_active'] = now
|
|
|
|
@app.after_request
|
|
def security_headers(response):
|
|
response.headers['X-Frame-Options'] = 'DENY'
|
|
response.headers['X-Content-Type-Options'] = 'nosniff'
|
|
response.headers['X-XSS-Protection'] = '1; mode=block'
|
|
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
|
if not app.debug:
|
|
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
|
|
return response
|
|
|
|
app.jinja_env.globals['format_currency'] = format_currency
|
|
app.jinja_env.globals['format_percent'] = format_percent
|
|
app.jinja_env.globals['format_large_number'] = format_large_number
|
|
|
|
@app.template_filter('currency')
|
|
def currency_filter(value, symbol=None):
|
|
return format_currency(value, symbol)
|
|
|
|
@app.template_filter('percent')
|
|
def percent_filter(value):
|
|
return format_percent(value)
|
|
|
|
@app.template_filter('shares')
|
|
def shares_filter(value):
|
|
"""Format share quantity — strips trailing zeros, keeps up to 8 decimal places."""
|
|
try:
|
|
f = float(value)
|
|
except (TypeError, ValueError):
|
|
return '0'
|
|
# Format to 8 decimal places then strip trailing zeros
|
|
s = '{:.8f}'.format(f).rstrip('0').rstrip('.')
|
|
return s if s else '0'
|
|
|
|
return app
|