import logging import os import threading import time from logging.handlers import TimedRotatingFileHandler from pathlib import Path from flask import Flask, abort, jsonify, redirect, request from flask_sqlalchemy import SQLAlchemy from sqlalchemy import event from sqlalchemy.engine import Engine from flask_cors import CORS from config import ProductionConfig, apply_network_settings_to_app, apply_security_settings_to_app, get_config_class db = SQLAlchemy() # Flask-SQLAlchemy 3 не экспонирует db.app; env.py берёт приложение отсюда во время upgrade. _alembic_host_app: Flask | None = None def _configure_wesp_file_logging(app: Flask, log_level: int, fmt: str) -> None: """Пишет логи в WESP_ADMIN_LOG_PATH (по умолчанию /data/logs/wesp.log), ротация в полночь, сутки хранения — WESP_LOG_RETENTION_DAYS.""" if app.config.get("TESTING"): return log_path = Path(str(app.config.get("WESP_ADMIN_LOG_PATH") or "")).expanduser() if not log_path.name: return try: log_path.parent.mkdir(parents=True, exist_ok=True) except OSError: logging.getLogger(__name__).warning("Не удалось создать каталог логов: %s", log_path.parent, exc_info=True) return root = logging.getLogger() target_abs = os.path.normcase(os.path.abspath(str(log_path))) if any( isinstance(h, TimedRotatingFileHandler) and os.path.normcase(os.path.abspath(getattr(h, "baseFilename", ""))) == target_abs for h in root.handlers ): return retention = max(1, int(app.config.get("WESP_LOG_RETENTION_DAYS", 3))) backup_count = max(0, retention - 1) try: handler = TimedRotatingFileHandler( filename=str(log_path), when="midnight", interval=1, backupCount=backup_count, encoding="utf-8", delay=True, ) except OSError: logging.getLogger(__name__).warning("Не удалось открыть файл лога: %s", log_path, exc_info=True) return # В файл — префикс времени (для ленты activity-feed); порог как у LOG_LEVEL / WESP_LOG_LEVEL. file_fmt = "%(asctime)s %(levelname)s:%(name)s:%(message)s" handler.setFormatter(logging.Formatter(file_fmt, datefmt="%Y-%m-%d %H:%M:%S")) handler.setLevel(log_level) root.addHandler(handler) logging.getLogger(__name__).info( "Файловый лог: %s (порог файла %s, хранение %s сут.)", log_path, logging.getLevelName(log_level), retention, ) def _parse_log_level(value) -> int: """Уровень логов из конфига (LOG_LEVEL / WESP_LOG_LEVEL): только имена уровней logging.""" if value is None or value == "": return logging.INFO name = str(value).strip().upper() level = getattr(logging, name, None) return level if isinstance(level, int) else logging.INFO def _install_immediate_log_flush(root: logging.Logger) -> None: """Сразу сбрасывает буфер после записи (удобно для tail -f и логов из фонового sync_client).""" for handler in list(root.handlers): orig_emit = handler.emit def _emit_with_flush(record, _orig=orig_emit, _h=handler): _orig(record) try: _h.flush() except Exception: pass handler.emit = _emit_with_flush # type: ignore[method-assign] def _start_sync_requeue_worker(app: Flask) -> None: """Периодический requeue_stuck_processing (ТЗ п. 4.6), без зависимости от sync pull.""" interval = int(app.config.get("SYNC_REQUEUE_INTERVAL_SEC", 120)) log = logging.getLogger(__name__) def _loop() -> None: while True: try: time.sleep(interval) with app.app_context(): from app.services.sync_manager import requeue_stuck_processing timeout = int(app.config.get("SYNC_REQUEUE_TIMEOUT_MINUTES", 15)) n = requeue_stuck_processing(timeout_minutes=timeout) if n: log.info( "Фоновый requeue sync_queue: возвращено в pending %s задач (timeout %s мин)", n, timeout, ) except Exception: log.exception("Фоновый requeue sync_queue: ошибка цикла") threading.Thread(target=_loop, daemon=True, name="wesp-sync-requeue").start() def _register_sklad_api(app: Flask) -> None: """Регистрирует полные маршруты /api/sklad из корневого sklad.py (остатки + расход из reports.db).""" from sklad import init_sklad from app.models import ( Component, Ingredient, LoadingReport, LoadingReportComponent, Recipe, ) init_sklad( app, db, Component=Component, LoadingReport=LoadingReport, LoadingReportComponent=LoadingReportComponent, Recipe=Recipe, Ingredient=Ingredient, ) def _run_auto_migrations(app: Flask) -> None: """Применяет alembic upgrade head (идемпотентно для существующей схемы).""" from alembic import command from alembic.config import Config as AlembicConfig # Чтобы migrations/env.py подключился к тем же URI, что и это приложение os.environ["WESP_RECIPES_DB_URI"] = app.config["SQLALCHEMY_DATABASE_URI"] binds = app.config.get("SQLALCHEMY_BINDS") or {} if binds.get("reports"): os.environ["WESP_REPORTS_DB_URI"] = binds["reports"] else: os.environ.pop("WESP_REPORTS_DB_URI", None) project_root = Path(__file__).resolve().parent.parent ini_path = project_root / "alembic.ini" cfg = AlembicConfig(str(ini_path)) cfg.set_main_option("script_location", str(project_root / "migrations")) from app.services.migration_pace import ( migration_pause, migration_pause_bind, release_sqlalchemy_sqlite_locks, ) global _alembic_host_app log = logging.getLogger(__name__) os.environ.pop("WESP_MIGRATION_BODY_RAN_COUNT", None) migration_pause("перед alembic upgrade") release_sqlalchemy_sqlite_locks(app) log.info( "База данных: Alembic upgrade head (recipes + reports — две БД, не повторный вызов create_app)..." ) # env.py выставляет WESP_ALEMBIC=1 через setdefault — снимаем после upgrade, иначе mDNS и др. службы # навсегда считают процесс «режимом Alembic» (см. mdns_skip_reason_on_boot). _alembic_flag_present = "WESP_ALEMBIC" in os.environ _alembic_flag_prev = os.environ.get("WESP_ALEMBIC") _alembic_host_app = app try: command.upgrade(cfg, "head") finally: _alembic_host_app = None if _alembic_flag_present: if _alembic_flag_prev is not None: os.environ["WESP_ALEMBIC"] = _alembic_flag_prev else: os.environ.pop("WESP_ALEMBIC", None) migration_pause("после alembic upgrade") from alembic.script import ScriptDirectory head_rev = ScriptDirectory.from_config(cfg).get_current_head() log.info("Миграции Alembic применены (ревизия %s).", head_rev or "—") # Догонка только если upgrade не выполнял тело wesp_2_0 (stamp / частичный прогон). binds = app.config.get("SQLALCHEMY_BINDS") or {} expected_bodies = 2 if binds.get("reports") else 1 bodies_ran = int(os.environ.pop("WESP_MIGRATION_BODY_RAN_COUNT", "0") or "0") if bodies_ran >= expected_bodies: log.info( "Идемпотентная догонка схемы пропущена: wesp_2_0 уже выполнен в Alembic (%s/%s БД).", bodies_ran, expected_bodies, ) return from alembic.operations import Operations from alembic.runtime.migration import MigrationContext from app.schema_bootstrap import run_wesp20_idempotent_sync log.info( "Идемпотентная догонка схемы 2.0 (Alembic не прогнал тело миграции, bodies=%s/%s)...", bodies_ran, expected_bodies, ) with app.app_context(): def _sync(engine: Engine, kw: dict) -> None: with engine.begin() as conn: ctx = MigrationContext.configure(conn) run_wesp20_idempotent_sync(Operations(ctx), kw) _sync(db.engine, {}) if binds.get("reports") and "reports" in db.engines: migration_pause_bind("догонка recipes → reports") _sync(db.engines["reports"], {"tag": "reports"}) migration_pause("после догонки схемы") log.info("Идемпотентная догонка схемы 2.0 выполнена.") def create_app( config_class: type | None = None, *, run_migrations: bool = True, ) -> Flask: """Application Factory для WESP.""" import wesp_runtime_env wesp_runtime_env.apply_kiosk_headless_env() if config_class is None: config_class = get_config_class() # env.py внутри command.upgrade: не второй Flask и не повторный db.init_app. if ( not run_migrations and os.environ.get("WESP_ALEMBIC", "").strip() == "1" and _alembic_host_app is not None ): return _alembic_host_app project_root = Path(__file__).resolve().parent.parent static_dir = project_root / "static" templates_dir = project_root / "templates" app = Flask( __name__, static_folder=str(static_dir), static_url_path="/static", template_folder=str(templates_dir), ) app.config.from_object(config_class) apply_security_settings_to_app(app) apply_network_settings_to_app(app) # env.py: create_app(run_migrations=False) — без повторного upgrade и без лишнего I/O. _alembic_env_only = ( os.environ.get("WESP_ALEMBIC", "").strip() == "1" and not run_migrations ) if run_migrations and not app.config.get("TESTING") and config_class is ProductionConfig: from config import validate_production_config validate_production_config(app) if not app.config.get("TESTING") and not _alembic_env_only: try: Path(app.config["BASE_DIR"], "data").mkdir(parents=True, exist_ok=True) except OSError: logging.getLogger(__name__).warning( "Не удалось создать каталог data/: %s", Path(app.config["BASE_DIR"], "data"), exc_info=True, ) try: from app.services.wesp_assistant_paths import ensure_assistant_layout ensure_assistant_layout(app.config.get("WESP_ASSISTANT_DIR")) except Exception: logging.getLogger(__name__).warning( "Каталог ассистента (WESP_ASSISTANT_DIR) не подготовлен", exc_info=True, ) log = logging.getLogger(__name__) if not _alembic_env_only: # Логирование (WESP_LOG_LEVEL → Config.LOG_LEVEL) log_level = _parse_log_level(app.config.get("LOG_LEVEL")) _fmt = "%(levelname)s:%(name)s:%(message)s" root = logging.getLogger() root.setLevel(logging.DEBUG) if not root.handlers: logging.basicConfig(level=logging.DEBUG, format=_fmt) for _h in root.handlers: _h.setLevel(log_level) for _name in ("app", "app.routes", "app.services", "sync_client"): logging.getLogger(_name).setLevel(min(log_level, logging.ERROR)) _configure_wesp_file_logging(app, log_level, _fmt) _install_immediate_log_flush(root) try: # Расширения db.init_app(app) CORS(app) # Настройки SQLite (WAL, synchronous, busy_timeout) на каждом engine (основной + binds). def _register_sqlite_pragmas(bind_engine: Engine) -> None: if bind_engine.dialect.name != "sqlite": return @event.listens_for(bind_engine, "connect") def set_sqlite_pragma(dbapi_connection, connection_record): # type: ignore[override] cursor = dbapi_connection.cursor() cursor.execute(f"PRAGMA journal_mode={app.config.get('SQLITE_JOURNAL_MODE', 'WAL')}") cursor.execute(f"PRAGMA synchronous={app.config.get('SQLITE_SYNCHRONOUS', 'NORMAL')}") busy_ms = int(app.config.get("SQLITE_BUSY_TIMEOUT_MS", 30000)) cursor.execute(f"PRAGMA busy_timeout={busy_ms}") cursor.close() with app.app_context(): for _eng in db.engines.values(): _register_sqlite_pragmas(_eng) # Импортируем модели, чтобы зарегистрировать их в metadata from app import models # noqa: F401 from importlib import import_module import_module("app.lab.models") if not app.config.get("TESTING") and not _alembic_env_only: try: from config import repair_sync_client_state_on_disk repair_sync_client_state_on_disk() except Exception: log.debug("repair_sync_client_state_on_disk", exc_info=True) _defer_heavy_startup = ( run_migrations and not app.config.get("TESTING") and not _alembic_env_only and bool(app.config.get("WESP_BACKGROUND_STARTUP", True)) ) app.config["WESP_DEFERRED_STARTUP"] = _defer_heavy_startup if run_migrations and not app.config.get("TESTING") and not _defer_heavy_startup: _run_auto_migrations(app) if _alembic_env_only: log.debug( "create_app: режим WESP_ALEMBIC (только ORM; upgrade в env.py, без повторных миграций)" ) return app # Регистрация blueprints (доступны /api/startup/status сразу; остальное — после готовности). from .routes import register_blueprints register_blueprints(app) # Маршруты sklad регистрируются до первого HTTP-запроса (нельзя в фоне после request). _register_sklad_api(app) if run_migrations and not app.config.get("TESTING") and not _defer_heavy_startup: from app.services.startup_background import _finish_app_startup from app.services.startup_state import mark_startup_ready _finish_app_startup(app) mark_startup_ready() if not _defer_heavy_startup: if ( run_migrations and not app.config.get("TESTING") and app.config.get("SYNC_BACKGROUND_REQUEUE", True) ): from app.services.startup_defer import schedule_sync_requeue_after_boot schedule_sync_requeue_after_boot(app) if not app.config.get("TESTING") and run_migrations: try: import psutil # noqa: F401 psutil.cpu_percent(interval=None) except ImportError: log.warning( "Пакет psutil не установлен: кольца CPU/RAM в админке будут пустыми. " "Установите: pip install psutil" ) except Exception: log.debug("psutil: прогрев при старте не удался", exc_info=True) try: from app.services.startup_defer import schedule_mdns_after_boot schedule_mdns_after_boot(app) except Exception: log.warning("mDNS не запланирован при старте", exc_info=True) from app.services.startup_defer import schedule_bootstrap_update_env schedule_bootstrap_update_env(app) # Киоск: те же HTML могут запрашиваться по /static/... — не блокировать (отдельная защита на маршрутах киоска). _STATIC_HTML_ALLOWED = frozenset( { "/static/login.html", "/static/unauthorized.html", "/static/recipes_selection.html", # !!!! костыль "/static/unloading.html", # !!!! костыль "/static/startup-loading.html", } ) @app.before_request def _wesp_startup_readiness_gate(): if app.config.get("TESTING"): return None from app.services.startup_background import is_startup_ready_implicit from app.services.startup_gate import is_path_allowed_during_startup if is_startup_ready_implicit(app): return None path = request.path or "" if is_path_allowed_during_startup(path): return None if path.startswith("/api/"): from app.services.startup_state import startup_status_payload body = startup_status_payload() return ( jsonify( { "status": "starting", "message": "Инициализация WESP…", **body, } ), 503, ) from urllib.parse import quote from app.services.hardware_settings_service import default_app_landing_path path_only = request.path or "/" if path_only in ("/", "/starting"): next_path = default_app_landing_path() else: next_path = request.full_path or path_only if next_path.endswith("?") and not request.query_string: next_path = path_only return redirect(f"/starting?next={quote(next_path, safe='/?:=&')}") if _defer_heavy_startup: from app.services.startup_background import schedule_background_startup schedule_background_startup(app) @app.before_request def _restrict_static_html_files() -> None: """Не отдавать HTML из static/ напрямую, кроме явного allowlist (обход именованных маршрутов).""" path = request.path if not path.startswith("/static/") or not path.endswith(".html"): return None if path in _STATIC_HTML_ALLOWED: return None abort(404) @app.after_request def _html_head_injects(response): from app.html_head_injects import apply_html_head_injects return apply_html_head_injects(response, request.path or "") @app.errorhandler(404) def _log_404(error): # type: ignore[no-untyped-def] """Диагностика киоска: фиксируем неожиданные 404 (в т.ч. для статики).""" try: ua = (request.headers.get("User-Agent") or "").strip() ref = (request.headers.get("Referer") or "").strip() logging.getLogger(__name__).warning( "[404] path=%s host=%s remote=%s ua=%s ref=%s", request.path, request.host, request.remote_addr, ua[:200], ref[:200], ) except Exception: pass return error.get_response() return app except Exception: log.exception( "Сбой при инициализации приложения (create_app): миграции, blueprints или склад" ) _append_startup_crash_dump(app) raise def _append_startup_crash_dump(app: Flask) -> None: """Дублирует traceback в отдельный файл, если основной файловый лог ещё не пишет ERROR (например, только WARNING+).""" if app.config.get("TESTING"): return try: import traceback log_path = Path(str(app.config.get("WESP_ADMIN_LOG_PATH") or "")).expanduser() if not log_path.name: log_path = Path(app.config["BASE_DIR"], "data", "logs", "wesp.log") crash_path = log_path.parent / "wesp-startup-crash.log" crash_path.parent.mkdir(parents=True, exist_ok=True) with crash_path.open("a", encoding="utf-8") as f: f.write("\n--- create_app failure ---\n") traceback.print_exc(file=f) except OSError: logging.getLogger(__name__).debug("Не удалось записать wesp-startup-crash.log", exc_info=True)