"""
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)