""" HTML-страницы приложения (без REST под /api). Откуда брать соответствие с монолитом: legacy/proga_monolith.py (@app.route для путей без префикса). Куда смотреть: - Статика: каталог static/ (раздаётся Flask как /static/... и send_from_directory для целых страниц). - Шаблоны Jinja: каталог templates/ (задаётся в create_app как template_folder; звук выгрузки там же — отдаётся как /sounds/unloading.mp3). - Весы: app.routes.scales — общий ScalesReader через _get_reader() (тот же, что /current_weight, /stream_weight). - Текущий рецепт при загрузке: app.routes.legacy_misc — _runtime и _get_active_recipe() (те же, что /api/get_current_recipe). """ import json import logging import uuid from datetime import datetime from pathlib import Path from flask import ( Blueprint, current_app, make_response, redirect, render_template, request, send_from_directory, session, url_for, ) from sqlalchemy import select from app import db from app.kiosk_device_cookie import set_kiosk_device_cookie from app.models import KioskDevice from app.routes.auth_decorators import _paired_terminal_valid, can_access_lab, is_superuser_session from app.routes import legacy_misc from app.routes.scales import _get_reader from app.services.hardware_settings_service import default_app_landing_path from app.services.setup_state import is_setup_completed bp = Blueprint("pages", __name__) logger = logging.getLogger(__name__) _SETUP_GUARD_EXACT = { "/setup", "/favicon.ico", "/calibration", "/calibrate", "/calibrate/continue", "/tare", "/current_weight", "/current_raw_data", "/stream_weight", } _SETUP_GUARD_PREFIXES = ( "/static/", "/api/setup", "/api/health", "/api/startup/", "/api/kiosk/", "/starting", ) @bp.before_app_request def _setup_wizard_guard(): if is_setup_completed(current_app): return None if current_app.config.get("TESTING") and current_app.config.get( "WESP_TESTING_BYPASS_SETUP_GUARD" ): return None path = request.path or "" if path in _SETUP_GUARD_EXACT: return None for prefix in _SETUP_GUARD_PREFIXES: if path.startswith(prefix): return None return redirect(url_for("pages.setup_page")) def _static_dir() -> str: if current_app.static_folder: return current_app.static_folder return str(Path(current_app.root_path).parent / "static") def _templates_dir() -> str: """Каталог templates/ в корне проекта (Jinja + вспомогательные файлы вроде звука).""" return str(Path(current_app.root_path).parent / "templates") def _no_cache(response): """Заголовки против кеширования (как в монолите для feed_dispensers).""" response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" response.headers["Pragma"] = "no-cache" response.headers["Expires"] = "0" return response def _send_static_html(filename: str): """Отдать HTML из static/ без direct_passthrough (для favicon и прочих вставок в ).""" path = Path(_static_dir()) / filename html = path.read_text(encoding="utf-8") return make_response(html) # Киоск/legacy иногда открывают HTML напрямую из /static/ — отдаём через inject, не send_file. _STATIC_HTML_ALIASES = { "/static/login.html": "login.html", "/static/unauthorized.html": "unauthorized.html", "/static/recipes_selection.html": "recipes_selection.html", "/static/unloading.html": "unloading.html", "/static/startup-loading.html": "startup-loading.html", } @bp.before_app_request def _serve_allowlisted_static_html(): filename = _STATIC_HTML_ALIASES.get(request.path or "") if not filename: return None return _send_static_html(filename) def _safe_next_path(raw: str | None) -> str | None: """Разрешить только относительный путь на этом же origin (без open redirect).""" if not raw or not isinstance(raw, str): return None s = raw.strip() if not s.startswith("/"): return None if s.startswith("//"): return None if "://" in s: return None if "\x00" in s or "\r" in s or "\n" in s: return None path_only = s.split("?", 1)[0] if "@" in path_only: return None return s def _current_path_for_next() -> str: """Путь + query для параметра next при редиректе на логин.""" qs = request.query_string.decode("utf-8", errors="replace") if qs: return request.path + "?" + qs return request.path def _require_zootech_session(): """Если нет сессии администратора — редирект на /login с безопасным next.""" if session.get("authenticated"): return None safe = _safe_next_path(_current_path_for_next()) if safe: return redirect(url_for("pages.login_page", next=safe)) return redirect(url_for("pages.login_page")) def _require_superuser_for_admin_page(): if is_superuser_session(): return None return redirect(url_for("pages.recipes_page")) def _require_lab_access_page(): guard = _require_zootech_session() if guard is not None: return guard if not can_access_lab(): return redirect(url_for("pages.recipes_page")) return None KIOSK_PAGE_PATHS = { "/scales", "/calibration", "/db_check", "/component_loader", "/duplicate", "/unloading", } def _ensure_kiosk_cookie(response): """Assign persistent kiosk device cookie and upsert terminal row.""" if request.path not in KIOSK_PAGE_PATHS: return response device_id = (request.cookies.get("wesp_kiosk_device_id") or "").strip() new_cookie = False if not device_id: device_id = str(uuid.uuid4()) new_cookie = True row = db.session.execute(select(KioskDevice).where(KioskDevice.id == device_id)).scalar_one_or_none() if row is None: row = KioskDevice( id=device_id, display_name=f"terminal-{device_id[:8]}", status="pending", last_ip=request.remote_addr, last_seen_at=datetime.utcnow(), ) db.session.add(row) else: row.last_ip = request.remote_addr row.last_seen_at = datetime.utcnow() db.session.commit() if new_cookie: logger.info( "[KIOSK-COOKIE] новый терминал: device_id=%s path=%s host=%s remote=%s", device_id, request.path, request.host, request.remote_addr, ) set_kiosk_device_cookie(response, device_id) return response @bp.route("/kiosk/start/") def kiosk_start(slug: str): """Start URL для Fully Kiosk Browser: cookie + active + редирект на весы.""" slug = (slug or "").strip() if not slug: # На Raspberry Pi (localhost kiosk boot) иногда всплывает пустой/старый start URL. # Чтобы киоск не «залипал» на 404, мягко уводим на /starting. if _is_localhost_request(): logger.warning( "[KIOSK] start URL: пустой slug (localhost) → redirect /starting remote=%s ua=%s", request.remote_addr, (request.headers.get("User-Agent") or "")[:120], ) return redirect( url_for("pages.starting_page") + f"?next={default_app_landing_path()}", code=302, ) logger.warning( "[KIOSK] start URL: пустой slug remote=%s ua=%s", request.remote_addr, (request.headers.get("User-Agent") or "")[:120], ) return "Ссылка не найдена", 404 device = db.session.execute( select(KioskDevice).where(KioskDevice.access_slug == slug) ).scalar_one_or_none() if not device or device.status == "revoked": logger.warning( "[KIOSK] start URL: неизвестный или отозванный slug=%s remote=%s", slug[:12] + "…" if len(slug) > 12 else slug, request.remote_addr, ) if _is_localhost_request(): logger.warning( "[KIOSK] start URL invalid (localhost) → redirect /starting slug=%s remote=%s", slug[:12] + "…" if len(slug) > 12 else slug, request.remote_addr, ) return redirect( url_for("pages.starting_page") + f"?next={default_app_landing_path()}", code=302, ) return "Ссылка не найдена", 404 device.status = "active" device.last_seen_at = datetime.utcnow() device.last_ip = request.remote_addr db.session.commit() response = make_response(redirect(url_for("pages.scales"))) set_kiosk_device_cookie(response, device.id) logger.info( "[KIOSK] start URL OK: device_id=%s remote=%s", device.id, request.remote_addr, ) return response def _is_localhost_request() -> bool: host = (request.host.split(":")[0] if request.host else "").lower() remote = (request.remote_addr or "").lower() localhost_hosts = {"localhost", "127.0.0.1", "::1"} return host in localhost_hosts or remote in localhost_hosts def _apply_kiosk_guard(response): """Set kiosk cookie/device and redirect unpaired non-localhost terminals.""" response = _ensure_kiosk_cookie(response) if request.path == "/calibration" and not is_setup_completed(current_app): return response if request.path not in KIOSK_PAGE_PATHS: return response if _is_localhost_request(): return response if not bool(current_app.config.get("KIOSK_ENFORCE_PAIRED_ONLY", True)): return response if _paired_terminal_valid(): return response did = (request.cookies.get("wesp_kiosk_device_id") or "").strip() logger.warning( "[KIOSK-GUARD] непривязанный терминал → redirect unauthorized: device_id=%s next=%s host=%s remote=%s", did or "(нет cookie)", request.path, request.host, request.remote_addr, ) target = url_for("pages.unauthorized_device_page", next=request.path) guarded_response = make_response(redirect(target)) return _ensure_kiosk_cookie(guarded_response) # --- URL как в монолите: файл templates/unloading.mp3 --- @bp.route("/sounds/unloading.mp3") def unloading_sound(): return send_from_directory( _templates_dir(), "unloading.mp3", mimetype="audio/mpeg", ) # --- Только файлы из static/ (одна HTML-страница = один файл) --- @bp.route("/favicon.ico") def favicon(): return send_from_directory( _static_dir(), "favicon-32.png", mimetype="image/png", ) @bp.route("/") def index(): return _send_static_html("login.html") @bp.route("/login") def login_page(): return _send_static_html("login.html") @bp.route("/components") def components_page(): guard = _require_zootech_session() if guard is not None: return guard return _send_static_html("components.html") @bp.route("/lab") def lab_sandbox_page(): guard = _require_lab_access_page() if guard is not None: return guard return _send_static_html("lab.html") @bp.route("/lab/profiles") def lab_profiles_page(): guard = _require_lab_access_page() if guard is not None: return guard return _send_static_html("lab-profiles.html") @bp.route("/api/recipes_wibor") @bp.route("/recipes_selection") def recipes_wibor_page(): # /api/recipes_wibor — URL из старых клиентов; /recipes_selection — то же UI, короткий путь. # Не требует сессии зоотехника: открывается с киоска (/scales и т.д.), доступ к API — по paired terminal / legacy guards. return _send_static_html("recipes_selection.html") @bp.route("/recipes") def recipes_page(): guard = _require_zootech_session() if guard is not None: return guard return _send_static_html("recipes.html") @bp.route("/recipes_content") def recipes_content(): guard = _require_zootech_session() if guard is not None: return guard return _send_static_html("recipes.html") @bp.route("/unloading") def unloading_page(): response = _send_static_html("unloading.html") return _apply_kiosk_guard(response) @bp.route("/reports") def reports_page(): guard = _require_zootech_session() if guard is not None: return guard return _send_static_html("reports.html") @bp.route("/feed_consumption") def feed_consumption_page(): guard = _require_zootech_session() if guard is not None: return guard return _send_static_html("consumption.html") @bp.route("/feed_dispensers") def feed_dispensers_page(): guard = _require_zootech_session() if guard is not None: return guard response = _send_static_html("feed_dispensers.html") return _no_cache(response) @bp.route("/admin") def admin_page(): guard = _require_zootech_session() if guard is not None: return guard super_guard = _require_superuser_for_admin_page() if super_guard is not None: return super_guard response = _send_static_html("admin.html") return _no_cache(response) @bp.route("/unauthorized-device") def unauthorized_device_page(): # Редирект сюда при MAC-lock в монолите; отдельно ответы API — 403 JSON return _send_static_html("unauthorized.html"), 403 @bp.route("/starting") def starting_page(): """Экран загрузки, пока в фоне идут миграции и инициализация.""" return _send_static_html("startup-loading.html") @bp.route("/setup") def setup_page(): return _send_static_html("setup.html") @bp.route("/logo-loader-demo") def logo_loader_demo_page(): """Превью пиксельной анимации логотипа (экран загрузки).""" return _send_static_html("logo-loader-demo.html") # --- Шаблоны templates/ + данные с бэкенда --- @bp.route("/scales") def scales(): # Вес в интерфейсе тянет SSE с /stream_weight, обнуление — POST /tare (blueprint scales) response = make_response(render_template("desktop_index.html")) return _apply_kiosk_guard(response) @bp.route("/calibration") def calibration(): # Тот же ScalesReader, что и маршруты в app.routes.scales embed = request.args.get("embed") == "1" setup_mode = not is_setup_completed(current_app) reader = _get_reader() current_weight = reader.get_current_weight() factor = reader.get_calibration_factor() raw = reader.calibration_debug_payload() stream_w = current_weight response = make_response(render_template( "calibration.html", embed=embed, setup_mode=setup_mode, dispenser_name="", current_weight=int(current_weight), calibration_factor=round(factor, 2), stream_weight=int(stream_w), raw_data=json.dumps(raw, indent=2), )) return _apply_kiosk_guard(response) @bp.route("/db_check") def db_check_page(): # Диагностика БД в UI; при необходимости дополняется вызовами /api/sync/check-db и т.д. из фронта embed = request.args.get("embed") == "1" response = make_response(render_template("db_check.html", embed=embed)) return _apply_kiosk_guard(response) @bp.route("/component_loader") def component_loader(): # Второй экран «только загрузка»; живые данные в монолите шли из глобального состояния — здесь стартовые заглушки. # Для обновления в реальном времени фронт может использовать /api/weight_display_data (legacy_misc). response = make_response(render_template( "duplicate_selection.html", component_name="Не выбран компонент", remaining_weight="0", current_loaded="0.0", total_component="0.0", total_mixture="0.0", last_update=datetime.now().strftime("%H:%M:%S"), )) return _apply_kiosk_guard(response) @bp.route("/duplicate") def duplicate_selection_page(): # Дублёр экрана выбора рецепта; основная логика на клиенте ходит в /api/* из legacy_misc try: snap = legacy_misc._runtime.snapshot() current_recipe = legacy_misc._get_active_recipe(snap["current_recipe_id"]) response = make_response(render_template( "recipes_selection_duplicate.html", current_recipe=current_recipe, )) return _apply_kiosk_guard(response) except Exception: response = make_response(render_template( "recipes_selection_duplicate.html", current_recipe=None, )) return _apply_kiosk_guard(response)