541 lines
17 KiB
Python
541 lines
17 KiB
Python
"""
|
||
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)
|