Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
+532
View File
@@ -0,0 +1,532 @@
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)