533 lines
21 KiB
Python
533 lines
21 KiB
Python
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 (по умолчанию <BASE_DIR>/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)
|
||
|