from dotenv import load_dotenv from pathlib import Path from flask import Flask, render_template, request, url_for from flask_login import current_user from werkzeug.middleware.proxy_fix import ProxyFix from dotenv import load_dotenv from sqlalchemy import inspect, text from sqlalchemy.exc import SQLAlchemyError from config import Config from redis.exceptions import RedisError from app.cli import register_cli from app.extensions import db, migrate, login_manager, csrf, limiter from app.logging_config import configure_logging from app.scheduler import init_scheduler from app.utils.formatters import pln import hashlib def _ensure_storage(app): Path(app.instance_path).mkdir(parents=True, exist_ok=True) for key in ['ARCHIVE_PATH', 'PDF_PATH', 'BACKUP_PATH', 'CERTS_PATH']: app.config[key].mkdir(parents=True, exist_ok=True) def _ensure_column(table_name: str, column_name: str, ddl: str): inspector = inspect(db.engine) columns = {col['name'] for col in inspector.get_columns(table_name)} if inspector.has_table(table_name) else set() if columns and column_name not in columns: db.session.execute(text(f'ALTER TABLE {table_name} ADD COLUMN {ddl}')) db.session.commit() def _bootstrap_database(app): try: db.create_all() patches = [ ('user', 'theme_preference', "theme_preference VARCHAR(20) DEFAULT 'light' NOT NULL"), ('user', 'is_blocked', 'is_blocked BOOLEAN DEFAULT 0 NOT NULL'), ('user', 'force_password_change', 'force_password_change BOOLEAN DEFAULT 0 NOT NULL'), ('app_setting', 'is_encrypted', 'is_encrypted BOOLEAN DEFAULT 0 NOT NULL'), ('invoice', 'company_id', 'company_id INTEGER'), ('invoice', 'source', "source VARCHAR(32) DEFAULT 'ksef' NOT NULL"), ('invoice', 'customer_id', 'customer_id INTEGER'), ('invoice', 'issued_to_ksef_at', 'issued_to_ksef_at DATETIME'), ('invoice', 'issued_status', "issued_status VARCHAR(32) DEFAULT 'received' NOT NULL"), ('sync_log', 'company_id', 'company_id INTEGER'), ('sync_log', 'total', 'total INTEGER DEFAULT 0'), ('company', 'note', 'note TEXT'), ('company', 'regon', "regon VARCHAR(32) DEFAULT ''"), ('company', 'address', "address VARCHAR(255) DEFAULT ''"), ('customer', 'regon', "regon VARCHAR(32) DEFAULT ''"), ] for table, col, ddl in patches: _ensure_column(table, col, ddl) app.logger.info('Database bootstrap checked.') except SQLAlchemyError: app.logger.exception('Automatic database bootstrap failed.') def _asset_hash(app: Flask, filename: str) -> str: static_file = Path(app.static_folder) / filename if not static_file.exists() or not static_file.is_file(): return 'dev' digest = hashlib.sha256(static_file.read_bytes()).hexdigest() return digest[:12] def create_app(config_class=Config): load_dotenv() app = Flask(__name__, instance_relative_config=True) app.config.from_object(config_class) app.config['RATELIMIT_STORAGE_URI'] = app.config.get('REDIS_URL', 'memory://') app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1) _ensure_storage(app) configure_logging(app) db.init_app(app) migrate.init_app(app, db) login_manager.init_app(app) csrf.init_app(app) from app.services.redis_service import RedisService if app.config.get('RATELIMIT_STORAGE_URI', '').startswith('redis://') and not RedisService.available(app): app.logger.warning('Redis niedostępny przy starcie - limiter przełączony na memory://') app.config['RATELIMIT_STORAGE_URI'] = 'memory://' limiter.init_app(app) app.jinja_env.filters['pln'] = pln from app.models import user, invoice, sync_log, notification, setting, audit_log, company, catalog # noqa from app.auth.routes import bp as auth_bp from app.dashboard.routes import bp as dashboard_bp from app.invoices.routes import bp as invoices_bp from app.settings.routes import bp as settings_bp from app.notifications.routes import bp as notifications_bp from app.api.routes import bp as api_bp from app.admin.routes import bp as admin_bp from app.nfz.routes import bp as nfz_bp app.register_blueprint(auth_bp) app.register_blueprint(dashboard_bp) app.register_blueprint(invoices_bp) app.register_blueprint(settings_bp) app.register_blueprint(notifications_bp) app.register_blueprint(admin_bp) app.register_blueprint(nfz_bp) app.register_blueprint(api_bp, url_prefix='/api') register_cli(app) with app.app_context(): if not app.config.get('TESTING'): _bootstrap_database(app) init_scheduler(app) @app.context_processor def inject_globals(): from app.models.setting import AppSetting from app.services.company_service import CompanyService theme = request.cookies.get('theme', 'light') if getattr(current_user, 'is_authenticated', False): theme = getattr(current_user, 'theme_preference', theme) or 'light' else: theme = AppSetting.get('ui.theme', theme) current_company = CompanyService.get_current_company() if getattr(current_user, 'is_authenticated', False) else None available_companies = CompanyService.available_for_user() if getattr(current_user, 'is_authenticated', False) else [] nfz_enabled = False if getattr(current_user, 'is_authenticated', False) and current_company: from app.services.settings_service import SettingsService nfz_enabled = SettingsService.get_effective('modules.nfz_enabled', 'false', company_id=current_company.id) == 'true' status_map = {'sent': 'Wysłano', 'success': 'Sukces', 'error': 'Błąd', 'failed': 'Błąd', 'skipped': 'Pominięto', 'queued': 'Oczekuje'} channel_map = {'email': 'E-mail', 'pushover': 'Pushover'} return { 'app_name': 'KSeF Manager', 'theme': theme, 'read_only_mode': (__import__('app.services.settings_service', fromlist=['SettingsService']).SettingsService.read_only_enabled(company_id=current_company.id if current_company else None) if getattr(current_user, 'is_authenticated', False) else False), 'current_company': current_company, 'available_companies': available_companies, 'nfz_module_enabled': nfz_enabled, 'static_asset': lambda filename: url_for('static', filename=filename, v=_asset_hash(app, filename)), 'global_footer_text': app.config.get('APP_FOOTER_TEXT', ''), 'status_pl': lambda value: status_map.get((value or '').lower(), value or '—'), 'channel_pl': lambda value: channel_map.get((value or '').lower(), (value or '—').upper() if value else '—'), } @app.after_request def cleanup_static_headers(response): if request.path.startswith('/static/'): response.headers.pop('Content-Disposition', None) return response @app.errorhandler(403) def error_403(err): return render_template('errors/403.html'), 403 @app.errorhandler(404) def error_404(err): return render_template('errors/404.html'), 404 @app.errorhandler(RedisError) @app.errorhandler(ConnectionError) def error_redis(err): db.session.rollback() return render_template('errors/503.html', message='Usługa cache jest chwilowo niedostępna. Aplikacja korzysta z trybu awaryjnego.'), 503 @app.errorhandler(500) def error_500(err): db.session.rollback() return render_template('errors/500.html'), 500 return app