@@ -0,0 +1,540 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user