Files
2026-07-17 12:57:18 +03:00

533 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)