Files
site/WESP_REL/app/routes/pages.py
T
2026-07-17 12:57:18 +03:00

541 lines
17 KiB
Python
Raw 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.
"""
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 и прочих вставок в <head>)."""
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/<slug>")
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)