@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user