Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
+40
View File
@@ -0,0 +1,40 @@
from flask import Blueprint, Flask
def register_blueprints(app: Flask) -> None:
"""Регистрирует все blueprints приложения.
Каркас маршрутов; легаси-сверка — legacy/proga_monolith.py.
"""
# Заглушки для будущих модулей
from . import auth, components, recipes, reports, reports_legacy, equipment, periods, sync, scales, sklad, pages, legacy_misc, kiosk, admin, setup, startup, feed_accounting, notifications, feed_quality, daily_plan, analytics, lab # noqa: F401
# Каждый модуль должен предоставить blueprint переменной bp
for module in (
startup,
auth,
components,
recipes,
reports,
reports_legacy,
equipment,
periods,
sync,
scales,
sklad,
pages,
legacy_misc,
kiosk,
admin,
setup,
feed_accounting,
notifications,
feed_quality,
daily_plan,
analytics,
lab,
):
bp = getattr(module, "bp", None)
if bp is not None:
app.register_blueprint(bp)
File diff suppressed because it is too large Load Diff
+145
View File
@@ -0,0 +1,145 @@
from flask import Blueprint, Response, jsonify, request
from app.routes.auth_decorators import require_auth
from app.services.analytics.export_sections import export_filename_base, parse_export_sections
from app.services.analytics.finance_summary import build_finance_summary
from app.services.analytics.pdf_export import build_analytics_pdf
from app.services.analytics.plan_fact_layers import build_plan_fact_rows
from app.services.analytics.stock_forecast import build_stock_forecast
from app.services.analytics.xlsx_summary import build_finance_summary_xlsx
bp = Blueprint("analytics", __name__, url_prefix="/api/analytics")
def _error(message: str, status_code: int = 400):
return jsonify({"error": True, "message": message}), status_code
def _export_params():
date_from = (request.args.get("date_from") or "").strip()
date_to = (request.args.get("date_to") or "").strip()
if not date_from or not date_to:
return None, None, None, None, _error("Укажите date_from и date_to (YYYY-MM-DD)", 400)
recipe_id = (request.args.get("recipe_id") or "").strip() or None
recipe_ids = (request.args.get("recipe_ids") or "").strip() or None
return date_from, date_to, recipe_id, recipe_ids, None
@bp.get("/finance")
@require_auth
def finance_summary():
return jsonify(
build_finance_summary(
date_from=request.args.get("date_from"),
date_to=request.args.get("date_to"),
recipe_id=(request.args.get("recipe_id") or "").strip() or None,
recipe_ids=(request.args.get("recipe_ids") or "").strip() or None,
)
)
@bp.get("/plan-fact")
@require_auth
def plan_fact():
return jsonify(
build_plan_fact_rows(
date_from=request.args.get("date_from"),
date_to=request.args.get("date_to"),
recipe_id=(request.args.get("recipe_id") or "").strip() or None,
recipe_ids=(request.args.get("recipe_ids") or "").strip() or None,
client_id=(request.args.get("client_id") or "").strip() or None,
)
)
@bp.get("/stock-forecast")
@require_auth
def stock_forecast():
from app.models import Component
return jsonify(
build_stock_forecast(
plan_date=request.args.get("plan_date"),
Component=Component,
)
)
def _filter_context_kwargs():
return {
"filter_farms": (request.args.get("filter_farms") or "").strip() or None,
"filter_dispensers": (request.args.get("filter_dispensers") or "").strip() or None,
"filter_recipes": (request.args.get("filter_recipes") or "").strip() or None,
}
@bp.get("/export")
@require_auth
def analytics_export():
date_from, date_to, recipe_id, recipe_ids, err = _export_params()
if err:
return err
fmt = (request.args.get("format") or "xlsx").strip().lower()
section = (request.args.get("section") or "all").strip().lower()
sections = parse_export_sections(section)
base_name = export_filename_base(
date_from=date_from, date_to=date_to, sections=sections
)
ctx = _filter_context_kwargs()
if fmt == "pdf":
data = build_analytics_pdf(
date_from=date_from,
date_to=date_to,
recipe_id=recipe_id,
recipe_ids=recipe_ids,
section=section,
**ctx,
)
return Response(
data,
mimetype="application/pdf",
headers={
"Content-Disposition": f'attachment; filename="{base_name}.pdf"'
},
)
if fmt in ("xlsx", "excel"):
data = build_finance_summary_xlsx(
date_from=date_from,
date_to=date_to,
recipe_id=recipe_id,
recipe_ids=recipe_ids,
section=section,
**ctx,
)
return Response(
data,
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f'attachment; filename="{base_name}.xlsx"'
},
)
return _error("format должен быть xlsx или pdf", 400)
@bp.get("/finance/export")
@require_auth
def finance_export():
date_from, date_to, recipe_id, recipe_ids, err = _export_params()
if err:
return err
section = (request.args.get("section") or "all").strip().lower()
sections = parse_export_sections(section)
data = build_finance_summary_xlsx(
date_from=date_from,
date_to=date_to,
recipe_id=recipe_id,
recipe_ids=recipe_ids,
section=section,
**_filter_context_kwargs(),
)
filename = f"{export_filename_base(date_from=date_from, date_to=date_to, sections=sections)}.xlsx"
return Response(
data,
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
+246
View File
@@ -0,0 +1,246 @@
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from werkzeug.security import check_password_hash, generate_password_hash
from flask import Blueprint, current_app, jsonify, request, session
from app import db
from app.models import WebUser
from config import Config
from app.routes.auth_decorators import can_access_lab, require_auth
bp = Blueprint("auth", __name__, url_prefix="/api/auth")
_PASSWORD_MIN_LEN = 8
_USER_RULES_PAYLOAD = {
"password_min_len": _PASSWORD_MIN_LEN,
}
def load_credentials():
"""Получение учетных данных из переменных окружения."""
return (
current_app.config.get("AUTH_LOGIN", getattr(Config, "AUTH_LOGIN", "admin")),
current_app.config.get(
"AUTH_PASSWORD", getattr(Config, "AUTH_PASSWORD", "admin")
),
)
def _ensure_default_superuser() -> None:
saved_login, saved_password = load_credentials()
login_key = saved_login.strip()
if db.session.scalar(select(WebUser.id).where(WebUser.login == login_key).limit(1)):
return
if db.session.scalar(select(WebUser.id).limit(1)):
return
user = WebUser(
login=login_key,
password_hash=generate_password_hash(saved_password),
is_superuser=True,
)
db.session.add(user)
try:
db.session.commit()
except IntegrityError:
db.session.rollback()
def _get_user_by_login(login: str) -> WebUser | None:
return db.session.execute(
select(WebUser).where(WebUser.login == login.strip())
).scalar_one_or_none()
def save_credentials(login: str, password: str) -> None:
"""Смена учетных данных через API отключена (env-only)."""
_ = (login, password)
raise RuntimeError(
"Смена учетных данных через API отключена. Используйте WESP_AUTH_LOGIN/WESP_AUTH_PASSWORD."
)
@bp.get("/ping")
def auth_ping():
return jsonify({"status": "ok"}), 200
@bp.post("/login")
def authenticate_login():
data = request.json or {}
login = (data.get("login") or "").strip()
password = data.get("password") or ""
if not login:
return jsonify({"status": "error", "message": "Не указан логин"}), 400
if not password:
return jsonify({"status": "error", "message": "Не указан пароль"}), 400
_ensure_default_superuser()
user = _get_user_by_login(login)
if user is not None and check_password_hash(user.password_hash, password):
remember = bool(data.get("remember"))
session["authenticated"] = True
session["user_login"] = user.login
session["is_superuser"] = bool(user.is_superuser)
session["lab_access"] = bool(user.is_superuser or user.lab_access)
session["remember_login"] = remember
if remember:
session.permanent = True
else:
session.permanent = False
return jsonify(
{
"status": "success",
"message": "Авторизация успешна",
"authenticated": True,
}
)
return (
jsonify(
{
"status": "error",
"message": "Неверный логин или пароль",
"authenticated": False,
}
),
401,
)
@bp.post("/logout")
def logout():
session.clear()
return jsonify({"status": "success", "message": "Выход выполнен успешно"})
@bp.get("/check")
def check_auth():
authenticated = session.get("authenticated", False)
user_login = session.get("user_login", "")
remember_login = bool(session.get("remember_login"))
is_superuser = bool(session.get("is_superuser", False))
can_lab = can_access_lab() if authenticated else False
if authenticated:
session["lab_access"] = can_lab
return jsonify(
{
"status": "success",
"authenticated": authenticated,
"user_login": user_login,
"remember_login": remember_login,
"is_superuser": is_superuser,
"can_lab": can_lab,
"user_rules": _USER_RULES_PAYLOAD,
}
)
@bp.get("/get_current_credentials")
def get_current_credentials():
current_login = (session.get("user_login") or "").strip()
if not current_login:
saved_login, _saved_password = load_credentials()
current_login = saved_login
return jsonify(
{
"status": "success",
"login": current_login,
# Никогда не возвращаем пароль в API.
"password": "",
"user_rules": _USER_RULES_PAYLOAD,
}
)
@bp.post("/change_credentials")
@require_auth
def change_credentials():
from app.routes.admin import (
_WEB_USER_PASSWORD_MIN_LEN,
_reject_if_weak_login_or_password,
_valid_login,
)
data = request.json or {}
old_login = (data.get("old_login") or "").strip()
old_password = data.get("old_password") or ""
new_login = (data.get("new_login") or "").strip()
new_password = data.get("new_password") or ""
confirm_password = data.get("confirm_password") or new_password
session_login = (session.get("user_login") or "").strip()
if not session_login:
return jsonify({"status": "error", "message": "Сессия не найдена"}), 401
user = _get_user_by_login(session_login)
if user is None:
return jsonify({"status": "error", "message": "Пользователь не найден"}), 404
if old_login != user.login:
return (
jsonify({"status": "error", "message": "Неверный текущий логин или пароль"}),
401,
)
if not check_password_hash(user.password_hash, old_password):
return (
jsonify({"status": "error", "message": "Неверный текущий логин или пароль"}),
401,
)
if not new_login:
return jsonify({"status": "error", "message": "Новый логин не может быть пустым"}), 400
if not new_password:
return jsonify({"status": "error", "message": "Новый пароль не может быть пустым"}), 400
if new_password != confirm_password:
return jsonify({"status": "error", "message": "Новые пароли не совпадают"}), 400
if not _valid_login(new_login):
return (
jsonify(
{
"status": "error",
"message": "Логин не длиннее 64 символов",
}
),
400,
)
if len(new_password) < _WEB_USER_PASSWORD_MIN_LEN:
return (
jsonify(
{
"status": "error",
"message": f"Пароль должен быть не короче {_WEB_USER_PASSWORD_MIN_LEN} символов",
}
),
400,
)
err_body, err_code = _reject_if_weak_login_or_password(new_login, new_password)
if err_body is not None:
return jsonify(err_body), err_code
if new_login != user.login:
existing = _get_user_by_login(new_login)
if existing is not None and existing.id != user.id:
return (
jsonify({"status": "error", "message": "Пользователь с таким логином уже существует"}),
409,
)
user.login = new_login
user.password_hash = generate_password_hash(new_password)
try:
db.session.commit()
except IntegrityError:
db.session.rollback()
return (
jsonify({"status": "error", "message": "Пользователь с таким логином уже существует"}),
409,
)
session["user_login"] = new_login
return jsonify({"status": "success", "message": "Учетные данные обновлены"})
+261
View File
@@ -0,0 +1,261 @@
import hmac
from functools import wraps
from flask import current_app, jsonify, request, session
from sqlalchemy import select
def require_auth(f):
"""Session-based authorization guard for protected API routes."""
@wraps(f)
def decorated_function(*args, **kwargs):
if not session.get("authenticated", False):
return (
jsonify(
{
"error": True,
"status": "error",
"message": "Требуется авторизация",
}
),
401,
)
return f(*args, **kwargs)
return decorated_function
def is_superuser_session() -> bool:
if not session.get("authenticated", False):
return False
return bool(session.get("is_superuser", False))
def can_access_lab() -> bool:
"""Lab доступен суперпользователю или при lab_access в БД."""
if not session.get("authenticated", False):
return False
if is_superuser_session():
return True
login = (session.get("user_login") or "").strip()
if not login:
return False
try:
from app import db
from app.models import WebUser
user = db.session.execute(
select(WebUser).where(WebUser.login == login)
).scalar_one_or_none()
return bool(user and user.lab_access)
except Exception:
return False
def require_lab_access(f):
"""Allow only authenticated users with Lab module access."""
@wraps(f)
def decorated_function(*args, **kwargs):
if not session.get("authenticated", False):
return (
jsonify(
{
"error": True,
"status": "error",
"message": "Требуется авторизация",
}
),
401,
)
if not can_access_lab():
return (
jsonify(
{
"error": True,
"status": "error",
"message": "Модуль Lab недоступен для этого пользователя",
}
),
403,
)
return f(*args, **kwargs)
return decorated_function
def require_superuser(f):
"""Allow only authenticated superusers for admin APIs."""
@wraps(f)
def decorated_function(*args, **kwargs):
if not session.get("authenticated", False):
return (
jsonify(
{
"error": True,
"status": "error",
"message": "Требуется авторизация",
}
),
401,
)
if not is_superuser_session():
return (
jsonify(
{
"error": True,
"status": "error",
"message": "Доступ только для суперпользователя",
}
),
403,
)
return f(*args, **kwargs)
return decorated_function
def _device_token_valid() -> bool:
if not bool(current_app.config.get("CALIBRATION_PUBLIC", False)):
return False
expected = str(current_app.config.get("DEVICE_TOKEN", "") or "").strip()
if not expected:
return False
header_name = str(current_app.config.get("DEVICE_TOKEN_HEADER", "X-Device-Token"))
provided = (request.headers.get(header_name) or request.args.get("device_token") or "").strip()
if not provided:
return False
return hmac.compare_digest(provided, expected)
def require_session_or_device_token(f):
"""Allow either authenticated session or valid kiosk device token."""
@wraps(f)
def decorated_function(*args, **kwargs):
if session.get("authenticated", False) or _device_token_valid():
return f(*args, **kwargs)
return (
jsonify(
{
"error": True,
"status": "error",
"message": "Требуется авторизация или device token",
}
),
401,
)
return decorated_function
def _paired_terminal_valid() -> bool:
device_id = (request.cookies.get("wesp_kiosk_device_id") or "").strip()
if not device_id:
return False
try:
from app import db
from app.models import KioskDevice
row = db.session.execute(
select(KioskDevice).where(
KioskDevice.id == device_id, KioskDevice.status == "active"
)
).scalar_one_or_none()
return row is not None
except Exception:
return False
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 can_issue_kiosk_access_link() -> bool:
"""Start URL / QR — только localhost (настройка) или сессия администратора."""
if session.get("authenticated", False):
return True
return _is_localhost_request()
def require_paired_terminal(f):
"""Allow only paired kiosk terminals (or bypass when disabled by config)."""
_SETUP_CALIBRATION_PATHS = {
"/calibrate",
"/calibrate/continue",
"/tare",
"/current_weight",
"/current_raw_data",
"/stream_weight",
}
@wraps(f)
def decorated_function(*args, **kwargs):
path = request.path or ""
try:
from app.services.setup_state import is_setup_completed
# Во время мастера настройки — весы без привязки; в pytest (TESTING) правила
# привязки не ослабляем, иначе test_requires_token_or_session ломается.
if (
not is_setup_completed(current_app)
and path in _SETUP_CALIBRATION_PATHS
and not current_app.config.get("TESTING")
):
return f(*args, **kwargs)
except Exception:
pass
if _is_localhost_request():
return f(*args, **kwargs)
if not bool(current_app.config.get("KIOSK_ENFORCE_PAIRED_ONLY", True)):
return f(*args, **kwargs)
if _paired_terminal_valid():
return f(*args, **kwargs)
return (
jsonify(
{
"error": True,
"status": "error",
"message": "Терминал не привязан. Откройте привязку терминала.",
"paired": False,
}
),
401,
)
return decorated_function
def require_auth_or_paired_terminal(f):
"""Вход администратора (сессия) ИЛИ доступ как у киоска (localhost / привязанный терминал / без guard)."""
@wraps(f)
def decorated_function(*args, **kwargs):
if session.get("authenticated", False):
return f(*args, **kwargs)
if _is_localhost_request():
return f(*args, **kwargs)
if not bool(current_app.config.get("KIOSK_ENFORCE_PAIRED_ONLY", True)):
return f(*args, **kwargs)
if _paired_terminal_valid():
return f(*args, **kwargs)
return (
jsonify(
{
"error": True,
"status": "error",
"message": "Требуется вход в систему или привязанный киоск.",
}
),
401,
)
return decorated_function
+292
View File
@@ -0,0 +1,292 @@
from flask import Blueprint, jsonify, request
from sqlalchemy import select
from app import db
from app.lab.calc.feed_groups import is_canonical_feed_type, list_component_feed_types
from app.lab.services.component_nutrients import nutrients_api_dict, save_component_nutrients
from app.models import Component
from app.routes.auth_decorators import can_access_lab, require_auth
from app.services.component_external_no import resolve_component_external_no
from config import Config
bp = Blueprint("components", __name__, url_prefix="/api/components")
@bp.get("/ping")
def components_ping():
"""Health-check для модуля компонентов."""
return jsonify({"status": "ok"}), 200
@bp.get("/feed-types")
@require_auth
def get_feed_types():
"""Канонические типы сырья для /components и авторациона."""
return jsonify({"types": list_component_feed_types()})
def _add_no_cache_headers(response):
"""Минимальный аналог add_no_cache_headers из легаси."""
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
return response
def _component_to_api(component: Component, *, include_nutrients: bool | None = None) -> dict:
show_nutrients = include_nutrients if include_nutrients is not None else can_access_lab()
payload = {
"id": component.id,
"name": component.name,
"type": component.type,
"is_active": component.is_active,
"dryMatter": component.dry_matter,
"protein": component.protein,
"energy": component.energy,
"price": component.price,
"externalNo": component.external_no,
"version": component.version,
"created_at": component.created_at.isoformat() if component.created_at else None,
"updated_at": component.updated_at.isoformat() if component.updated_at else None,
"created_by": component.created_by,
"updated_by": component.updated_by,
}
if show_nutrients:
payload["nutrients"] = nutrients_api_dict(component.id)
else:
payload["nutrients"] = {}
return payload
@bp.get("")
@require_auth
def get_components():
"""Возвращает список активных компонентов (совместимо с легаси /api/components)."""
try:
limit = int(request.args.get("limit", 1000))
offset = int(request.args.get("offset", 0))
except ValueError:
return jsonify({"error": True, "message": "Некорректные параметры пагинации"}), 400
stmt = (
select(Component)
.where(Component.is_active.is_(True), Component.is_deleted.is_(False))
.offset(offset)
.limit(limit)
)
items = db.session.execute(stmt).scalars().all()
response = jsonify([_component_to_api(c) for c in items])
return _add_no_cache_headers(response)
@bp.get("/deleted")
@require_auth
def get_deleted_components():
rows = db.session.execute(
select(Component)
.where(Component.is_deleted.is_(True))
.order_by(Component.deleted_at.desc())
).scalars().all()
return jsonify(
[
{
"id": c.id,
"name": c.name,
"is_deleted": c.is_deleted,
"deleted_at": c.deleted_at.isoformat() if c.deleted_at else None,
"deleted_by": c.deleted_by,
}
for c in rows
]
)
@bp.get("/<string:component_id>")
@require_auth
def get_component(component_id: str):
"""Возвращает один компонент по ID (совместимо с легаси)."""
component = db.session.get(Component, component_id)
if component is None:
return jsonify({"error": True, "message": "Компонент не найден"}), 404
if getattr(component, "is_deleted", False):
return jsonify({"error": True, "message": "Компонент удалён"}), 404
return jsonify(_component_to_api(component))
@bp.post("")
@require_auth
def create_component():
"""Создание компонента (минимально совместимо с легаси)."""
data = request.get_json() or {}
name = (data.get("name") or "").strip()
if not name:
return jsonify({"error": True, "message": "Название компонента обязательно"}), 400
comp_type = (data.get("type") or "").strip()
if not is_canonical_feed_type(comp_type):
return jsonify(
{
"error": True,
"message": "Выберите тип корма: Грубые / Сочные / Концентрированные / Добавки",
}
), 400
component = Component(
name=name,
type=comp_type,
is_active=bool(data.get("is_active", True)),
dry_matter=float(data.get("dry_matter", data.get("dryMatter", 0)) or 0),
protein=float(data.get("protein", 0) or 0),
energy=float(data.get("energy", 0) or 0),
price=float(data.get("price", 0) or 0),
created_by="system",
updated_by="system",
)
external_raw = data.get("externalNo", data.get("external_no"))
external_no, ext_error = resolve_component_external_no(external_raw)
if ext_error:
return jsonify({"error": True, "message": ext_error}), 400
component.external_no = external_no
db.session.add(component)
db.session.flush()
save_result: dict = {}
if "nutrients" in data:
if not can_access_lab():
return (
jsonify(
{
"error": True,
"message": "Модуль Lab недоступен для этого пользователя",
}
),
403,
)
save_result = save_component_nutrients(component.id, data.get("nutrients") or {})
db.session.commit()
return (
jsonify(
{
"success": True,
"message": "Компонент успешно добавлен",
"id": component.id,
"affectedRecipeIds": save_result.get("affectedRecipeIds") or [],
}
),
201,
)
@bp.put("/<string:component_id>")
@require_auth
def update_component(component_id: str):
"""Обновление параметров компонента, включая dry_matter."""
component = db.session.get(Component, component_id)
if component is None:
return jsonify({"error": True, "message": "Компонент не найден"}), 404
if getattr(component, "is_deleted", False):
return jsonify({"error": True, "message": "Компонент удалён"}), 404
data = request.get_json() or {}
name = data.get("name")
if name is not None:
component.name = name.strip()
if "type" in data:
comp_type = (data.get("type") or "").strip()
if comp_type and not is_canonical_feed_type(comp_type):
return jsonify(
{
"error": True,
"message": "Выберите тип корма: Грубые / Сочные / Концентрированные / Добавки",
}
), 400
component.type = comp_type
if "is_active" in data:
component.is_active = bool(data.get("is_active"))
if "dry_matter" in data or "dryMatter" in data:
dm_raw = data.get("dry_matter", data.get("dryMatter"))
try:
component.dry_matter = float(dm_raw or 0)
except (TypeError, ValueError):
return (
jsonify({"error": True, "message": "Некорректное значение dry_matter"}),
400,
)
if "protein" in data:
component.protein = float(data.get("protein") or 0)
if "energy" in data:
component.energy = float(data.get("energy") or 0)
if "price" in data:
component.price = float(data.get("price") or 0)
save_result: dict = {}
if "nutrients" in data:
if not can_access_lab():
return (
jsonify(
{
"error": True,
"message": "Модуль Lab недоступен для этого пользователя",
}
),
403,
)
save_result = save_component_nutrients(component.id, data.get("nutrients") or {})
elif "dry_matter" in data or "dryMatter" in data:
save_result = save_component_nutrients(component.id, None)
if "externalNo" in data or "external_no" in data:
external_raw = data.get("externalNo", data.get("external_no"))
external_no, ext_error = resolve_component_external_no(
external_raw,
exclude_component_id=component.id,
current_external_no=component.external_no,
)
if ext_error:
return jsonify({"error": True, "message": ext_error}), 400
component.external_no = external_no
elif component.external_no is None:
external_no, ext_error = resolve_component_external_no(
None,
exclude_component_id=component.id,
current_external_no=None,
)
if ext_error:
return jsonify({"error": True, "message": ext_error}), 400
component.external_no = external_no
component.updated_by = "system"
db.session.commit()
return jsonify(
{
"success": True,
"message": "Компонент обновлён",
"affectedRecipeIds": save_result.get("affectedRecipeIds") or [],
}
)
@bp.delete("/<string:component_id>")
@require_auth
def delete_component(component_id: str):
"""Мягкое удаление компонента (soft-delete + каскад через события)."""
component = db.session.get(Component, component_id)
if component is None:
return jsonify({"error": True, "message": "Компонент не найден"}), 404
if getattr(component, "is_deleted", False):
return jsonify({"success": True, "message": "Компонент уже удалён"})
if hasattr(component, "soft_delete"):
component.soft_delete(deleted_by_user="api")
else:
db.session.delete(component)
db.session.commit()
return jsonify({"success": True, "message": "Компонент удалён"})
+519
View File
@@ -0,0 +1,519 @@
import io
from datetime import date as date_cls
from flask import Blueprint, jsonify, request, send_file, session
from app.routes.auth_decorators import require_auth
from app.services.daily_plan.builder import build_daily_plan
from app.services.daily_plan.notify import (
notify_component_replaced_in_plan,
notify_ingredient_replaced,
notify_ingredient_replacement_undone,
notify_ingredient_skipped,
notify_ingredient_unskipped,
notify_ingredients_unskipped_all,
notify_trip_skipped,
notify_trip_unskipped,
notify_unloading_group_skipped,
notify_unloading_group_unskipped,
notify_unloading_groups_unskipped_all,
)
from app.services.daily_plan.pdf import build_daily_plan_pdf
from app.services.daily_plan.adjustments import (
adjust_component_norm,
list_component_norms_for_plan,
undo_component_norm_adjustment,
)
from app.services.daily_plan.replacements import (
find_component_alternatives,
replace_component_in_plan,
replace_ingredient,
undo_ingredient_replacement,
)
from app.services.daily_plan.skips import (
list_all_skips,
skip_ingredient,
skip_trip,
skip_unloading_group,
unskip_all_ingredient_parts,
unskip_all_unloading_group_parts,
unskip_ingredient,
unskip_trip,
unskip_unloading_group,
)
bp = Blueprint("daily_plan", __name__, url_prefix="/api/daily-plan")
def _dispenser_id_from_request() -> str:
return (request.args.get("dispenser_id") or "").strip()
def _skip_duration_kwargs(data: dict) -> dict:
return {
"duration": data.get("duration") or data.get("skipDuration"),
"until_date": data.get("untilDate") or data.get("until_date") or data.get("validUntil"),
}
def _skip_row_response(row, *, id_key: str | None = None, extra: dict | None = None):
payload = {
"id": row.id,
"recipeId": row.recipe_id,
"date": row.plan_date.isoformat(),
}
end = row.valid_until or row.plan_date
if end and end != row.plan_date:
payload["validUntil"] = end.isoformat()
if id_key and hasattr(row, id_key):
payload[id_key] = getattr(row, id_key)
if extra:
payload.update(extra)
return payload
@bp.get("")
@require_auth
def get_daily_plan():
dispenser_id = _dispenser_id_from_request()
if not dispenser_id:
return jsonify({"error": True, "message": "dispenser_id обязателен"}), 400
try:
plan = build_daily_plan(
dispenser_id=dispenser_id,
plan_date=request.args.get("date"),
)
except LookupError as exc:
return jsonify({"error": True, "message": str(exc)}), 404
return jsonify(plan)
@bp.get("/pdf")
@require_auth
def get_daily_plan_pdf():
dispenser_id = _dispenser_id_from_request()
if not dispenser_id:
return jsonify({"error": True, "message": "dispenser_id обязателен"}), 400
try:
plan = build_daily_plan(
dispenser_id=dispenser_id,
plan_date=request.args.get("date"),
)
except LookupError as exc:
return jsonify({"error": True, "message": str(exc)}), 404
pdf_bytes = build_daily_plan_pdf(plan)
filename = f"plan-{plan.get('date', 'day')}.pdf"
return send_file(
io.BytesIO(pdf_bytes),
mimetype="application/pdf",
as_attachment=True,
download_name=filename,
)
def _current_user() -> str:
return str(session.get("user_login") or "system")
def _parse_plan_date_route(value) -> date_cls:
try:
return date_cls.fromisoformat(str(value).strip()[:10])
except (TypeError, ValueError):
return date_cls.today()
@bp.get("/skips")
@require_auth
def get_daily_plan_skips():
return jsonify(list_all_skips(request.args.get("date")))
@bp.post("/skips")
@require_auth
def post_daily_plan_skip():
data = request.get_json() or {}
recipe_id = (data.get("recipeId") or data.get("recipe_id") or "").strip()
plan_date = data.get("date")
if not recipe_id:
return jsonify({"error": True, "message": "recipeId обязателен"}), 400
try:
row = skip_trip(
recipe_id,
plan_date,
user=_current_user(),
**_skip_duration_kwargs(data),
)
except LookupError as exc:
return jsonify({"error": True, "message": str(exc)}), 404
user = _current_user()
end = row.valid_until or row.plan_date
notify_trip_skipped(
recipe_id,
row.plan_date,
valid_until=end if end and end != row.plan_date else None,
user=user,
)
return jsonify(_skip_row_response(row))
@bp.delete("/skips")
@require_auth
def delete_daily_plan_skip():
recipe_id = (request.args.get("recipe_id") or request.args.get("recipeId") or "").strip()
plan_date = request.args.get("date")
if not recipe_id:
return jsonify({"error": True, "message": "recipe_id обязателен"}), 400
if not unskip_trip(recipe_id, plan_date, user=_current_user()):
return jsonify({"error": True, "message": "Исключение не найдено"}), 404
notify_trip_unskipped(recipe_id, _parse_plan_date_route(plan_date), user=_current_user())
return jsonify({"ok": True})
@bp.post("/skips/ingredients")
@require_auth
def post_daily_plan_ingredient_skip():
data = request.get_json() or {}
recipe_id = (data.get("recipeId") or data.get("recipe_id") or "").strip()
ingredient_id = (data.get("ingredientId") or data.get("ingredient_id") or "").strip()
plan_date = data.get("date")
if not recipe_id or not ingredient_id:
return jsonify({"error": True, "message": "recipeId и ingredientId обязательны"}), 400
try:
row = skip_ingredient(
recipe_id,
ingredient_id,
plan_date,
user=_current_user(),
**_skip_duration_kwargs(data),
)
except LookupError as exc:
return jsonify({"error": True, "message": str(exc)}), 404
return jsonify(_skip_row_response(row, id_key="ingredient_id", extra={"ingredientId": row.ingredient_id}))
@bp.delete("/skips/ingredients")
@require_auth
def delete_daily_plan_ingredient_skip():
recipe_id = (request.args.get("recipe_id") or request.args.get("recipeId") or "").strip()
ingredient_id = (
request.args.get("ingredient_id") or request.args.get("ingredientId") or ""
).strip()
plan_date = request.args.get("date")
all_parts = request.args.get("all") in ("1", "true", "yes")
if not recipe_id:
return jsonify({"error": True, "message": "recipe_id обязателен"}), 400
if all_parts:
count = unskip_all_ingredient_parts(recipe_id, plan_date, user=_current_user())
if not count:
return jsonify({"error": True, "message": "Исключения не найдены"}), 404
notify_ingredients_unskipped_all(
recipe_id,
_parse_plan_date_route(plan_date),
count=count,
user=_current_user(),
)
return jsonify({"ok": True, "count": count})
if not ingredient_id:
return jsonify({"error": True, "message": "ingredient_id обязателен"}), 400
if not unskip_ingredient(recipe_id, ingredient_id, plan_date, user=_current_user()):
return jsonify({"error": True, "message": "Исключение не найдено"}), 404
notify_ingredient_unskipped(
recipe_id,
ingredient_id,
_parse_plan_date_route(plan_date),
user=_current_user(),
)
return jsonify({"ok": True})
@bp.post("/skips/unloading-groups")
@require_auth
def post_daily_plan_unloading_group_skip():
data = request.get_json() or {}
recipe_id = (data.get("recipeId") or data.get("recipe_id") or "").strip()
group_id = (
data.get("unloadingGroupId")
or data.get("unloading_group_id")
or data.get("groupId")
or ""
).strip()
plan_date = data.get("date")
if not recipe_id or not group_id:
return jsonify({"error": True, "message": "recipeId и unloadingGroupId обязательны"}), 400
try:
row = skip_unloading_group(
recipe_id,
group_id,
plan_date,
user=_current_user(),
**_skip_duration_kwargs(data),
)
except LookupError as exc:
return jsonify({"error": True, "message": str(exc)}), 404
user = _current_user()
end = row.valid_until or row.plan_date
notify_unloading_group_skipped(
recipe_id,
group_id,
row.plan_date,
valid_until=end if end and end != row.plan_date else None,
user=user,
)
return jsonify(
_skip_row_response(
row,
extra={"unloadingGroupId": row.unloading_group_id},
)
)
@bp.delete("/skips/unloading-groups")
@require_auth
def delete_daily_plan_unloading_group_skip():
recipe_id = (request.args.get("recipe_id") or request.args.get("recipeId") or "").strip()
group_id = (
request.args.get("unloading_group_id")
or request.args.get("unloadingGroupId")
or request.args.get("groupId")
or ""
).strip()
plan_date = request.args.get("date")
all_parts = request.args.get("all") in ("1", "true", "yes")
if not recipe_id:
return jsonify({"error": True, "message": "recipe_id обязателен"}), 400
if all_parts:
count = unskip_all_unloading_group_parts(recipe_id, plan_date, user=_current_user())
if not count:
return jsonify({"error": True, "message": "Исключения не найдены"}), 404
notify_unloading_groups_unskipped_all(
recipe_id,
_parse_plan_date_route(plan_date),
count=count,
user=_current_user(),
)
return jsonify({"ok": True, "count": count})
if not group_id:
return jsonify({"error": True, "message": "unloading_group_id обязателен"}), 400
if not unskip_unloading_group(recipe_id, group_id, plan_date, user=_current_user()):
return jsonify({"error": True, "message": "Исключение не найдено"}), 404
notify_unloading_group_unskipped(
recipe_id,
group_id,
_parse_plan_date_route(plan_date),
user=_current_user(),
)
return jsonify({"ok": True})
@bp.get("/component-alternatives")
@require_auth
def get_component_alternatives():
component_id = (request.args.get("component_id") or request.args.get("componentId") or "").strip()
if not component_id:
return jsonify({"error": True, "message": "component_id обязателен"}), 400
try:
payload = find_component_alternatives(
component_id,
query=request.args.get("q") or request.args.get("query") or "",
limit=request.args.get("limit") or 20,
)
except LookupError as exc:
return jsonify({"error": True, "message": str(exc)}), 404
return jsonify(payload)
@bp.post("/replacements/ingredients")
@require_auth
def post_daily_plan_ingredient_replacement():
data = request.get_json() or {}
recipe_id = (data.get("recipeId") or data.get("recipe_id") or "").strip()
ingredient_id = (data.get("ingredientId") or data.get("ingredient_id") or "").strip()
replacement_id = (
data.get("replacementComponentId")
or data.get("replacement_component_id")
or data.get("componentId")
or ""
).strip()
plan_date = data.get("date")
if not recipe_id or not ingredient_id or not replacement_id:
return jsonify(
{"error": True, "message": "recipeId, ingredientId и replacementComponentId обязательны"}
), 400
try:
row = replace_ingredient(
recipe_id,
ingredient_id,
replacement_id,
plan_date,
user=_current_user(),
**_skip_duration_kwargs(data),
)
except LookupError as exc:
return jsonify({"error": True, "message": str(exc)}), 404
user = _current_user()
end = row.valid_until or row.plan_date
notify_ingredient_replaced(
recipe_id,
ingredient_id,
replacement_id,
row.plan_date,
valid_until=end if end and end != row.plan_date else None,
user=user,
)
return jsonify(
_skip_row_response(
row,
extra={
"ingredientId": row.ingredient_id,
"replacementComponentId": row.replacement_component_id,
},
)
)
@bp.post("/replacements/components")
@require_auth
def post_daily_plan_component_replacement():
data = request.get_json() or {}
component_id = (data.get("componentId") or data.get("component_id") or "").strip()
replacement_id = (
data.get("replacementComponentId")
or data.get("replacement_component_id")
or ""
).strip()
dispenser_id = (data.get("dispenserId") or data.get("dispenser_id") or "").strip()
plan_date = data.get("date")
if not component_id or not replacement_id:
return jsonify(
{"error": True, "message": "componentId и replacementComponentId обязательны"}
), 400
if not dispenser_id:
return jsonify({"error": True, "message": "dispenserId обязателен"}), 400
try:
rows = replace_component_in_plan(
component_id,
replacement_id,
plan_date,
dispenser_id=dispenser_id,
user=_current_user(),
**_skip_duration_kwargs(data),
)
except LookupError as exc:
return jsonify({"error": True, "message": str(exc)}), 404
if rows:
end = rows[0].valid_until or rows[0].plan_date
notify_component_replaced_in_plan(
component_id,
replacement_id,
rows[0].plan_date,
recipe_count=len({row.recipe_id for row in rows}),
valid_until=end if end and end != rows[0].plan_date else None,
user=_current_user(),
)
return jsonify(
{
"ok": True,
"count": len(rows),
"componentId": component_id,
"replacementComponentId": replacement_id,
"recipeIds": sorted({row.recipe_id for row in rows}),
}
)
@bp.delete("/replacements/ingredients")
@require_auth
def delete_daily_plan_ingredient_replacement():
recipe_id = (request.args.get("recipe_id") or request.args.get("recipeId") or "").strip()
ingredient_id = (
request.args.get("ingredient_id") or request.args.get("ingredientId") or ""
).strip()
plan_date = request.args.get("date")
if not recipe_id or not ingredient_id:
return jsonify({"error": True, "message": "recipe_id и ingredient_id обязательны"}), 400
if not undo_ingredient_replacement(recipe_id, ingredient_id, plan_date, user=_current_user()):
return jsonify({"error": True, "message": "Замена не найдена"}), 404
notify_ingredient_replacement_undone(
recipe_id,
ingredient_id,
_parse_plan_date_route(plan_date),
user=_current_user(),
)
return jsonify({"ok": True})
@bp.get("/component-norms")
@require_auth
def get_component_norms():
dispenser_id = _dispenser_id_from_request()
if not dispenser_id:
return jsonify({"error": True, "message": "dispenser_id обязателен"}), 400
rows = list_component_norms_for_plan(
request.args.get("date"),
dispenser_id=dispenser_id,
)
return jsonify({"items": rows, "date": request.args.get("date")})
@bp.post("/adjustments/components")
@require_auth
def post_component_norm_adjustment():
data = request.get_json() or {}
component_id = (data.get("componentId") or data.get("component_id") or "").strip()
if not component_id:
return jsonify({"error": True, "message": "componentId обязателен"}), 400
wph = data.get("weightPerHead", data.get("weight_per_head"))
dm_ph = data.get("dryMatterPerHead", data.get("dry_matter_per_head"))
dm = data.get("dryMatter", data.get("dryMatterPct", data.get("dry_matter")))
dm_locked = data.get("dryMatterLocked", data.get("dry_matter_locked", False))
try:
wph_val = float(wph) if wph is not None and wph != "" else None
except (TypeError, ValueError):
return jsonify({"error": True, "message": "weightPerHead должен быть числом"}), 400
try:
dm_val = float(dm_ph) if dm_ph is not None and dm_ph != "" else None
except (TypeError, ValueError):
return jsonify({"error": True, "message": "dryMatterPerHead должен быть числом"}), 400
try:
dm_pct_val = float(dm) if dm is not None and dm != "" else None
except (TypeError, ValueError):
return jsonify({"error": True, "message": "dryMatter должен быть числом"}), 400
try:
row = adjust_component_norm(
component_id,
data.get("date"),
dry_matter=dm_pct_val,
dry_matter_locked=bool(dm_locked),
weight_per_head=wph_val,
dry_matter_per_head=dm_val,
user=_current_user(),
**_skip_duration_kwargs(data),
)
except ValueError as exc:
return jsonify({"error": True, "message": str(exc)}), 400
except LookupError as exc:
return jsonify({"error": True, "message": str(exc)}), 404
return jsonify(
{
"ok": True,
"componentId": row.component_id,
"dryMatter": row.dry_matter,
"dryMatterLocked": bool(row.dry_matter_locked),
"weightPerHead": row.weight_per_head,
"dryMatterPerHead": row.dry_matter_per_head,
"date": row.plan_date.isoformat(),
"validUntil": (row.valid_until or row.plan_date).isoformat(),
}
)
@bp.delete("/adjustments/components")
@require_auth
def delete_component_norm_adjustment():
component_id = (request.args.get("component_id") or request.args.get("componentId") or "").strip()
plan_date = request.args.get("date")
if not component_id:
return jsonify({"error": True, "message": "component_id обязателен"}), 400
if not undo_component_norm_adjustment(component_id, plan_date, user=_current_user()):
return jsonify({"error": True, "message": "Правка нормы не найдена"}), 404
return jsonify({"ok": True})
+750
View File
@@ -0,0 +1,750 @@
import logging
from datetime import datetime
from flask import Blueprint, jsonify, request
from sqlalchemy import exists, func, select, update
from app import db
from app.models import FeedDispenser, FeedingPeriod, PeriodRecipe, Recipe
from app.routes.auth_decorators import require_auth, require_auth_or_paired_terminal
from app.services.period_recipes_query import recipes_for_period_ordered
from app.services.daily_plan.adjustments import get_adjusted_recipe_ids
from app.services.daily_plan.replacements import get_replaced_recipe_ids
from app.services.daily_plan.builder import recipe_total_weights_by_id
from app.services.daily_plan.skips import (
filter_recipes_for_list_view,
get_recipe_ids_with_any_skip,
get_skipped_ingredient_ids,
get_skipped_unloading_group_ids,
is_kiosk_recipe_list_request,
is_zootech_recipe_list_view,
)
from app.services.sync_manager import enqueue_sync_queue_task
bp = Blueprint("equipment", __name__, url_prefix="/api/feed_dispensers")
logger = logging.getLogger(__name__)
def _error(message: str, status_code: int = 400):
return jsonify({"error": True, "message": message}), status_code
@bp.get("/ping")
def equipment_ping():
"""Временный health-check эндпоинт для модуля оборудования."""
return jsonify({"status": "ok"}), 200
@bp.get("")
@require_auth_or_paired_terminal
def list_feed_dispensers():
"""Список кормораздатчиков с пагинацией."""
try:
limit = int(request.args.get("limit", 100))
offset = int(request.args.get("offset", 0))
except ValueError:
return _error("Некорректные параметры пагинации", 400)
rows = db.session.execute(
select(FeedDispenser)
.where(FeedDispenser.is_deleted.is_(False))
.order_by(FeedDispenser.created_at.desc())
.offset(offset)
.limit(limit)
).scalars().all()
return jsonify(
[
{
**_serialize_dispenser(d),
"periods": _periods_payload_for_dispenser(d.id),
"hasSkipToday": _dispenser_has_skip_today(d),
}
for d in rows
]
)
@bp.get("/names")
@require_auth_or_paired_terminal
def feed_dispenser_names():
"""Legacy-compatible endpoint for dispenser names list."""
rows = db.session.execute(
select(FeedDispenser)
.where(FeedDispenser.is_deleted.is_(False))
.order_by(FeedDispenser.name.asc())
).scalars().all()
names = [r.name for r in rows if r.name]
# Preserve order while deduplicating
seen = set()
unique_names = []
for n in names:
if n in seen:
continue
seen.add(n)
unique_names.append(n)
return jsonify(unique_names)
def _serialize_recipe_short(
recipe: Recipe,
*,
total_weight: float | None = None,
skipped_today: bool = False,
skipped_ingredient_today: bool = False,
skipped_group_today: bool = False,
adjusted_ingredient_today: bool = False,
replaced_ingredient_today: bool = False,
):
payload = {
"id": recipe.id,
"name": recipe.name,
"heads_count": recipe.heads_per_trip,
"mixing_time": recipe.mixing_time,
"trip_percent": recipe.trip_percent,
}
if total_weight is not None:
payload["total_weight"] = total_weight
if is_zootech_recipe_list_view():
payload["skippedToday"] = skipped_today
payload["skippedIngredientToday"] = skipped_ingredient_today
payload["skippedGroupToday"] = skipped_group_today
payload["adjustedIngredientToday"] = adjusted_ingredient_today
payload["replacedIngredientToday"] = replaced_ingredient_today
return payload
def _serialize_dispenser(dispenser: FeedDispenser):
return {
"id": dispenser.id,
"name": dispenser.name,
"farm": dispenser.farm,
"operator": dispenser.operator,
"type": dispenser.type,
"is_active": dispenser.is_active,
"version": dispenser.version,
}
def _dispenser_has_skip_today(dispenser: FeedDispenser) -> bool:
skip_ids = get_recipe_ids_with_any_skip()
if not skip_ids:
return False
if dispenser.type == "mill":
recipes = db.session.execute(
select(Recipe.id).where(
Recipe.is_deleted.is_(False),
~exists(
select(1).where(
PeriodRecipe.recipe_id == Recipe.id,
PeriodRecipe.is_deleted.is_(False),
)
),
)
).scalars().all()
return any(rid in skip_ids for rid in recipes)
periods = db.session.execute(
select(FeedingPeriod.id).where(
FeedingPeriod.dispenser_id == dispenser.id,
FeedingPeriod.is_deleted.is_(False),
FeedingPeriod.is_active.is_(True),
)
).scalars().all()
for period_id in periods:
recipes = recipes_for_period_ordered(period_id)
if any(r.id in skip_ids for r in recipes):
return True
return False
def _periods_payload_for_dispenser(dispenser_id: str) -> list[dict]:
"""Активные периоды с рейсами (как в GET .../feed_dispensers/<id>). Для UI списка кормораздатчиков."""
periods = db.session.execute(
select(FeedingPeriod)
.where(
FeedingPeriod.dispenser_id == dispenser_id,
FeedingPeriod.is_deleted.is_(False),
FeedingPeriod.is_active.is_(True),
)
.order_by(FeedingPeriod.created_at.asc())
).unique().scalars().all()
payload = []
zootech = is_zootech_recipe_list_view()
skip_ings = get_skipped_ingredient_ids()
skip_grps = get_skipped_unloading_group_ids()
skip_ids = get_recipe_ids_with_any_skip()
adj_ids = get_adjusted_recipe_ids()
repl_ids = get_replaced_recipe_ids()
for p in periods:
recipes = recipes_for_period_ordered(p.id)
visible, skipped_today = filter_recipes_for_list_view(recipes)
period_has_skip = any(r.id in skip_ids for r in recipes)
if zootech:
recipe_rows = [
_serialize_recipe_short(
r,
skipped_today=r.id in skipped_today,
skipped_ingredient_today=bool(skip_ings.get(r.id)),
skipped_group_today=bool(skip_grps.get(r.id)),
adjusted_ingredient_today=r.id in adj_ids,
replaced_ingredient_today=r.id in repl_ids,
)
for r in visible
]
else:
recipe_rows = [_serialize_recipe_short(r) for r in visible]
payload.append(
{
"id": p.id,
"name": p.name,
"is_active": p.is_active,
"is_deleted": False,
"hasSkipToday": period_has_skip,
"recipes": recipe_rows,
}
)
return payload
@bp.get("/<string:dispenser_id>/periods")
@require_auth_or_paired_terminal
def list_dispenser_periods(dispenser_id: str):
dispenser = db.session.execute(
select(FeedDispenser).where(
FeedDispenser.id == dispenser_id, FeedDispenser.is_deleted.is_(False)
)
).scalar_one_or_none()
if not dispenser:
return _error("Кормораздатчик не найден", 404)
raw = _periods_payload_for_dispenser(dispenser_id)
# Совместимость: в ответе /periods поле is_deleted не требовалось
payload = [{k: v for k, v in item.items() if k != "is_deleted"} for item in raw]
return jsonify(payload)
@bp.get("/<string:dispenser_id>/recipes")
@require_auth_or_paired_terminal
def list_dispenser_recipes(dispenser_id: str):
dispenser = db.session.execute(
select(FeedDispenser).where(
FeedDispenser.id == dispenser_id, FeedDispenser.is_deleted.is_(False)
)
).scalar_one_or_none()
if not dispenser:
return _error("Кормораздатчик не найден", 404)
recipes = []
if dispenser.type == "mill":
# Как legacy get_dispenser_recipes: только рецепты без привязки к периодам (кормоцех),
# а не все строки recipe — иначе рейсы из периодов «висят» в списке кормоцеха.
recipes = db.session.execute(
select(Recipe)
.where(
Recipe.is_deleted.is_(False),
~exists(
select(1).where(
PeriodRecipe.recipe_id == Recipe.id,
PeriodRecipe.is_deleted.is_(False),
)
),
)
.order_by(Recipe.updated_at.desc())
).scalars().all()
logger.info(
"[RECIPES-DB] list_dispenser_recipes mill dispenser_id=%s orphan_recipes=%s",
dispenser_id,
len(recipes),
)
else:
periods = db.session.execute(
select(FeedingPeriod)
.where(
FeedingPeriod.dispenser_id == dispenser_id,
FeedingPeriod.is_deleted.is_(False),
FeedingPeriod.is_active.is_(True),
)
.order_by(FeedingPeriod.created_at.asc())
).unique().scalars().all()
seen = set()
for p in periods:
for r in recipes_for_period_ordered(p.id):
if r.id in seen:
continue
seen.add(r.id)
recipes.append(r)
visible, skipped_today = filter_recipes_for_list_view(recipes)
skip_ings = get_skipped_ingredient_ids()
skip_grps = get_skipped_unloading_group_ids()
adj_ids = get_adjusted_recipe_ids()
repl_ids = get_replaced_recipe_ids()
plan_weights: dict[str, float] = {}
if is_kiosk_recipe_list_request():
plan_weights = recipe_total_weights_by_id(dispenser_id=dispenser_id)
def _row(recipe: Recipe) -> dict:
return _serialize_recipe_short(
recipe,
total_weight=plan_weights.get(recipe.id),
skipped_today=recipe.id in skipped_today,
skipped_ingredient_today=bool(skip_ings.get(recipe.id)),
skipped_group_today=bool(skip_grps.get(recipe.id)),
adjusted_ingredient_today=recipe.id in adj_ids,
replaced_ingredient_today=recipe.id in repl_ids,
)
return jsonify([_row(r) for r in visible])
@bp.post("")
@require_auth
def create_feed_dispenser():
data = request.get_json() or {}
name = (data.get("name") or "").strip()
farm = (data.get("farm") or "").strip()
operator = (data.get("operator") or "").strip()
if not name or not farm or not operator:
return _error("Поля name, farm и operator обязательны", 400)
device_type = (data.get("type") or "dispenser").strip()
if device_type not in {"dispenser", "mill"}:
device_type = "dispenser"
dispenser = FeedDispenser(
name=name,
farm=farm,
operator=operator,
type=device_type,
created_by="system",
updated_by="system",
)
db.session.add(dispenser)
db.session.commit()
return (
jsonify(
{
"message": "Кормораздатчик успешно создан",
"id": dispenser.id,
}
),
201,
)
@bp.get("/<string:dispenser_id>")
@require_auth_or_paired_terminal
def get_feed_dispenser(dispenser_id: str):
dispenser = db.session.execute(
select(FeedDispenser).where(
FeedDispenser.id == dispenser_id, FeedDispenser.is_deleted.is_(False)
)
).scalar_one_or_none()
if not dispenser:
return _error("Кормораздатчик не найден", 404)
payload = _serialize_dispenser(dispenser)
payload["periods"] = _periods_payload_for_dispenser(dispenser_id)
return jsonify(payload)
@bp.put("/<string:dispenser_id>")
@require_auth
def update_feed_dispenser(dispenser_id: str):
dispenser = db.session.execute(
select(FeedDispenser).where(
FeedDispenser.id == dispenser_id, FeedDispenser.is_deleted.is_(False)
)
).scalar_one_or_none()
if not dispenser:
return _error("Кормораздатчик не найден", 404)
data = request.get_json() or {}
if "name" in data:
dispenser.name = (data.get("name") or dispenser.name).strip()
if "farm" in data:
dispenser.farm = (data.get("farm") or dispenser.farm).strip()
if "operator" in data:
dispenser.operator = (data.get("operator") or dispenser.operator).strip()
if "type" in data:
candidate = (data.get("type") or "").strip()
if candidate in {"dispenser", "mill"}:
dispenser.type = candidate
dispenser.updated_by = "system"
db.session.commit()
return jsonify({"message": "Кормораздатчик успешно обновлен"})
@bp.delete("/<string:dispenser_id>")
@require_auth
def delete_feed_dispenser(dispenser_id: str):
dispenser = db.session.execute(
select(FeedDispenser).where(
FeedDispenser.id == dispenser_id, FeedDispenser.is_deleted.is_(False)
)
).scalar_one_or_none()
if not dispenser:
return _error("Кормораздатчик не найден", 404)
dispenser.soft_delete(deleted_by="system", reason="Удаление через API", request=None)
db.session.commit()
return jsonify({"message": "Кормораздатчик успешно удален"})
@bp.post("/<string:dispenser_id>/periods")
@require_auth
def create_feeding_period(dispenser_id: str):
dispenser = db.session.execute(
select(FeedDispenser).where(
FeedDispenser.id == dispenser_id, FeedDispenser.is_deleted.is_(False)
)
).scalar_one_or_none()
if not dispenser:
return _error("Кормораздатчик не найден", 404)
data = request.get_json() or {}
name = (data.get("name") or "").strip()
if not name:
return _error("Название периода обязательно", 400)
period = FeedingPeriod(
name=name,
dispenser_id=dispenser_id,
created_by="system",
updated_by="system",
)
db.session.add(period)
db.session.commit()
return jsonify({"id": period.id}), 201
@bp.put("/<string:dispenser_id>/periods/<string:period_id>")
@require_auth
def update_feeding_period(dispenser_id: str, period_id: str):
period = db.session.execute(
select(FeedingPeriod).where(
FeedingPeriod.id == period_id,
FeedingPeriod.dispenser_id == dispenser_id,
FeedingPeriod.is_deleted.is_(False),
)
).unique().scalar_one_or_none()
if not period:
return _error("Период не найден", 404)
data = request.get_json() or {}
if "name" in data:
new_name = (data.get("name") or "").strip()
if not new_name:
return _error("Название периода не может быть пустым", 400)
period.name = new_name
period.updated_by = "system"
db.session.commit()
return jsonify({"id": period.id})
@bp.delete("/<string:dispenser_id>/periods/<string:period_id>")
@require_auth
def delete_feeding_period(dispenser_id: str, period_id: str):
period = db.session.execute(
select(FeedingPeriod).where(
FeedingPeriod.id == period_id,
FeedingPeriod.dispenser_id == dispenser_id,
FeedingPeriod.is_deleted.is_(False),
)
).unique().scalar_one_or_none()
if not period:
return _error("Период не найден", 404)
period.soft_delete(deleted_by="system", reason="Удаление через API", request=None)
db.session.commit()
return jsonify({"message": "Период кормления успешно удален"})
@bp.post("/<string:dispenser_id>/periods/<string:period_id>/recipes/<string:recipe_id>")
@require_auth
def add_recipe_to_period(dispenser_id: str, period_id: str, recipe_id: str):
period = db.session.execute(
select(FeedingPeriod).where(
FeedingPeriod.id == period_id,
FeedingPeriod.dispenser_id == dispenser_id,
FeedingPeriod.is_deleted.is_(False),
)
).unique().scalar_one_or_none()
if not period:
return _error("Период не найден", 404)
recipe = db.session.execute(
select(Recipe).where(Recipe.id == recipe_id, Recipe.is_deleted.is_(False))
).scalar_one_or_none()
if not recipe:
return _error("Рецепт не найден", 404)
logger.info(
"[RECIPES-DB] add_recipe_to_period dispenser_id=%s period_id=%s recipe_id=%s path=%s remote=%s",
dispenser_id,
period_id,
recipe_id,
request.path,
request.remote_addr,
)
existing = db.session.execute(
select(PeriodRecipe).where(
PeriodRecipe.period_id == period_id,
PeriodRecipe.recipe_id == recipe_id,
PeriodRecipe.is_deleted.is_(False),
)
).scalar_one_or_none()
if existing:
logger.info(
"[RECIPES-DB] add_recipe_to_period already_linked period_id=%s recipe_id=%s",
period_id,
recipe_id,
)
return jsonify({"message": "Рецепт добавлен в период"})
max_order = db.session.scalar(
select(func.max(PeriodRecipe.order)).where(
PeriodRecipe.period_id == period_id,
PeriodRecipe.is_deleted.is_(False),
)
) or 0
link = PeriodRecipe(
period_id=period_id,
recipe_id=recipe_id,
order=max_order + 1,
created_at=datetime.now(),
created_by="system",
updated_by="system",
)
db.session.add(link)
period.updated_by = "system"
db.session.commit()
logger.info(
"[RECIPES-DB] add_recipe_to_period committed period_id=%s recipe_id=%s order=%s",
period_id,
recipe_id,
link.order,
)
return jsonify({"message": "Рецепт добавлен в период"})
@bp.post("/<string:to_dispenser_id>/periods/<string:to_period_id>/recipes/<string:recipe_id>/transfer")
@require_auth
def transfer_recipe_between_periods(
to_dispenser_id: str, to_period_id: str, recipe_id: str
):
"""Атомарно снять рейс с исходного периода и вставить в целевой на позицию to_index (0-based)."""
data = request.get_json() or {}
from_dispenser_id = data.get("from_dispenser_id")
from_period_id = data.get("from_period_id")
if not from_dispenser_id or not from_period_id:
return _error("Укажите from_dispenser_id и from_period_id", 400)
try:
to_index = int(data.get("to_index"))
except (TypeError, ValueError):
return _error("to_index должен быть числом", 400)
if from_period_id == to_period_id and from_dispenser_id == to_dispenser_id:
return _error("Для смены порядка внутри периода используйте перестановку в списке", 400)
to_dispenser = db.session.execute(
select(FeedDispenser).where(
FeedDispenser.id == to_dispenser_id, FeedDispenser.is_deleted.is_(False)
)
).scalar_one_or_none()
if not to_dispenser:
return _error("Целевое оборудование не найдено", 404)
if getattr(to_dispenser, "type", None) == "mill":
return _error("Перенос в кормоцех через периоды не поддерживается", 400)
to_period = db.session.execute(
select(FeedingPeriod).where(
FeedingPeriod.id == to_period_id,
FeedingPeriod.dispenser_id == to_dispenser_id,
FeedingPeriod.is_deleted.is_(False),
)
).unique().scalar_one_or_none()
if not to_period:
return _error("Целевой период не найден", 404)
from_period = db.session.execute(
select(FeedingPeriod).where(
FeedingPeriod.id == from_period_id,
FeedingPeriod.dispenser_id == from_dispenser_id,
FeedingPeriod.is_deleted.is_(False),
)
).unique().scalar_one_or_none()
if not from_period:
return _error("Исходный период не найден", 404)
recipe = db.session.execute(
select(Recipe).where(Recipe.id == recipe_id, Recipe.is_deleted.is_(False))
).scalar_one_or_none()
if not recipe:
return _error("Рецепт не найден", 404)
source_link = db.session.execute(
select(PeriodRecipe).where(
PeriodRecipe.period_id == from_period_id,
PeriodRecipe.recipe_id == recipe_id,
PeriodRecipe.is_deleted.is_(False),
)
).scalar_one_or_none()
if not source_link:
return _error("Рейс не привязан к исходному периоду", 404)
existing_target = db.session.execute(
select(PeriodRecipe).where(
PeriodRecipe.period_id == to_period_id,
PeriodRecipe.recipe_id == recipe_id,
PeriodRecipe.is_deleted.is_(False),
)
).scalar_one_or_none()
if existing_target:
return _error("Этот рейс уже привязан к целевому периоду — перенос не выполнен.", 409)
source_link.soft_delete(deleted_by_user="system")
from_period.updated_by = "system"
db.session.flush()
others = (
db.session.execute(
select(PeriodRecipe)
.where(
PeriodRecipe.period_id == to_period_id,
PeriodRecipe.is_deleted.is_(False),
)
.order_by(PeriodRecipe.order.asc(), PeriodRecipe.created_at.asc())
)
.scalars()
.all()
)
n = len(others)
if to_index < 0:
to_index = 0
elif to_index > n:
to_index = n
ordered_ids = [pr.recipe_id for pr in others[:to_index]] + [recipe_id] + [
pr.recipe_id for pr in others[to_index:]
]
ghost = db.session.execute(
select(PeriodRecipe).where(
PeriodRecipe.period_id == to_period_id,
PeriodRecipe.recipe_id == recipe_id,
)
).scalar_one_or_none()
if ghost is not None:
if not ghost.is_deleted:
logger.error(
"transfer_recipe_between_periods: unexpected active ghost "
"period_id=%s recipe_id=%s",
to_period_id,
recipe_id,
)
db.session.rollback()
return _error(
"Этот рейс уже привязан к целевому периоду — перенос не выполнен.",
409,
)
ghost.is_deleted = False
ghost.deleted_at = None
ghost.deleted_by = None
ghost.updated_by = "system"
else:
db.session.add(
PeriodRecipe(
period_id=to_period_id,
recipe_id=recipe_id,
order=0,
created_at=datetime.now(),
created_by="system",
updated_by="system",
)
)
db.session.flush()
now = datetime.now()
with db.session.no_autoflush:
for idx, rid in enumerate(ordered_ids, start=1):
db.session.execute(
update(PeriodRecipe)
.where(
PeriodRecipe.period_id == to_period_id,
PeriodRecipe.recipe_id == rid,
)
.values(order=idx, updated_by="system", updated_at=now)
)
to_period.updated_by = "system"
from_period.updated_by = "system"
enqueue_sync_queue_task(
"period_recipes",
f"{from_period_id}:{recipe_id}",
"delete",
priority=1,
)
for rid in ordered_ids:
enqueue_sync_queue_task(
"period_recipes",
f"{to_period_id}:{rid}",
"update",
priority=4,
)
db.session.commit()
logger.info(
"[RECIPES-DB] transfer_recipe_between_periods committed recipe_id=%s from=%s/%s to=%s/%s index=%s",
recipe_id,
from_dispenser_id,
from_period_id,
to_dispenser_id,
to_period_id,
to_index,
)
return jsonify({"success": True, "message": "Рейс перенесён"})
@bp.delete("/<string:dispenser_id>/periods/<string:period_id>/recipes/<string:recipe_id>")
@require_auth
def remove_recipe_from_period(dispenser_id: str, period_id: str, recipe_id: str):
period = db.session.execute(
select(FeedingPeriod).where(
FeedingPeriod.id == period_id,
FeedingPeriod.dispenser_id == dispenser_id,
FeedingPeriod.is_deleted.is_(False),
)
).unique().scalar_one_or_none()
if not period:
return _error("Период не найден", 404)
link = db.session.execute(
select(PeriodRecipe).where(
PeriodRecipe.period_id == period_id,
PeriodRecipe.recipe_id == recipe_id,
PeriodRecipe.is_deleted.is_(False),
)
).scalar_one_or_none()
if link:
logger.info(
"[RECIPES-DB] remove_recipe_from_period dispenser_id=%s period_id=%s recipe_id=%s path=%s",
dispenser_id,
period_id,
recipe_id,
request.path,
)
link.soft_delete(deleted_by="system", reason="Удаление через API", request=None)
period.updated_by = "system"
enqueue_sync_queue_task(
"period_recipes",
f"{period_id}:{recipe_id}",
"delete",
priority=1,
)
db.session.commit()
logger.info(
"[RECIPES-DB] remove_recipe_from_period committed period_id=%s recipe_id=%s",
period_id,
recipe_id,
)
return jsonify({"message": "Рецепт удален из периода"})
+284
View File
@@ -0,0 +1,284 @@
"""API учётных документов (СП-20, остатки, журнал)."""
from __future__ import annotations
import base64
import calendar
import re
from datetime import date
from urllib.parse import quote
from flask import Blueprint, Response, current_app, jsonify, request
from app.routes.auth_decorators import require_auth
from app.services.feed_accounting.consumption_pdf import build_consumption_pdf
from app.services.feed_accounting.consumption_report_builder import (
consumption_export_filename,
consumption_export_filename_ascii,
)
from app.services.feed_accounting.consumption_xlsx import build_consumption_xlsx
from app.services.feed_accounting.pdf_feed_accounting import build_journal_pdf, build_sp20_pdf
from app.services.feed_accounting.sp20_report_builder import parse_month, sp20_report_summary
from app.services.feed_accounting.xlsx_feed_accounting import (
build_documents_zip,
build_journal_xlsx,
build_sp20_xlsx,
build_stock_balances_xlsx,
)
from app.services.org_settings import (
SIGNATURE_ROLES,
delete_signature,
get_org_settings,
list_signature_status,
save_signature_png,
signature_path,
validate_org_settings_payload,
write_org_settings,
list_farms_from_db,
)
bp = Blueprint("feed_accounting", __name__, url_prefix="/api/feed-accounting")
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
def _bad(message: str, code: int = 400):
return jsonify({"error": True, "message": message}), code
def _parse_dates():
date_from = (request.args.get("date_from") or "").strip()
date_to = (request.args.get("date_to") or "").strip()
if not date_from or not date_to:
today = date.today()
start = date(today.year, today.month, 1)
last = calendar.monthrange(today.year, today.month)[1]
end = date(today.year, today.month, last)
return start.isoformat(), end.isoformat(), None
if not _DATE_RE.match(date_from) or not _DATE_RE.match(date_to):
return None, None, _bad("date_from/date_to: формат YYYY-MM-DD")
return date_from, date_to, None
def _parse_month_arg():
month = (request.args.get("month") or "").strip()
if not month:
today = date.today()
month = f"{today.year}-{today.month:02d}"
try:
year, mon = parse_month(month)
return year, mon, month, None
except ValueError as e:
return None, None, None, _bad(str(e))
def _content_disposition(filename: str, *, ascii_filename: str | None = None) -> str:
"""RFC 5987: кириллица в filename*, latin-1 fallback в filename."""
try:
filename.encode("ascii")
return f'attachment; filename="{filename}"'
except UnicodeEncodeError:
fallback = ascii_filename or filename
quoted = quote(filename, safe="!#$&+-.^_`|~")
return f"attachment; filename=\"{fallback}\"; filename*=UTF-8''{quoted}"
def _xlsx_response(data: bytes, filename: str, *, ascii_filename: str | None = None) -> Response:
return Response(
data,
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": _content_disposition(filename, ascii_filename=ascii_filename)},
)
def _pdf_response(data: bytes, filename: str, *, ascii_filename: str | None = None) -> Response:
return Response(
data,
mimetype="application/pdf",
headers={"Content-Disposition": _content_disposition(filename, ascii_filename=ascii_filename)},
)
@bp.get("/org-settings")
@require_auth
def api_get_org_settings():
settings = get_org_settings(current_app)
return jsonify(
{
**settings,
"available_farms": list_farms_from_db(),
"signatures": list_signature_status(),
}
)
@bp.put("/org-settings")
@require_auth
def api_put_org_settings():
data = request.get_json(silent=True) or {}
ok, err, partial = validate_org_settings_payload(data)
if not ok:
return _bad(err)
write_org_settings(partial)
return jsonify({"success": True, "settings": get_org_settings(current_app)})
@bp.get("/signatures")
@require_auth
def api_list_signatures():
return jsonify({"roles": list_signature_status()})
@bp.get("/signatures/<role>.png")
@require_auth
def api_get_signature_png(role: str):
if role not in SIGNATURE_ROLES:
return _bad("Неизвестная роль подписи")
path = signature_path(role)
if not path.is_file():
return _bad("Подпись не найдена", 404)
return Response(path.read_bytes(), mimetype="image/png")
@bp.post("/signatures/<role>")
@require_auth
def api_save_signature(role: str):
if role not in SIGNATURE_ROLES:
return _bad("Неизвестная роль подписи")
png_bytes = None
if request.content_type and "application/json" in request.content_type:
body = request.get_json(silent=True) or {}
raw = body.get("png_base64") or body.get("data") or ""
if isinstance(raw, str) and "," in raw:
raw = raw.split(",", 1)[1]
try:
png_bytes = base64.b64decode(raw)
except Exception:
return _bad("Некорректный base64")
elif "file" in request.files:
f = request.files["file"]
png_bytes = f.read()
else:
png_bytes = request.get_data()
try:
save_signature_png(role, png_bytes or b"")
except ValueError as e:
return _bad(str(e))
return jsonify({"success": True, "roles": list_signature_status()})
@bp.delete("/signatures/<role>")
@require_auth
def api_delete_signature(role: str):
if role not in SIGNATURE_ROLES:
return _bad("Неизвестная роль подписи")
try:
delete_signature(role)
except ValueError as e:
return _bad(str(e))
return jsonify({"success": True, "roles": list_signature_status()})
@bp.get("/stock-balances.xlsx")
@require_auth
def api_stock_balances_xlsx():
date_from, date_to, err = _parse_dates()
if err:
return err
data = build_stock_balances_xlsx(current_app, date_from, date_to)
return _xlsx_response(data, f"WESP_ostatki_{date_from}_{date_to}.xlsx")
@bp.get("/consumption.xlsx")
@require_auth
def api_consumption_xlsx():
date_from, date_to, err = _parse_dates()
if err:
return err
data = build_consumption_xlsx(current_app, date_from, date_to)
return _xlsx_response(
data,
consumption_export_filename(date_from, date_to, "xlsx"),
ascii_filename=consumption_export_filename_ascii(date_from, date_to, "xlsx"),
)
@bp.get("/consumption.pdf")
@require_auth
def api_consumption_pdf():
date_from, date_to, err = _parse_dates()
if err:
return err
data = build_consumption_pdf(current_app, date_from, date_to)
return _pdf_response(
data,
consumption_export_filename(date_from, date_to, "pdf"),
ascii_filename=consumption_export_filename_ascii(date_from, date_to, "pdf"),
)
@bp.get("/sp20-preview")
@require_auth
def api_sp20_preview():
year, month, month_s, err = _parse_month_arg()
if err:
return err
return jsonify(sp20_report_summary(current_app, year, month))
@bp.get("/sp20.xlsx")
@require_auth
def api_sp20_xlsx():
year, month, month_s, err = _parse_month_arg()
if err:
return err
include_journal = request.args.get("include_journal", "1") != "0"
data = build_sp20_xlsx(current_app, year, month, include_journal=include_journal)
return _xlsx_response(data, f"WESP_SP20_{month_s}.xlsx")
@bp.get("/sp20.pdf")
@require_auth
def api_sp20_pdf():
year, month, month_s, err = _parse_month_arg()
if err:
return err
data = build_sp20_pdf(current_app, year, month)
return _pdf_response(data, f"WESP_SP20_{month_s}.pdf")
@bp.get("/journal.xlsx")
@require_auth
def api_journal_xlsx():
year, month, month_s, err = _parse_month_arg()
if err:
return err
data = build_journal_xlsx(current_app, year, month)
return _xlsx_response(data, f"WESP_zhurnal_{month_s}.xlsx")
@bp.get("/journal.pdf")
@require_auth
def api_journal_pdf():
year, month, month_s, err = _parse_month_arg()
if err:
return err
data = build_journal_pdf(current_app, year, month)
return _pdf_response(data, f"WESP_zhurnal_{month_s}.pdf")
@bp.get("/documents.zip")
@require_auth
def api_documents_zip():
date_from, date_to, err = _parse_dates()
if err:
return err
year, month, month_s, err2 = _parse_month_arg()
if err2:
return err2
data = build_documents_zip(current_app, year, month, date_from, date_to)
return Response(
data,
mimetype="application/zip",
headers={"Content-Disposition": f'attachment; filename="WESP_documents_{month_s}.zip"'},
)
+72
View File
@@ -0,0 +1,72 @@
from datetime import timedelta
from flask import Blueprint, jsonify, request
from app.routes.auth_decorators import require_auth
from app.services.feed_quality.evaluator import reevaluate_feed_alerts_for_period
from app.services.feed_quality.query import feed_alerts_summary, list_feed_alerts
from app.services.feed_quality.settings_store import (
normalize_settings,
save_feed_quality_settings,
settings_for_api,
)
from app.timeutil import utc_now_naive
bp = Blueprint("feed_quality", __name__, url_prefix="/api/feed-quality")
@bp.get("/settings")
@require_auth
def get_settings():
return jsonify(settings_for_api())
@bp.put("/settings")
@require_auth
def put_settings():
body = request.get_json(silent=True) or {}
partial = body.get("settings") if isinstance(body.get("settings"), dict) else body
try:
saved = save_feed_quality_settings(normalize_settings(partial))
except (TypeError, ValueError) as exc:
return jsonify({"error": True, "message": str(exc)}), 400
today = utc_now_naive().date()
week_ago = today - timedelta(days=7)
reevaluated = reevaluate_feed_alerts_for_period(
date_from=week_ago.isoformat(),
date_to=today.isoformat(),
limit=500,
)
return jsonify({"saved": True, "settings": saved, "reevaluatedReports": reevaluated})
@bp.get("/alerts/summary")
@require_auth
def alerts_summary():
return jsonify(
feed_alerts_summary(
date_from=request.args.get("date_from"),
date_to=request.args.get("date_to"),
dispenser_id=request.args.get("dispenser_id"),
)
)
@bp.get("/alerts")
@require_auth
def alerts_list():
try:
limit = int(request.args.get("limit", 200))
except (TypeError, ValueError):
limit = 200
return jsonify(
list_feed_alerts(
date_from=request.args.get("date_from"),
date_to=request.args.get("date_to"),
severity=request.args.get("severity"),
event_type=request.args.get("event_type"),
dispenser_id=request.args.get("dispenser_id"),
limit=limit,
)
)
+514
View File
@@ -0,0 +1,514 @@
import logging
import secrets
from html import escape
from datetime import datetime, timedelta
from flask import Blueprint, Response, current_app, jsonify, redirect, request, url_for
from sqlalchemy import select
from app import db
from app.kiosk_qr import pair_url_to_qr_data_uri
from app.models import KioskDevice, KioskPairToken
from app.routes.auth_decorators import can_issue_kiosk_access_link
bp = Blueprint("kiosk", __name__, url_prefix="/api/kiosk")
logger = logging.getLogger(__name__)
def _token_log_fragment(token: str) -> str:
"""Фрагмент токена для логов (не логируем полностью)."""
if not token:
return "(пусто)"
return (token[:12] + "") if len(token) > 12 else token
def _device_cookie_id() -> str:
return (request.cookies.get("wesp_kiosk_device_id") or "").strip()
def _error(message: str, status_code: int = 400):
return jsonify({"error": True, "message": message}), status_code
def _confirm_html_page(inner_html: str, status: int = 200) -> Response:
body = (
"<!DOCTYPE html><html lang=\"ru\"><head><meta charset=\"utf-8\">"
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"></head>"
f"<body style=\"font-family:system-ui,sans-serif;padding:24px;max-width:520px;margin:0 auto;\">{inner_html}</body></html>"
)
return Response(body, status=status, mimetype="text/html; charset=utf-8")
def _confirm_pair_form_html(token: str) -> Response:
safe = escape(token, quote=True)
action = escape(url_for("kiosk.confirm_pair"), quote=True)
inner = (
"<h1 style=\"font-size:1.25rem;\">Подтвердите привязку терминала</h1>"
"<p style=\"color:#444;line-height:1.45;\">Нажмите кнопку, чтобы завершить привязку. "
"Страница без кнопки не расходует токен — так ссылка не «сгорает» из‑за предпросмотра в мессенджере или лишнего запроса.</p>"
f'<form method="post" action="{action}">'
f'<input type="hidden" name="token" value="{safe}">'
'<button type="submit" style="margin-top:12px;padding:14px 22px;font-size:1.05rem;cursor:pointer;'
'border-radius:8px;border:1px solid #333;background:#1a1a1a;color:#fff;">Подтвердить привязку</button>'
"</form>"
)
return _confirm_html_page(inner, 200)
def _confirm_pair_error_html(message: str, status: int) -> Response:
inner = f"<p>{escape(message)}</p>"
return _confirm_html_page(inner, status)
def _scales_public_url() -> str:
"""Тот же origin, что в QR (`_resolve_public_base_url`), путь /scales."""
return _resolve_public_base_url().rstrip("/") + "/scales"
def _confirm_pair_success_response():
"""После привязки — редирект на /scales по LAN/public base; для JSON — поле redirect."""
target = _scales_public_url()
if request.is_json or (request.headers.get("Accept") or "").find("application/json") >= 0:
return jsonify({"success": True, "paired": True, "redirect": target}), 200
return redirect(target, code=302)
def _confirm_pair_error_response(message: str, status_code: int):
"""JSON для API, HTML для отправки формы из браузера."""
if request.is_json or (request.headers.get("Accept") or "").find("application/json") >= 0:
return _error(message, status_code)
return _confirm_pair_error_html(message, status_code)
def _is_local_host(host: str) -> bool:
return host.lower() in {"localhost", "127.0.0.1", "::1"}
from app.services.lan_network import detect_lan_ip
def _resolve_public_base_url() -> str:
override = str(current_app.config.get("KIOSK_PUBLIC_BASE_URL", "") or "").strip().rstrip("/")
if override:
return override
host = (request.host or "").strip()
host_name = host.split(":")[0] if host else ""
if not _is_local_host(host_name):
return request.host_url.rstrip("/")
lan_ip = detect_lan_ip()
if not lan_ip:
return request.host_url.rstrip("/")
port = str(request.environ.get("SERVER_PORT") or "").strip()
# На ферме Flask обычно слушает plain HTTP (80). HTTPS в Start URL без reverse-proxy
# приводит к «Bad request version» в логах (TLS-handshake на HTTP-порт).
scheme = "https" if port == "443" else "http"
if port and port not in {"80", "443"}:
return f"{scheme}://{lan_ip}:{port}"
return f"{scheme}://{lan_ip}"
def _ensure_access_slug(device: KioskDevice, *, refresh: bool = False) -> str:
if device.access_slug and not refresh:
return device.access_slug
device.access_slug = secrets.token_urlsafe(32)
db.session.commit()
return device.access_slug
def _start_url_for_slug(slug: str) -> str:
return f"{_resolve_public_base_url().rstrip('/')}/kiosk/start/{slug}"
def _access_link_refresh_requested() -> bool:
if request.args.get("refresh") == "1":
return True
payload = request.get_json(silent=True) or {}
return bool(payload.get("refresh"))
def _activate_by_token(token: str):
row = db.session.execute(select(KioskPairToken).where(KioskPairToken.token == token)).scalar_one_or_none()
if not row:
logger.warning(
"[KIOSK] токен не найден token=%s remote=%s",
_token_log_fragment(token),
request.remote_addr,
)
return None, _error("Токен не найден", 404)
if row.used_at is not None:
logger.warning(
"[KIOSK] токен уже использован token=%s device_id_в_токене=%s remote=%s",
_token_log_fragment(token),
row.device_id,
request.remote_addr,
)
return None, _error("Токен уже использован", 409)
if row.expires_at < datetime.utcnow():
logger.warning(
"[KIOSK] токен истёк token=%s remote=%s",
_token_log_fragment(token),
request.remote_addr,
)
return None, _error("Срок действия токена истёк", 410)
return row, None
def _pair_current_device_by_token(token: str):
target_device_id = _device_cookie_id()
if not target_device_id:
logger.warning(
"[KIOSK] claim/scan: нет cookie wesp_kiosk_device_id token=%s remote=%s host=%s",
_token_log_fragment(token),
request.remote_addr,
request.host,
)
return None, _error("Не найден cookie терминала. Откройте kiosk-страницу заново.", 400)
target_device = db.session.get(KioskDevice, target_device_id)
if not target_device:
logger.warning(
"[KIOSK] claim/scan: нет строки KioskDevice для cookie device_id=%s",
target_device_id,
)
return None, _error("Терминал не зарегистрирован на сервере.", 404)
row, error = _activate_by_token(token)
if error:
return None, error
row.used_at = datetime.utcnow()
target_device.status = "active"
target_device.last_seen_at = datetime.utcnow()
target_device.last_ip = request.remote_addr
db.session.commit()
logger.info(
"[KIOSK] claim/scan OK: активирован device_id=%s token=%s remote=%s",
target_device.id,
_token_log_fragment(token),
request.remote_addr,
)
return target_device, None
@bp.get("/status")
def kiosk_status():
enforce_paired_only = bool(current_app.config.get("KIOSK_ENFORCE_PAIRED_ONLY", True))
device_id = _device_cookie_id()
if not device_id:
logger.debug("[KIOSK] /status без cookie remote=%s host=%s", request.remote_addr, request.host)
return jsonify(
{
"paired": False,
"device_id": None,
"status": "unregistered",
"enforce_paired_only": enforce_paired_only,
}
)
device = db.session.get(KioskDevice, device_id)
if not device:
logger.debug(
"[KIOSK] /status неизвестный device_id=%s remote=%s",
device_id,
request.remote_addr,
)
return jsonify(
{
"paired": False,
"device_id": device_id,
"status": "unregistered",
"enforce_paired_only": enforce_paired_only,
}
)
paired = device.status == "active"
if paired:
device.last_seen_at = datetime.utcnow()
device.last_ip = request.remote_addr
db.session.commit()
logger.debug(
"[KIOSK] /status device_id=%s paired=%s status=%s remote=%s",
device_id,
paired,
device.status,
request.remote_addr,
)
return jsonify(
{
"paired": paired,
"device_id": device.id,
"status": device.status,
"display_name": device.display_name,
"enforce_paired_only": enforce_paired_only,
}
)
@bp.get("/setup-available")
def kiosk_setup_available():
"""Можно ли показывать UI выдачи Start URL (localhost или admin-сессия)."""
return jsonify({"can_issue_access_link": can_issue_kiosk_access_link()})
@bp.route("/access-link", methods=["GET", "POST"])
def kiosk_access_link():
"""Постоянная Start URL для Fully Kiosk + QR."""
if not can_issue_kiosk_access_link():
logger.warning(
"[KIOSK] access-link: отказ (не localhost и нет admin-сессии) remote=%s host=%s",
request.remote_addr,
request.host,
)
return _error("Выдача Start URL доступна только администратору или с localhost.", 403)
device_id = _device_cookie_id()
if not device_id:
logger.warning(
"[KIOSK] access-link: нет cookie remote=%s host=%s",
request.remote_addr,
request.host,
)
return _error("Не найден cookie терминала. Откройте kiosk-страницу заново.", 400)
device = db.session.get(KioskDevice, device_id)
if not device:
logger.warning("[KIOSK] access-link: нет KioskDevice для id=%s", device_id)
return _error("Терминал не зарегистрирован на сервере.", 404)
refresh = _access_link_refresh_requested()
slug = _ensure_access_slug(device, refresh=refresh)
start_url = _start_url_for_slug(slug)
try:
qr_img_url = pair_url_to_qr_data_uri(start_url)
except Exception:
logger.exception(
"[KIOSK] access-link: ошибка генерации QR device_id=%s",
device.id,
)
return _error("Не удалось сгенерировать QR-код", 500)
logger.info(
"[KIOSK] access-link: device_id=%s refresh=%s public_base=%s remote=%s",
device.id,
refresh,
_resolve_public_base_url(),
request.remote_addr,
)
return jsonify(
{
"success": True,
"start_url": start_url,
"pair_url": start_url,
"qr_image_url": qr_img_url,
"device_id": device.id,
"permanent": True,
}
)
@bp.post("/pair-token")
def kiosk_pair_token():
if not can_issue_kiosk_access_link():
return _error("Выдача pair-token доступна только администратору или с localhost.", 403)
device_id = _device_cookie_id()
if not device_id:
logger.warning(
"[KIOSK] pair-token: нет cookie remote=%s host=%s",
request.remote_addr,
request.host,
)
return _error("Не найден cookie терминала. Откройте kiosk-страницу заново.", 400)
device = db.session.get(KioskDevice, device_id)
if not device:
logger.warning("[KIOSK] pair-token: нет KioskDevice для id=%s", device_id)
return _error("Терминал не зарегистрирован на сервере.", 404)
ttl = max(30, int(current_app.config.get("KIOSK_PAIR_TOKEN_TTL_SECONDS", 300)))
token = secrets.token_urlsafe(24)
obj = KioskPairToken(
token=token,
device_id=device.id,
expires_at=datetime.utcnow() + timedelta(seconds=ttl),
issued_for="kiosk_qr_pair",
)
db.session.add(obj)
db.session.commit()
base = _resolve_public_base_url()
# QR и pair_url ведут на confirm: привязка киоска по device_id из токена, без cookie на телефоне.
# pair/claim остаётся для сценария «второй терминал уже открыл сайт и имеет свой cookie».
confirm_url = f"{base}/api/kiosk/pair/confirm?token={token}"
pair_url = confirm_url
try:
qr_img_url = pair_url_to_qr_data_uri(pair_url)
except Exception:
logger.exception(
"[KIOSK] pair-token: ошибка локальной генерации QR device_id=%s",
device.id,
)
return _error("Не удалось сгенерировать QR-код", 500)
logger.info(
"[KIOSK] pair-token выдан: device_id=%s token=%s public_base=%s remote=%s host=%s",
device.id,
_token_log_fragment(token),
base,
request.remote_addr,
request.host,
)
return jsonify(
{
"success": True,
"token": token,
"pair_url": pair_url,
"confirm_url": confirm_url,
"qr_image_url": qr_img_url,
"expires_in": ttl,
}
)
@bp.route("/pair/confirm", methods=["GET", "POST"])
def confirm_pair():
"""GET — страница с кнопкой (токен не расходуется). POST — фактическая привязка."""
if request.method == "GET":
token = (request.args.get("token") or "").strip()
if not token:
logger.warning("[KIOSK] pair/confirm GET без token remote=%s", request.remote_addr)
return _confirm_pair_error_html("token обязателен", 400)
row = db.session.execute(
select(KioskPairToken).where(KioskPairToken.token == token)
).scalar_one_or_none()
if not row:
logger.warning(
"[KIOSK] pair/confirm GET токен не найден token=%s remote=%s",
_token_log_fragment(token),
request.remote_addr,
)
return _confirm_pair_error_html("Токен не найден", 404)
if row.used_at is not None:
inner = (
"<h2>Привязка уже выполнена</h2>"
"<p>Этот одноразовый код уже использован. Если киоск не показывает доступ, "
"обновите страницу весов на терминале.</p>"
)
return _confirm_html_page(inner, 200)
if row.expires_at < datetime.utcnow():
logger.warning(
"[KIOSK] pair/confirm GET токен просрочен token=%s remote=%s",
_token_log_fragment(token),
request.remote_addr,
)
return _confirm_pair_error_html("Срок действия токена истёк", 410)
logger.info(
"[KIOSK] pair/confirm GET форма подтверждения token=%s device_id_в_токене=%s remote=%s",
_token_log_fragment(token),
row.device_id,
request.remote_addr,
)
return _confirm_pair_form_html(token)
token = (request.form.get("token") or "").strip()
if not token:
payload = request.get_json(silent=True) or {}
token = str(payload.get("token") or "").strip()
if not token:
return _confirm_pair_error_response("token обязателен", 400)
row, error = _activate_by_token(token)
if error:
resp, status_code = error
data = resp.get_json(silent=True) or {}
msg = data.get("message", "Ошибка")
logger.warning(
"[KIOSK] pair/confirm POST отказ: %s token=%s remote=%s status=%s",
msg,
_token_log_fragment(token),
request.remote_addr,
status_code,
)
return _confirm_pair_error_response(msg, status_code)
device = db.session.get(KioskDevice, row.device_id)
if not device:
logger.error(
"[KIOSK] pair/confirm POST: нет KioskDevice для device_id=%s из токена",
row.device_id,
)
return _confirm_pair_error_response("Терминал не найден", 404)
row.used_at = datetime.utcnow()
device.status = "active"
device.last_seen_at = datetime.utcnow()
device.last_ip = request.remote_addr
# Телефон с другим хостом, чем главный киоск (localhost vs LAN), имеет свой cookie и
# отдельный KioskDevice. Редирект на /scales иначе попадает в guard с pending — активируем
# и браузер, который отправил POST, если это другой pending-терминал.
helper_id = _device_cookie_id()
if helper_id and helper_id != device.id:
helper = db.session.get(KioskDevice, helper_id)
if helper and helper.status == "pending":
helper.status = "active"
helper.last_seen_at = datetime.utcnow()
helper.last_ip = request.remote_addr
logger.info(
"[KIOSK] pair/confirm POST: также активирован браузер подтверждения device_id=%s "
"(терминал из токена=%s)",
helper_id,
device.id,
)
db.session.commit()
redirect_to = _scales_public_url()
logger.info(
"[KIOSK] pair/confirm POST OK: активирован device_id=%s token=%s redirect=%s remote=%s",
device.id,
_token_log_fragment(token),
redirect_to,
request.remote_addr,
)
return _confirm_pair_success_response()
@bp.post("/pair/scan-confirm")
def confirm_pair_from_scan():
payload = request.get_json(silent=True) or {}
token = str(payload.get("token") or "").strip()
if not token:
return _error("token обязателен", 400)
target_device, error = _pair_current_device_by_token(token)
if error:
return error
logger.info(
"[KIOSK] pair/scan-confirm OK device_id=%s token=%s",
target_device.id,
_token_log_fragment(token),
)
return jsonify({"success": True, "paired": True, "device_id": target_device.id})
@bp.get("/pair/claim")
def claim_pair_from_link():
token = (request.args.get("token") or "").strip()
if not token:
return _error("token обязателен", 400)
logger.info(
"[KIOSK] pair/claim GET token=%s remote=%s",
_token_log_fragment(token),
request.remote_addr,
)
target_device, error = _pair_current_device_by_token(token)
if error:
return error
return (
"<html><head><meta charset='utf-8'></head><body>"
"<h2>Терминал привязан</h2>"
"<p>Переход на экран весов через 3 секунды...</p>"
"<script>setTimeout(function(){ window.location.href = '/scales'; }, 3000);</script>"
"</body></html>",
200,
)
+632
View File
@@ -0,0 +1,632 @@
from __future__ import annotations
import os
from flask import Blueprint, jsonify, render_template, request, session
from sqlalchemy import select
from app import db
from app.lab.calc.diff import compare_master_execution
from app.lab.calc.gfe_norms import preview_dynamic_norms
from app.lab.calc.norms_resolver import NormsParams, NormsResolveRequest, normalize_norms_method, resolve_norms
from app.lab.calc.nutrients import parse_num
from app.lab.calc.feed_groups import list_feed_groups_api, parse_group_selections
from app.lab.calc.formulate import FormulateRequest, formulate, list_formulate_components
from app.lab.calc.formulate_validate import validate_components
from app.lab.commands import (
apply_from_master,
delete_animal_profile,
ensure_empty_master,
recalculate_ration,
seed_from_execution,
sync_from_execution,
upsert_animal_profile,
upsert_ration,
)
from app.lab.loaders.execution_loader import load_execution
from app.lab.loaders.profile_loader import list_animal_profiles, load_animal_profile
from app.lab.norm_catalog import list_norm_indicators
from app.lab.services.seed_norms_catalog import (
ReferenceNormsCatalogEmptyError,
get_seed_norm_entry,
list_seed_norm_catalog,
)
from app.lab.etl.lab_import_router import parse_lab_import
from app.lab.etl.agrostar_xml_import import (
apply_agrostar_import,
enrich_parse_with_matches,
parse_agrostar_xml,
)
from app.lab.loaders.ration_loader import load_ration, ration_to_calc_lines
from app.lab.serde.api import diff_to_api, ration_to_api
from app.models import Recipe
from app.routes.auth_decorators import can_access_lab, require_auth
_LAB_TEMPLATES = os.path.join(os.path.dirname(__file__), "..", "lab", "templates")
bp = Blueprint("lab", __name__, url_prefix="/api/lab", template_folder=_LAB_TEMPLATES)
@bp.before_request
def _lab_module_access_guard():
if request.endpoint == "lab.health":
return None
if not session.get("authenticated", False):
return None
if not can_access_lab():
return (
jsonify(
{
"error": True,
"status": "error",
"message": "Модуль Lab недоступен для этого пользователя",
}
),
403,
)
return None
def _user() -> str:
return str(session.get("user_login") or "system")
@bp.get("/health")
def health():
return jsonify({"ok": True, "module": "lab"})
@bp.get("/recipes")
@require_auth
def list_lab_recipes():
"""Список партий для UI lab — только контур зоотехника (сессия)."""
rows = db.session.execute(
select(Recipe.id, Recipe.name)
.where(Recipe.is_deleted.is_(False))
.order_by(Recipe.name)
.limit(500)
).all()
return jsonify({"recipes": [{"id": rid, "name": name} for rid, name in rows]})
def _read_agrostar_xml_from_request() -> tuple[str | None, tuple | None]:
"""Вернуть (xml_text, error_response) — error_response = (json, status) или None."""
upload = request.files.get("file")
if upload is not None:
raw = upload.read()
for encoding in ("utf-8", "utf-8-sig", "cp1251"):
try:
return raw.decode(encoding), None
except UnicodeDecodeError:
continue
return None, (jsonify({"error": True, "message": "Не удалось прочитать кодировку XML"}), 400)
data = request.get_json(silent=True) or {}
xml_text = (data.get("xml") or data.get("content") or "").strip()
if xml_text:
return xml_text, None
return None, (jsonify({"error": True, "message": "Передайте file (multipart) или json.xml"}), 400)
@bp.post("/import/parse")
@require_auth
def post_lab_import_parse():
"""Единый комбайн: AgroStar xml/pdf/xlsx, ПЛИНОР pdf."""
upload = request.files.get("file")
if upload is None:
return jsonify({"error": True, "message": "Передайте file (multipart)"}), 400
data = upload.read()
body = parse_lab_import(upload.filename or "upload.bin", data)
if body.get("error"):
return jsonify(body), 400
return jsonify(body)
@bp.post("/import/agrostar-xml")
@require_auth
def post_agrostar_parse():
"""Разбор AgroStar Standard_XML_Data + подсказки сопоставления с component."""
xml_text, err = _read_agrostar_xml_from_request()
if err:
return err
parsed = parse_agrostar_xml(xml_text or "")
if parsed.errors and not parsed.samples:
return jsonify({"error": True, "message": "; ".join(parsed.errors), **parsed.to_api_dict()}), 400
body = enrich_parse_with_matches(parsed)
if parsed.errors:
body["warnings"] = parsed.errors
return jsonify(body)
@bp.post("/import/agrostar-xml/apply")
@require_auth
def post_agrostar_apply():
"""Запись nutrients и dry_matter в component по сопоставленным пробам."""
data = request.get_json(silent=True) or {}
assignments = data.get("assignments") or []
if not assignments:
return jsonify({"error": True, "message": "assignments required"}), 400
dry_run = bool(data.get("dryRun") or data.get("dry_run"))
result = apply_agrostar_import(assignments, user_id=_user(), dry_run=dry_run)
return jsonify(result.to_api_dict())
@bp.get("/rations/diff")
@require_auth
def get_diff_batch():
raw = (request.args.get("recipe_ids") or "").strip()
if not raw:
return jsonify({"error": True, "message": "recipe_ids required"}), 400
ids = [x.strip() for x in raw.split(",") if x.strip()]
results = []
for recipe_id in ids:
try:
master = load_ration(recipe_id)
execution = load_execution(recipe_id)
except LookupError:
results.append({"recipeId": recipe_id, "error": "not_found"})
continue
master_lines = [
{
"component_id": l.component_id,
"daily_kg": l.daily_kg,
"in_ration": l.in_ration,
}
for l in master.lines
]
exec_lines = [
{
"component_id": l.component_id,
"daily_kg_total": l.daily_kg_total,
}
for l in execution.lines
]
results.append(
{
"recipeId": recipe_id,
**diff_to_api(compare_master_execution(master_lines, exec_lines)),
}
)
return jsonify({"results": results})
@bp.get("/rations/<recipe_id>")
@require_auth
def get_ration(recipe_id: str):
try:
snapshot = load_ration(recipe_id)
except LookupError as exc:
return jsonify({"error": True, "message": str(exc)}), 404
return jsonify(ration_to_api(snapshot))
@bp.put("/rations/<recipe_id>")
@require_auth
def put_ration(recipe_id: str):
data = request.get_json(silent=True) or {}
try:
upsert_ration(recipe_id, data, _user())
snapshot = load_ration(recipe_id)
except LookupError as exc:
return jsonify({"error": True, "message": str(exc)}), 404
except Exception as exc:
return jsonify({"error": True, "message": str(exc)}), 400
return jsonify(ration_to_api(snapshot))
@bp.post("/rations/<recipe_id>/recalculate")
@require_auth
def post_recalculate(recipe_id: str):
try:
result = recalculate_ration(recipe_id, _user())
except LookupError as exc:
return jsonify({"error": True, "message": str(exc)}), 404
except ValueError as exc:
return jsonify({"error": True, "message": str(exc)}), 400
return jsonify(result)
@bp.get("/rations/<recipe_id>/diff")
@require_auth
def get_diff(recipe_id: str):
try:
master = load_ration(recipe_id)
execution = load_execution(recipe_id)
except LookupError as exc:
return jsonify({"error": True, "message": str(exc)}), 404
master_lines = [
{
"component_id": l.component_id,
"daily_kg": l.daily_kg,
"in_ration": l.in_ration,
}
for l in master.lines
]
exec_lines = [
{
"component_id": l.component_id,
"daily_kg_total": l.daily_kg_total,
}
for l in execution.lines
]
return jsonify(diff_to_api(compare_master_execution(master_lines, exec_lines)))
@bp.post("/rations/<recipe_id>/seed-from-execution")
@require_auth
def post_seed(recipe_id: str):
try:
result = seed_from_execution(recipe_id, _user())
except LookupError as exc:
return jsonify({"error": True, "message": str(exc)}), 404
return jsonify(result)
@bp.post("/rations/<recipe_id>/ensure-empty")
@require_auth
def post_ensure_empty(recipe_id: str):
try:
result = ensure_empty_master(recipe_id, _user())
except LookupError as exc:
return jsonify({"error": True, "message": str(exc)}), 404
return jsonify(result)
@bp.post("/rations/<recipe_id>/sync-from-execution")
@require_auth
def post_sync_from_execution(recipe_id: str):
try:
result = sync_from_execution(recipe_id, _user())
except LookupError as exc:
return jsonify({"error": True, "message": str(exc)}), 404
return jsonify(result)
@bp.post("/rations/<recipe_id>/apply-from-master")
@require_auth
def post_apply(recipe_id: str):
try:
result = apply_from_master(recipe_id, _user())
except LookupError as exc:
return jsonify({"error": True, "message": str(exc)}), 404
except ValueError as exc:
return jsonify({"error": True, "message": str(exc)}), 400
return jsonify(result)
@bp.get("/rations/<recipe_id>/compound")
@require_auth
def get_compound(recipe_id: str):
try:
snapshot = load_ration(recipe_id)
except LookupError as exc:
return jsonify({"error": True, "message": str(exc)}), 404
return jsonify(snapshot.compound_results or {})
@bp.get("/norm-indicators")
@require_auth
def get_norm_indicators():
return jsonify({"indicators": list_norm_indicators()})
@bp.get("/norms-preview")
@require_auth
def get_norms_preview():
"""Preview суточных норм по выбранной методике."""
from app.lab.models import LabAnimalProfile
from app.lab.services.norms_params import load_norms_params
method = normalize_norms_method(request.args.get("method"))
mass = parse_num(request.args.get("mass_kg"))
milk = parse_num(request.args.get("milk_yield_kg"))
profile_id = (request.args.get("profile_id") or "").strip()
stored: dict = {}
params = NormsParams()
if profile_id:
profile = LabAnimalProfile.query.filter_by(id=profile_id, is_deleted=False).first()
if profile is not None:
from app.lab.services.profile_norms import load_norms_dict
stored = load_norms_dict(profile.id)
params = load_norms_params(profile)
if mass is None:
mass = profile.mass_kg
if milk is None:
milk = profile.milk_yield_kg
if not request.args.get("method"):
method = normalize_norms_method(profile.norms_method)
override = NormsParams.from_dict(
{
"milkFatPct": parse_num(request.args.get("milk_fat_pct")),
"lactationNo": request.args.get("lactation_no"),
"bodyCondition": request.args.get("body_condition"),
"housingSystem": request.args.get("housing_system"),
"koncOeSv": parse_num(request.args.get("konc_oe_sv")),
}
)
params = NormsParams(
milk_fat_pct=override.milk_fat_pct if override.milk_fat_pct is not None else params.milk_fat_pct,
lactation_no=override.lactation_no if override.lactation_no is not None else params.lactation_no,
lactation_stage=override.lactation_stage if override.lactation_stage is not None else params.lactation_stage,
body_condition=override.body_condition if override.body_condition is not None else params.body_condition,
housing_system=override.housing_system if override.housing_system is not None else params.housing_system,
konc_oe_sv=override.konc_oe_sv if override.konc_oe_sv is not None else params.konc_oe_sv,
)
try:
resolved, meta = resolve_norms(
NormsResolveRequest(
method=method,
stored=stored,
mass_kg=mass,
milk_yield_kg=milk,
force_dynamic=method == "wesp",
params=params,
)
)
except ValueError as exc:
return jsonify({"error": True, "message": str(exc)}), 400
dynamic = meta.get("dynamicNorms") or meta.get("dynamic") or {}
if method == "wesp" and not dynamic:
dynamic = preview_dynamic_norms(mass, milk)
body: dict = {
"normsMethod": method,
"resolvedIndicators": resolved,
"dynamicNorms": dynamic,
"normsMeta": meta.get("meta"),
}
if meta.get("coverage"):
body["coverage"] = meta["coverage"]
return jsonify(body)
@bp.get("/gfe-norms-preview")
@require_auth
def get_gfe_norms_preview():
"""Расчётные min по GfE 2001 (alias method=wesp)."""
mass = parse_num(request.args.get("mass_kg"))
milk = parse_num(request.args.get("milk_yield_kg"))
return jsonify({"dynamicNorms": preview_dynamic_norms(mass, milk), "normsMethod": "wesp"})
@bp.get("/seed-norms-catalog")
@require_auth
def get_seed_norms_catalog():
"""Строки справочника zootech (lab_animal_profile norm_*)."""
ration_type = (request.args.get("ration_type") or "DAIRY").strip().upper()
try:
entries = list_seed_norm_catalog(ration_type)
except ValueError as exc:
return jsonify({"error": True, "message": str(exc)}), 400
except ReferenceNormsCatalogEmptyError as exc:
return jsonify({"error": True, "message": str(exc)}), 404
return jsonify({"rationType": ration_type, "entries": entries})
@bp.get("/seed-norms-catalog/<int:external_no>")
@require_auth
def get_seed_norms_catalog_row(external_no: int):
ration_type = (request.args.get("ration_type") or "DAIRY").strip().upper()
try:
entry = get_seed_norm_entry(ration_type, external_no)
except ValueError as exc:
return jsonify({"error": True, "message": str(exc)}), 400
if entry is None:
return jsonify({"error": True, "message": "Строка справочника не найдена"}), 404
return jsonify(entry)
@bp.get("/formulate/groups")
@require_auth
def get_formulate_groups():
return jsonify({"groups": list_feed_groups_api()})
@bp.get("/formulate/components")
@require_auth
def get_formulate_components():
return jsonify({"components": list_formulate_components(), "groups": list_feed_groups_api()})
@bp.get("/formulate/validate")
@require_auth
def get_formulate_validate():
raw = (request.args.get("ids") or "").strip()
if not raw:
return jsonify({"error": True, "message": "ids required"}), 400
ids = [x.strip() for x in raw.split(",") if x.strip()]
return jsonify({"components": validate_components(ids)})
@bp.post("/formulate")
@require_auth
def post_formulate():
data = request.get_json(silent=True) or {}
group_selections = parse_group_selections(data.get("groupSelections"))
main_feed_ids = data.get("mainFeedIds") or []
additional_ids = data.get("additionalIds") or []
candidate_ids = data.get("candidateIds") or data.get("componentIds") or []
if group_selections:
candidate_ids = []
elif main_feed_ids or additional_ids:
if not isinstance(main_feed_ids, list) or not isinstance(additional_ids, list):
return jsonify({"error": True, "message": "mainFeedIds/additionalIds must be lists"}), 400
group_selections = {
"rough": [str(x) for x in main_feed_ids],
"succulent": [],
"concentrate": [str(x) for x in additional_ids if str(x) not in main_feed_ids],
"other": [],
}
for x in additional_ids:
sx = str(x)
if sx not in group_selections["rough"] and sx not in group_selections["concentrate"]:
group_selections["concentrate"].append(sx)
candidate_ids = []
elif not isinstance(candidate_ids, list):
return jsonify({"error": True, "message": "candidateIds must be a list"}), 400
profile_id = (data.get("profileId") or "").strip()
if not profile_id:
return jsonify({"error": True, "message": "profileId required"}), 400
try:
result = formulate(
FormulateRequest(
profile_id=profile_id,
candidate_ids=[str(x) for x in candidate_ids],
group_selections=group_selections,
main_feed_ids=[str(x) for x in main_feed_ids],
mass_kg=parse_formulate_num(data.get("massKg")),
milk_yield_kg=parse_formulate_num(data.get("milkYieldKg")),
heads_per_trip=_parse_int(data.get("headsPerTrip")),
total_kg_per_head=float(data.get("totalKgPerHead") or 7.3),
optimize_keys=list(data.get("optimizeKeys") or []),
objective=str(data.get("objective") or "min_cost"),
cost_weight=float(data.get("costWeight") or 100),
grid_step=float(data.get("gridStep") or 0.1),
prefilter_k=int(data.get("prefilterK") or 18),
min_share=float(data.get("minShare") or 0.05),
norms_method=str(data.get("normsMethod") or "wesp"),
norms_params=dict(data.get("normsParams") or {}),
)
)
except LookupError as exc:
return jsonify({"error": True, "message": str(exc)}), 404
except ValueError as exc:
args = exc.args
if args and args[0] == "ineligible_components":
return jsonify(
{
"error": True,
"message": "Некоторые компоненты не подходят для авторациона",
"components": args[1],
}
), 400
if args and args[0] == "candidate_ids_min_3":
return jsonify({"error": True, "message": "Нужно минимум 3 кандидата"}), 400
if args and args[0] == "candidate_ids_duplicate":
return jsonify({"error": True, "message": "Дубликаты в candidateIds"}), 400
if args and args[0] == "no_feasible_solution":
return jsonify({"error": True, "message": "Не удалось подобрать рацион"}), 400
if args and args[0] == "group_selection_invalid":
return jsonify(
{
"error": True,
"message": "Некорректный выбор по группам кормов",
"errors": args[1],
}
), 400
if args and args[0] == "no_feasible_solution_groups":
return jsonify(
{
"error": True,
"message": "Не удалось подобрать рацион с учётом обязательных групп кормов",
}
), 400
return jsonify({"error": True, "message": str(exc)}), 400
return jsonify(result)
def parse_formulate_num(value) -> float | None:
if value is None or value == "":
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _parse_int(value) -> int | None:
if value is None or value == "":
return None
try:
return int(value)
except (TypeError, ValueError):
return None
@bp.get("/animal-profiles")
@require_auth
def get_profiles():
ration_type = (request.args.get("ration_type") or "").strip() or None
return jsonify({"profiles": list_animal_profiles(ration_type)})
@bp.post("/animal-profiles")
@require_auth
def post_profile():
data = request.get_json(silent=True) or {}
try:
result = upsert_animal_profile(None, data, _user())
except ValueError as exc:
return jsonify({"error": True, "message": str(exc)}), 400
return jsonify(result), 201
@bp.get("/animal-profiles/<profile_id>")
@require_auth
def get_profile(profile_id: str):
try:
return jsonify(load_animal_profile(profile_id))
except LookupError as exc:
return jsonify({"error": True, "message": str(exc)}), 404
@bp.put("/animal-profiles/<profile_id>")
@require_auth
def put_profile(profile_id: str):
data = request.get_json(silent=True) or {}
try:
result = upsert_animal_profile(profile_id, data, _user())
except ValueError as exc:
return jsonify({"error": True, "message": str(exc)}), 400
return jsonify(result)
@bp.delete("/animal-profiles/<profile_id>")
@require_auth
def delete_profile(profile_id: str):
try:
result = delete_animal_profile(profile_id, _user())
except LookupError as exc:
return jsonify({"error": True, "message": str(exc)}), 404
return jsonify(result)
@bp.get("/rations/<recipe_id>/print")
@require_auth
def get_print(recipe_id: str):
mode = (request.args.get("mode") or "ration").strip().lower()
if mode not in ("ration", "compound"):
return jsonify({"error": True, "message": "mode must be ration or compound"}), 400
try:
snapshot = load_ration(recipe_id)
except LookupError as exc:
return jsonify({"error": True, "message": str(exc)}), 404
if not snapshot.exists:
return jsonify({"error": True, "message": "Мастер рациона не найден"}), 404
lines = []
for line in snapshot.lines:
included = line.in_ration if mode == "ration" else line.in_compound
if not included:
continue
lines.append(
{
"name": line.ingredient_name or line.component_id or "",
"daily_kg": line.daily_kg,
"included": True,
}
)
results = snapshot.ration_results if mode == "ration" else snapshot.compound_results
indicators = (results or {}).get("indicators") or []
title = "Рацион" if mode == "ration" else "Комбикорм"
return render_template(
"print_ration.html",
title=title,
recipe_name=snapshot.recipe_name,
ration_type=snapshot.ration_type,
heads=snapshot.heads_per_trip,
mode=mode,
lines=lines,
indicators=indicators[:12],
)
+583
View File
@@ -0,0 +1,583 @@
import time
import uuid
from threading import Lock
from typing import Any, Dict, List, Optional, Tuple
from flask import Blueprint, current_app, jsonify, request, send_from_directory, session
from sqlalchemy import select
from app import db
from app.models import (
Component,
ComponentLoadingTime,
FeedDispenser,
FeedMixer,
FeedingLocation,
FeedingPeriod,
FeedingPoint,
Ingredient,
LoadingReport,
LoadingReportComponent,
PeriodRecipe,
Recipe,
Trip,
UnloadingGroup,
UnloadingReport,
UnloadingReportGroup,
)
from app.routes.auth_decorators import require_auth, require_auth_or_paired_terminal, require_paired_terminal
from app.services.daily_plan.loading_recipe import plan_ingredients_for_loading
from app.services.update_api_service import (
build_updates_status_payload,
force_check_updates,
install_pending_update,
)
from app.services.hardware import GPIOController
from config import write_sync_client_state
bp = Blueprint("legacy_misc", __name__, url_prefix="/api")
class _LoadingRuntime:
def __init__(self) -> None:
self._lock = Lock()
self.current_recipe_id: Optional[str] = None
self.current_component_index = 0
self.weight_at_current_component_start = 0.0
self.is_mixing_mode = False
self.mixing_timer_active = False
self.navigation_commands_queue: List[Dict[str, Any]] = []
def snapshot(self) -> Dict[str, Any]:
with self._lock:
return {
"current_recipe_id": self.current_recipe_id,
"current_component_index": self.current_component_index,
"weight_at_current_component_start": self.weight_at_current_component_start,
"is_mixing_mode": self.is_mixing_mode,
"mixing_timer_active": self.mixing_timer_active,
}
_runtime = _LoadingRuntime()
def _error(message: str, status_code: int = 400):
return jsonify({"error": True, "message": message}), status_code
def _get_active_recipe(recipe_id: Optional[str]) -> Optional[Recipe]:
if not recipe_id:
return None
return db.session.execute(
select(Recipe).where(Recipe.id == recipe_id, Recipe.is_deleted.is_(False))
).scalar_one_or_none()
def _get_active_ingredients(recipe: Recipe) -> List[Ingredient]:
rows = [i for i in (recipe.ingredients or []) if not getattr(i, "is_deleted", False)]
return sorted(rows, key=lambda x: (getattr(x, "order", 0), x.created_at))
def _loading_ingredient_rows(recipe: Recipe) -> List[Dict[str, Any]]:
"""Веса и состав для экрана оператора — overlay «План на день», как на киоске."""
return plan_ingredients_for_loading(recipe)
def _current_weight() -> float:
"""Текущий вес с тех же весов, что и GET /current_weight (ScalesReader)."""
try:
from app.routes import scales as scales_module
reader = scales_module._get_reader()
return float(reader.get_current_weight())
except Exception:
return 0.0
@bp.post("/set_mixing_mode")
@require_auth
def set_mixing_mode():
data = request.get_json(silent=True) or {}
with _runtime._lock:
_runtime.is_mixing_mode = bool(data.get("is_mixing", False))
return jsonify({"status": "success"})
@bp.post("/led/<string:state>")
@require_auth
def control_led(state: str):
try:
controller = GPIOController.get_instance(
pin=int(current_app.config.get("GPIO_LED_PIN", 18)),
simulation_mode=bool(current_app.config.get("SIMULATION_MODE", False)),
)
if state == "on":
controller.on()
return jsonify({"status": "success", "message": "Светодиод включен"})
if state == "off":
controller.off()
return jsonify({"status": "success", "message": "Светодиод выключен"})
if state == "blink":
payload = request.get_json(silent=True) or {}
times = int(payload.get("times", 1) or 1)
delay = float(payload.get("delay", 0.5) or 0.5)
controller.blink(times=max(1, times), delay=max(0.05, delay))
return jsonify({"status": "success", "message": f"Мигание {times} раз"})
return jsonify({"status": "error", "message": "Неверная команда"}), 400
except Exception as e:
return jsonify({"status": "error", "message": str(e)}), 500
@bp.post("/set_mixing_timer")
@require_auth_or_paired_terminal
def set_mixing_timer():
data = request.get_json(silent=True) or {}
with _runtime._lock:
_runtime.mixing_timer_active = bool(data.get("active", False))
return jsonify({"status": "success"})
@bp.get("/get_mixing_timer")
@require_auth_or_paired_terminal
def get_mixing_timer():
with _runtime._lock:
active = _runtime.mixing_timer_active
return jsonify({"active": active})
def _kg_int(value: float) -> int:
"""Целые килограммы для API и UI."""
return int(round(float(value)))
@bp.get("/weight_display_data")
@require_paired_terminal
def weight_display_data():
snap = _runtime.snapshot()
recipe = _get_active_recipe(snap["current_recipe_id"])
current_weight = _current_weight()
if recipe is None:
return jsonify(
{
"status": "no_recipe",
"component_name": "Текущий вес",
"remaining_weight": 0,
"current_loaded": _kg_int(current_weight),
"total_component": 0,
"total_mixture": _kg_int(current_weight),
"recipe_name": "",
"current_index": 0,
"total_components": 0,
"show_reset_button": False,
"show_nav_buttons": False,
"is_mixing_mode": snap["is_mixing_mode"],
"mixing_timer_active": snap["mixing_timer_active"],
"next_component_name": None,
}
)
ingredients = _loading_ingredient_rows(recipe)
total_components = len(ingredients)
if total_components == 0:
return jsonify({"status": "error", "message": "Нет активных компонентов"}), 400
idx = min(snap["current_component_index"], total_components - 1)
current_ingredient = ingredients[idx]
current_loaded = max(0.0, current_weight - snap["weight_at_current_component_start"])
total_component_weight = float(current_ingredient.get("amount") or 0)
total_mixture_weight = sum(float(i.get("amount") or 0) for i in ingredients)
remaining = max(0.0, total_component_weight - current_loaded)
next_name = ingredients[idx + 1]["name"] if idx < total_components - 1 else None
return jsonify(
{
"status": "active",
"component_name": current_ingredient["name"],
"remaining_weight": _kg_int(remaining),
"current_loaded": _kg_int(current_loaded),
"total_component": _kg_int(total_component_weight),
"total_mixture": _kg_int(total_mixture_weight),
"recipe_name": recipe.name,
"current_index": idx,
"total_components": total_components,
"show_reset_button": current_loaded > 0,
"show_nav_buttons": total_components > 1,
"is_mixing_mode": snap["is_mixing_mode"],
"mixing_timer_active": snap["mixing_timer_active"],
"next_component_name": next_name,
}
)
@bp.get("/get_current_recipe")
@require_auth
def get_current_recipe():
snap = _runtime.snapshot()
recipe = _get_active_recipe(snap["current_recipe_id"])
if recipe is None:
return jsonify({"status": "inactive", "message": "Рецепт не выбран"})
ingredients = _loading_ingredient_rows(recipe)
return jsonify(
{
"status": "active",
"recipe_id": recipe.id,
"name": recipe.name,
"ingredients": [
{"name": ingredient["name"], "amount": float(ingredient.get("amount") or 0)}
for ingredient in ingredients
],
"total_weight": sum(float(i.get("amount") or 0) for i in ingredients),
}
)
@bp.get("/current_loading_state")
@require_auth
def current_loading_state():
snap = _runtime.snapshot()
recipe = _get_active_recipe(snap["current_recipe_id"])
if recipe is None:
return jsonify({"status": "inactive", "message": "Рецепт не выбран"})
ingredients = _loading_ingredient_rows(recipe)
idx = min(snap["current_component_index"], max(len(ingredients) - 1, 0))
current_ingredient = ingredients[idx] if ingredients else None
current_weight = _current_weight()
loaded = max(0.0, current_weight - snap["weight_at_current_component_start"])
target = float(current_ingredient.get("amount") or 0) if current_ingredient else 0.0
remaining = max(0.0, target - loaded)
return jsonify(
{
"status": "active",
"recipe_id": recipe.id,
"recipe_name": recipe.name,
"current_index": idx,
"current_component": {
"name": current_ingredient["name"] if current_ingredient else "Не выбран",
"target_weight": target,
"current_weight": current_weight,
"current_loaded": loaded,
"remaining_weight": remaining,
},
"total_mixture_weight": sum(float(i.get("amount") or 0) for i in ingredients),
}
)
@bp.post("/set_current_recipe")
@require_auth_or_paired_terminal
def set_current_recipe():
data = request.get_json(silent=True) or {}
recipe_id = data.get("recipe_id")
if not recipe_id:
with _runtime._lock:
_runtime.current_recipe_id = None
_runtime.current_component_index = 0
_runtime.weight_at_current_component_start = 0.0
return jsonify({"status": "cleared"})
recipe = _get_active_recipe(str(recipe_id))
if recipe is None:
return jsonify({"status": "error", "message": "Рецепт не найден или удален"}), 404
with _runtime._lock:
_runtime.current_recipe_id = recipe.id
_runtime.current_component_index = 0
_runtime.weight_at_current_component_start = _current_weight()
return jsonify({"status": "ok"})
@bp.post("/sync_loading_state")
@require_auth_or_paired_terminal
def sync_loading_state():
data = request.get_json(silent=True) or {}
with _runtime._lock:
if data.get("recipe_id"):
_runtime.current_recipe_id = str(data["recipe_id"])
_runtime.current_component_index = int(data.get("component_index", 0) or 0)
_runtime.weight_at_current_component_start = float(
data.get("component_start_weight", 0) or 0
)
return jsonify({"status": "success"})
@bp.post("/update_loading_state")
@require_auth_or_paired_terminal
def update_loading_state():
return jsonify({"status": "success"})
@bp.post("/navigate_component")
@require_auth_or_paired_terminal
def navigate_component():
data = request.get_json(silent=True) or {}
direction = data.get("direction")
with _runtime._lock:
recipe = _get_active_recipe(_runtime.current_recipe_id)
if recipe is None:
return jsonify({"status": "error", "message": "Рецепт не выбран"}), 400
total = len(_loading_ingredient_rows(recipe))
if total == 0:
return jsonify({"status": "error", "message": "Нет активных компонентов"}), 400
if direction == "next" and _runtime.current_component_index < total - 1:
_runtime.current_component_index += 1
_runtime.weight_at_current_component_start = _current_weight()
elif direction == "prev" and _runtime.current_component_index > 0:
_runtime.current_component_index -= 1
_runtime.weight_at_current_component_start = _current_weight()
else:
return jsonify({"status": "error", "message": "Невозможно переключить"}), 400
return jsonify({"status": "ok", "index": _runtime.current_component_index})
@bp.post("/reset_component")
@require_auth_or_paired_terminal
def reset_component():
with _runtime._lock:
if _runtime.current_recipe_id is None:
return jsonify({"status": "error", "message": "Рецепт не выбран"}), 400
_runtime.weight_at_current_component_start = _current_weight()
return jsonify({"status": "ok"})
@bp.post("/send_navigation_command")
@require_paired_terminal
def send_navigation_command():
data = request.get_json(silent=True) or {}
command = data.get("command")
if command not in {"prev", "next"}:
return jsonify({"status": "error", "message": "Неверная команда"}), 400
with _runtime._lock:
_runtime.navigation_commands_queue.append({"command": command, "timestamp": time.time()})
if len(_runtime.navigation_commands_queue) > 10:
_runtime.navigation_commands_queue = _runtime.navigation_commands_queue[-10:]
return jsonify({"status": "success", "message": f"Команда {command} отправлена"})
@bp.get("/get_navigation_commands")
@require_auth_or_paired_terminal
def get_navigation_commands():
with _runtime._lock:
commands = list(_runtime.navigation_commands_queue)
_runtime.navigation_commands_queue.clear()
return jsonify({"status": "success", "commands": commands})
@bp.post("/set_role")
@require_auth
def set_role():
data = request.get_json(silent=True) or {}
role = data.get("role")
dispenser_name = (data.get("dispenser_name") or "").strip()
if role not in {"zootechnician", "dispenser"}:
return jsonify({"status": "error", "message": "Неверная роль"}), 400
session["role"] = role
if role == "dispenser":
session["dispenser_name"] = dispenser_name
else:
session.pop("dispenser_name", None)
return jsonify({"status": "ok", "role": role, "dispenser_name": dispenser_name})
@bp.get("/mac_info")
@require_auth
def mac_info():
raw = f"{uuid.getnode():012x}"
mac = ":".join(raw[i : i + 2] for i in range(0, 12, 2))
lock_enabled = bool(current_app.config.get("MAC_LOCK_ENABLED", False))
allowed = current_app.config.get("ALLOWED_MAC_ADDRESSES", []) or []
return jsonify(
{
"current_mac": mac,
"is_authorized": (not lock_enabled) or (mac in allowed),
"mac_lock_enabled": lock_enabled,
"allowed_mac_addresses": allowed,
}
)
def _model_by_entity_type(entity_type: str):
model_map = {
"components": Component,
"recipes": Recipe,
"ingredients": Ingredient,
"unloading_groups": UnloadingGroup,
"feed_dispensers": FeedDispenser,
"feed_mixers": FeedMixer,
"feeding_periods": FeedingPeriod,
"feeding_locations": FeedingLocation,
"feeding_points": FeedingPoint,
"trips": Trip,
"period_recipes": PeriodRecipe,
"loading_reports": LoadingReport,
"loading_report_components": LoadingReportComponent,
"component_loading_times": ComponentLoadingTime,
"unloading_reports": UnloadingReport,
"unloading_report_groups": UnloadingReportGroup,
}
return model_map.get(entity_type)
def _resolve_entity(model, entity_id: str):
if model is PeriodRecipe:
parts = entity_id.split(":", 1)
if len(parts) != 2:
return None
return db.session.execute(
select(PeriodRecipe).where(
PeriodRecipe.period_id == parts[0],
PeriodRecipe.recipe_id == parts[1],
PeriodRecipe.is_deleted.is_(False),
)
).scalar_one_or_none()
return db.session.get(model, entity_id)
@bp.delete("/<string:entity_type>/<string:entity_id>/soft_delete")
@require_auth
def soft_delete_entity(entity_type: str, entity_id: str):
model = _model_by_entity_type(entity_type)
if model is None:
return _error(f"Неизвестный тип сущности: {entity_type}", 400)
entity = _resolve_entity(model, entity_id)
if entity is None:
return _error("Объект не найден", 404)
if not hasattr(entity, "is_deleted"):
return _error("Мягкое удаление не поддерживается", 400)
if entity.is_deleted:
return _error("Объект уже удален", 400)
deleted_by = (request.get_json(silent=True) or {}).get("deleted_by") or "system"
entity.soft_delete(deleted_by_user=deleted_by)
db.session.commit()
return jsonify(
{
"success": True,
"message": f"{entity_type} успешно удален",
"deleted_at": entity.deleted_at.isoformat() if entity.deleted_at else None,
"deleted_by": entity.deleted_by,
}
)
@bp.post("/<string:entity_type>/<string:entity_id>/restore")
@require_auth
def restore_entity(entity_type: str, entity_id: str):
model = _model_by_entity_type(entity_type)
if model is None:
return _error(f"Неизвестный тип сущности: {entity_type}", 400)
entity = _resolve_entity(model, entity_id)
if entity is None:
return _error("Объект не найден", 404)
if not hasattr(entity, "is_deleted"):
return _error("Восстановление не поддерживается", 400)
if not entity.is_deleted:
return _error("Объект не удален", 400)
entity.is_deleted = False
entity.deleted_at = None
entity.deleted_by = None
db.session.commit()
return jsonify({"success": True, "message": f"{entity_type} успешно восстановлен"})
@bp.get("/<string:entity_type>/deleted")
@require_auth
def deleted_entities(entity_type: str):
model = _model_by_entity_type(entity_type)
if model is None:
return _error(f"Неизвестный тип сущности: {entity_type}", 400)
if not hasattr(model, "is_deleted"):
return jsonify([])
rows = db.session.execute(select(model).where(model.is_deleted.is_(True))).scalars().all()
payload = []
for r in rows:
payload.append(
{
"id": getattr(r, "id", None),
"name": getattr(r, "name", "N/A"),
"is_deleted": getattr(r, "is_deleted", False),
"deleted_at": r.deleted_at.isoformat() if getattr(r, "deleted_at", None) else None,
"deleted_by": getattr(r, "deleted_by", None),
}
)
return jsonify(payload)
@bp.get("/updates/check")
@require_auth_or_paired_terminal
def updates_check():
return jsonify(force_check_updates())
@bp.post("/updates/install")
@require_auth_or_paired_terminal
def updates_install():
body, code = install_pending_update()
return jsonify(body), code
@bp.get("/updates/status")
@require_auth_or_paired_terminal
def updates_status():
return jsonify(build_updates_status_payload())
@bp.get("/health")
def health_check():
from app.services.update_health import build_health_payload
payload = build_health_payload(current_app)
code = 200 if payload.get("ok") else 503
return jsonify(payload), code
@bp.get("/server/info")
def server_info():
ver = current_app.config.get("SYNC_CLIENT_VERSION", "1.0.0")
is_master = bool(current_app.config.get("SYNC_CLIENT_IS_MASTER", True))
return jsonify(
{
"system": "wesp",
"version": ver,
"role": "server",
"status": "active",
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
"api_version": "1.0",
"is_master": is_master,
}
)
@bp.get("/config/update_server_url")
@require_auth
def update_server_url():
new_url = (request.args.get("url") or "").strip()
if not new_url:
return jsonify({"status": "error", "message": "Не указан параметр url"}), 400
if not (new_url.startswith("http://") or new_url.startswith("https://")):
return (
jsonify(
{
"status": "error",
"message": "url должен начинаться с http:// или https://",
}
),
400,
)
normalized = new_url.rstrip("/") + "/"
write_sync_client_state(
{
"server_url": normalized,
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
}
)
return jsonify(
{
"status": "success",
"message": "URL сервера обновлён",
"server_url": normalized,
}
)
+84
View File
@@ -0,0 +1,84 @@
from flask import Blueprint, jsonify, request, session
from app.routes.auth_decorators import require_auth
from app.services.notification_center_service import (
create_notification,
get_notification,
list_notifications,
mark_all_read,
mark_read,
unread_count,
)
bp = Blueprint("notifications", __name__, url_prefix="/api/notifications")
@bp.get("")
@require_auth
def notifications_list():
if request.args.get("summary") in ("1", "true", "yes"):
return jsonify(unread_count()), 200
sort = request.args.get("sort", "newest")
category = request.args.get("category") or None
unread_only = request.args.get("unread") in ("1", "true", "yes")
day = request.args.get("date") or None
return jsonify(
list_notifications(
date=day,
category=category,
unread_only=unread_only,
sort=sort,
)
), 200
@bp.get("/<string:notification_id>")
@require_auth
def notifications_get(notification_id: str):
row = get_notification(notification_id)
if row is None:
return jsonify({"error": True, "message": "Уведомление не найдено"}), 404
from app.services.notification_center_service import _serialize
return jsonify(_serialize(row)), 200
@bp.post("")
@require_auth
def notifications_create():
data = request.get_json(silent=True) or {}
title = data.get("title") or ""
detail = data.get("detail") or title
if not str(title).strip() and not str(detail).strip():
return jsonify({"error": True, "message": "Пустое уведомление"}), 400
row = create_notification(
title=str(title),
detail=str(detail),
kind=data.get("kind") or data.get("type") or "info",
category=data.get("category") or "general",
page=data.get("page"),
link_kind=data.get("linkKind") or data.get("link_kind"),
link_id=data.get("linkId") or data.get("link_id"),
user_login=session.get("login"),
)
from app.services.notification_center_service import _serialize
return jsonify(_serialize(row)), 201
@bp.patch("/read-all")
@require_auth
def notifications_read_all():
count = mark_all_read()
return jsonify({"success": True, "marked": count}), 200
@bp.patch("/<string:notification_id>/read")
@require_auth
def notifications_mark_read(notification_id: str):
row = mark_read(notification_id)
if row is None:
return jsonify({"error": True, "message": "Уведомление не найдено"}), 404
from app.services.notification_center_service import _serialize
return jsonify(_serialize(row)), 200
+540
View File
@@ -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)
+220
View File
@@ -0,0 +1,220 @@
import logging
from datetime import datetime
from flask import Blueprint, jsonify, request
from sqlalchemy import func, select
from app import db
from app.models import FeedingPeriod, Ingredient, PeriodRecipe, Recipe, UnloadingGroup
from app.routes.auth_decorators import require_auth, require_auth_or_paired_terminal
from app.services.period_recipes_query import recipes_for_period_ordered
from app.services.daily_plan.adjustments import get_adjusted_recipe_ids
from app.services.daily_plan.replacements import get_replaced_recipe_ids
from app.services.daily_plan.builder import recipe_total_weights_by_id
from app.services.daily_plan.skips import (
filter_recipes_for_list_view,
get_skipped_ingredient_ids,
get_skipped_unloading_group_ids,
is_kiosk_recipe_list_request,
is_zootech_recipe_list_view,
)
from app.routes.recipes import (
_group_distribution_type,
_ingredient_dry_matter_per_head,
_ingredient_weight_per_head,
_unloading_groups_from_payload,
)
bp = Blueprint("periods", __name__, url_prefix="/api/periods")
logger = logging.getLogger(__name__)
def _error(message: str, status_code: int = 400):
return jsonify({"error": True, "message": message}), status_code
def _serialize_period_recipe(
recipe: Recipe,
*,
total_weight: float | None = None,
skipped_today: bool = False,
skipped_ingredient_today: bool = False,
skipped_group_today: bool = False,
adjusted_ingredient_today: bool = False,
replaced_ingredient_today: bool = False,
):
payload = {
"id": recipe.id,
"name": recipe.name,
"heads_count": recipe.heads_per_trip,
"mixing_time": recipe.mixing_time,
"trip_percent": recipe.trip_percent,
}
if total_weight is not None:
payload["total_weight"] = total_weight
if is_zootech_recipe_list_view():
payload["skippedToday"] = skipped_today
payload["skippedIngredientToday"] = skipped_ingredient_today
payload["skippedGroupToday"] = skipped_group_today
payload["adjustedIngredientToday"] = adjusted_ingredient_today
payload["replacedIngredientToday"] = replaced_ingredient_today
return payload
@bp.get("/<string:period_id>/recipes")
@require_auth_or_paired_terminal
def period_recipes_list(period_id: str):
period = db.session.execute(
select(FeedingPeriod).where(
FeedingPeriod.id == period_id, FeedingPeriod.is_deleted.is_(False)
)
).unique().scalar_one_or_none()
if not period:
return _error("Период не найден", 404)
recipes = recipes_for_period_ordered(period_id)
visible, skipped_today = filter_recipes_for_list_view(recipes)
skip_ings = get_skipped_ingredient_ids()
skip_grps = get_skipped_unloading_group_ids()
adj_ids = get_adjusted_recipe_ids()
repl_ids = get_replaced_recipe_ids()
plan_weights: dict[str, float] = {}
if is_kiosk_recipe_list_request():
plan_weights = recipe_total_weights_by_id(dispenser_id=period.dispenser_id)
def _row(recipe: Recipe) -> dict:
return _serialize_period_recipe(
recipe,
total_weight=plan_weights.get(recipe.id),
skipped_today=recipe.id in skipped_today,
skipped_ingredient_today=bool(skip_ings.get(recipe.id)),
skipped_group_today=bool(skip_grps.get(recipe.id)),
adjusted_ingredient_today=recipe.id in adj_ids,
replaced_ingredient_today=recipe.id in repl_ids,
)
return jsonify([_row(r) for r in visible])
@bp.post("/<string:period_id>/recipes")
@require_auth
def period_recipes_add(period_id: str):
period = db.session.execute(
select(FeedingPeriod).where(
FeedingPeriod.id == period_id, FeedingPeriod.is_deleted.is_(False)
)
).unique().scalar_one_or_none()
if not period:
return _error("Период не найден", 404)
data = request.get_json() or {}
recipe_id = data.get("recipe_id")
logger.info(
"[RECIPES-DB] period_recipes_add period_id=%s dispenser_id=%s attach_recipe_id=%s "
"path=%s remote=%s",
period_id,
period.dispenser_id,
recipe_id or "(new)",
request.path,
request.remote_addr,
)
if recipe_id:
recipe = db.session.execute(
select(Recipe).where(Recipe.id == recipe_id, Recipe.is_deleted.is_(False))
).scalar_one_or_none()
if not recipe:
return _error("Рецепт не найден", 404)
else:
name = (data.get("name") or "").strip()
if not name:
return _error("Название рецепта обязательно", 400)
recipe = Recipe(
name=name,
heads_per_trip=int(data.get("heads_count", data.get("headsPerTrip", 1))),
mixing_time=int(data.get("mixing_time", data.get("mixingTime", 0))),
trip_percent=float(data.get("trip_percent", data.get("tripPercent", 100))),
dry_matter_locked=bool(
data.get("dry_matter_locked", data.get("dryMatterLocked", False))
),
unloading_link_broken=bool(data.get("unloading_link_broken", False)),
target_component_id=data.get("target_component_id"),
created_by="system",
updated_by="system",
)
db.session.add(recipe)
db.session.flush()
for idx, ing in enumerate(data.get("ingredients", []) or [], start=1):
ingredient = Ingredient(
name=ing.get("name", ""),
weight_per_head=_ingredient_weight_per_head(ing),
amount=float(ing.get("amount", 0) or 0),
dry_matter=float(ing.get("dry_matter", 0) or 0),
dry_matter_per_head=_ingredient_dry_matter_per_head(ing),
component_id=ing.get("component_id"),
order=idx,
recipe_id=recipe.id,
created_by="system",
updated_by="system",
)
db.session.add(ingredient)
for idx, group in enumerate(_unloading_groups_from_payload(data), start=1):
unloading_group = UnloadingGroup(
name=group.get("name", ""),
distribution_type=_group_distribution_type(group),
value=float(group.get("value", 0) or 0),
weight=float(group["weight"]) if group.get("weight") not in (None, "") else None,
order=int(group.get("order", idx)),
recipe_id=recipe.id,
created_by="system",
updated_by="system",
)
db.session.add(unloading_group)
existing = db.session.execute(
select(PeriodRecipe).where(
PeriodRecipe.period_id == period.id,
PeriodRecipe.recipe_id == recipe.id,
PeriodRecipe.is_deleted.is_(False),
)
).scalar_one_or_none()
if existing:
db.session.commit()
logger.info(
"[RECIPES-DB] period_recipes_add period_id=%s recipe_id=%s already_linked",
period.id,
recipe.id,
)
return jsonify({"success": True, "id": recipe.id, "message": "Рецепт уже в периоде"})
max_order = db.session.scalar(
select(func.max(PeriodRecipe.order)).where(
PeriodRecipe.period_id == period.id, PeriodRecipe.is_deleted.is_(False)
)
) or 0
link = PeriodRecipe(
period_id=period.id,
recipe_id=recipe.id,
order=max_order + 1,
created_at=datetime.now(),
created_by="system",
updated_by="system",
)
db.session.add(link)
db.session.commit()
logger.info(
"[RECIPES-DB] period_recipes_add period_id=%s dispenser_id=%s recipe_id=%s "
"period_recipe_order=%s new_recipe=%s",
period.id,
period.dispenser_id,
recipe.id,
link.order,
not bool(recipe_id),
)
return jsonify({"success": True, "id": recipe.id, "message": "Рецепт добавлен в период"}), 201
+838
View File
@@ -0,0 +1,838 @@
import logging
import threading
import time
from datetime import datetime
from flask import Blueprint, current_app, jsonify, request
from sqlalchemy import exists, select, update
from sqlalchemy.exc import OperationalError
from app import db
from app.models import (
Component,
FeedDispenser,
FeedingPeriod,
Ingredient,
PeriodRecipe,
Recipe,
UnloadingGroup,
)
from app.routes.auth_decorators import require_auth, require_auth_or_paired_terminal, require_paired_terminal
from app.services.daily_plan.builder import trip_overlay_for_recipe
from app.services.daily_plan.ingredient_weights import resolve_plan_ingredient_weights
from app.services.daily_plan.adjustments import get_component_adjustment_map
from app.services.daily_plan.replacements import get_ingredient_replacement_map
from app.services.daily_plan.skips import (
get_skipped_ingredient_ids,
get_skipped_unloading_group_ids,
is_kiosk_recipe_list_request,
)
from app.services.recipe_calculator import calculate_recipe
from app.services.recipe_update_service import RecipeUpdateError, update_recipe_from_payload
from app.services.sync_manager import _is_sqlite_lock_message, db_commit_with_retry, enqueue_sync_queue_task
from app.services.sync_content_hash import compute_content_hash_hex, stable_payload_for_hash
from config import Config
logger = logging.getLogger(__name__)
bp = Blueprint("recipes", __name__, url_prefix="/api/recipes")
@bp.get("/ping")
def recipes_ping():
"""Health-check для модуля рецептов."""
return jsonify({"status": "ok"}), 200
def _add_no_cache_headers(response):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
return response
def _error(message: str, status_code: int = 400):
return jsonify({"error": True, "message": message}), status_code
def _ingredient_weight_per_head(ing: dict) -> float:
"""Как в recipes.html: приходит weight_per_head или weightPerHead."""
for key in ("weightPerHead", "weight_per_head"):
if key in ing and ing[key] is not None and ing[key] != "":
try:
return float(ing[key])
except (TypeError, ValueError):
continue
return 0.0
def _ingredient_dry_matter_per_head(ing: dict):
if not ing:
return None
for key in ("dryMatterPerHead", "dry_matter_per_head"):
if key in ing and ing[key] is not None and ing[key] != "":
try:
return float(ing[key])
except (TypeError, ValueError):
return None
return None
def _group_distribution_type(group: dict) -> str:
return group.get("distributionType") or group.get("distribution_type") or "percent"
def _unloading_groups_from_payload(data: dict) -> list:
raw = data.get("unloadingGroups") or data.get("unloading_groups") or []
return [g for g in raw if isinstance(g, dict)]
def _serialize_recipe(
recipe: Recipe, *, plan_date: str | None = None, exclude_skipped: bool = False
):
from datetime import date as date_cls
apply_overlay = plan_date is not None
resolved_date = None
if apply_overlay:
try:
resolved_date = date_cls.fromisoformat(str(plan_date).strip()[:10]).isoformat()
except ValueError:
apply_overlay = False
skipped_ingredient_ids: set[str] = set()
skipped_group_ids: set[str] = set()
replacement_map: dict[str, str] = {}
component_adjustment_map: dict[str, dict] = {}
if apply_overlay and resolved_date:
skipped_ing_by_recipe = get_skipped_ingredient_ids(resolved_date)
skipped_grp_by_recipe = get_skipped_unloading_group_ids(resolved_date)
skipped_ingredient_ids = skipped_ing_by_recipe.get(recipe.id, set())
skipped_group_ids = skipped_grp_by_recipe.get(recipe.id, set())
replacement_map = get_ingredient_replacement_map(resolved_date).get(recipe.id, {})
component_adjustment_map = get_component_adjustment_map(resolved_date)
kiosk_view = is_kiosk_recipe_list_request()
hide_skipped = kiosk_view or exclude_skipped
trip_overlay = None
if apply_overlay and resolved_date:
trip_overlay = trip_overlay_for_recipe(recipe, resolved_date)
trip_ing_by_id = {
row["id"]: row for row in (trip_overlay or {}).get("ingredients") or []
}
trip_grp_by_id = {
row["id"]: row for row in (trip_overlay or {}).get("unloadingGroups") or []
}
ingredients = db.session.execute(
select(Ingredient)
.where(Ingredient.recipe_id == recipe.id, Ingredient.is_deleted.is_(False))
.order_by(Ingredient.order.asc())
).scalars().all()
comp_ids = [i.component_id for i in ingredients if i.component_id]
comp_ids.extend(replacement_map.values())
components_by_id: dict[str, Component] = {}
comp_name_by_id: dict[str, str] = {}
if comp_ids:
comps = db.session.execute(
select(Component).where(
Component.id.in_(set(comp_ids)),
Component.is_deleted.is_(False),
)
).scalars().all()
for c in comps:
components_by_id[c.id] = c
comp_name_by_id[c.id] = c.name
def _ingredient_row_name(ing: Ingredient) -> str:
raw = (ing.name or "").strip()
if raw:
base = raw
elif ing.component_id and ing.component_id in comp_name_by_id:
base = comp_name_by_id[ing.component_id]
else:
base = ""
repl_id = replacement_map.get(ing.id)
if repl_id and repl_id in comp_name_by_id:
return f"{base}{comp_name_by_id[repl_id]}"
return base
def _ingredient_payload(i: Ingredient) -> dict:
repl_id = replacement_map.get(i.id) if apply_overlay else None
heads = int(recipe.heads_per_trip or 0)
component_adj = (
component_adjustment_map.get(str(i.component_id)) if apply_overlay and i.component_id else None
)
trip_row = trip_ing_by_id.get(i.id) if apply_overlay else None
if apply_overlay:
weights = resolve_plan_ingredient_weights(
i,
recipe,
heads=heads,
components_by_id=components_by_id,
replacement_component_id=repl_id,
component_adjustment=component_adj,
)
wph = weights["weightPerHead"]
dm_pct = weights["dryMatterPct"]
dm_ph = weights["dryMatterPerHead"]
if trip_row is not None:
amount = float(trip_row.get("totalKg") or 0)
elif i.id in skipped_ingredient_ids:
amount = 0.0
else:
amount = weights["totalKg"] if wph > 0 else i.amount
else:
wph = float(i.weight_per_head or 0)
dm_pct = float(i.dry_matter or 0)
if not dm_pct and i.component_id and i.component_id in components_by_id:
dm_pct = float(components_by_id[i.component_id].dry_matter or 0)
dm_ph = float(i.dry_matter_per_head or 0)
if not dm_ph and wph > 0 and dm_pct > 0:
dm_ph = wph * (dm_pct / 100.0)
amount = i.amount
weights = {}
payload = {
"id": i.id,
"name": (
trip_row.get("name")
if trip_row and trip_row.get("name")
else (_ingredient_row_name(i) if apply_overlay else (
(i.name or "").strip()
or (comp_name_by_id.get(i.component_id) if i.component_id else "")
or ""
))
),
"weightPerHead": wph,
"weight_per_head": wph,
"amount": amount,
"dry_matter": dm_pct,
"dry_matter_per_head": dm_ph,
"order": i.order,
"component_id": (repl_id or i.component_id) if apply_overlay else i.component_id,
"original_component_id": i.component_id,
"version": i.version,
"created_at": i.created_at.isoformat() if i.created_at else None,
"updated_at": i.updated_at.isoformat() if i.updated_at else None,
"created_by": i.created_by,
"updated_by": i.updated_by,
}
if apply_overlay and not kiosk_view:
payload["replacedToday"] = bool(repl_id)
payload["adjustedToday"] = bool(weights.get("adjustedToday"))
if repl_id:
payload["recalculationMode"] = weights.get("recalculationMode")
if repl_id or weights.get("adjustedToday"):
payload["originalWeightPerHead"] = weights.get("originalWeightPerHead")
payload["originalDryMatterPerHead"] = weights.get("originalDryMatterPerHead")
payload["originalDryMatterPct"] = weights.get("originalDryMatterPct")
if i.id in skipped_ingredient_ids:
payload["skippedToday"] = True
return payload
groups = db.session.execute(
select(UnloadingGroup)
.where(UnloadingGroup.recipe_id == recipe.id, UnloadingGroup.is_deleted.is_(False))
.order_by(UnloadingGroup.order.asc())
).scalars().all()
def _group_plan_weight(group: UnloadingGroup) -> float | None:
if not apply_overlay:
return None
trip_group = trip_grp_by_id.get(group.id)
if trip_group is None:
return None
return float(trip_group.get("weightKg") or 0)
return {
"id": recipe.id,
"name": recipe.name,
"headsPerTrip": recipe.heads_per_trip,
"mixingTime": recipe.mixing_time,
"tripPercent": recipe.trip_percent,
# Синонимы в snake_case — форма /recipes и копирование рейса ожидают эти ключи
"heads_count": recipe.heads_per_trip,
"mixing_time": recipe.mixing_time,
"trip_percent": recipe.trip_percent,
"dryMatterLocked": recipe.dry_matter_locked,
"dry_matter_locked": recipe.dry_matter_locked,
"unloading_link_broken": recipe.unloading_link_broken,
"unloadingLinkBroken": recipe.unloading_link_broken,
"target_component_id": recipe.target_component_id,
"version": recipe.version,
"created_at": recipe.created_at.isoformat() if recipe.created_at else None,
"updated_at": recipe.updated_at.isoformat() if recipe.updated_at else None,
"created_by": recipe.created_by,
"updated_by": recipe.updated_by,
"ingredients": [
_ingredient_payload(i)
for i in ingredients
if not hide_skipped or i.id not in skipped_ingredient_ids
],
"unloadingGroups": [
{
"id": g.id,
"name": g.name,
"distributionType": g.distribution_type,
"value": g.value,
"weight": (_pw if (_pw := _group_plan_weight(g)) is not None else g.weight),
"order": g.order,
"version": g.version,
"created_at": g.created_at.isoformat() if g.created_at else None,
"updated_at": g.updated_at.isoformat() if g.updated_at else None,
"created_by": g.created_by,
"updated_by": g.updated_by,
**(
{"skippedToday": True}
if apply_overlay and not kiosk_view and g.id in skipped_group_ids
else {}
),
}
for g in groups
if not hide_skipped or g.id not in skipped_group_ids
],
# snake_case — copy/paste рейса и форма редактора
"unloading_groups": [
{
"id": g.id,
"name": g.name,
"distribution_type": g.distribution_type,
"distributionType": g.distribution_type,
"value": g.value,
"weight": (_pw if (_pw := _group_plan_weight(g)) is not None else g.weight),
"order": g.order,
**(
{"skippedToday": True}
if apply_overlay and not kiosk_view and g.id in skipped_group_ids
else {}
),
}
for g in groups
if not hide_skipped or g.id not in skipped_group_ids
],
**(
{
"total_weight": trip_overlay["totalWeightKg"],
"totalWeightKg": trip_overlay["totalWeightKg"],
"unloadingTotalKg": trip_overlay.get("unloadingTotalKg"),
}
if trip_overlay
else {}
),
}
@bp.get("")
@require_paired_terminal
def list_recipes():
"""Список рецептов (совместим с легаси /api/recipes)."""
try:
limit = int(request.args.get("limit", 500))
offset = int(request.args.get("offset", 0))
except ValueError:
return _error("Некорректные параметры пагинации", 400)
recipes = db.session.execute(
select(Recipe)
.where(Recipe.is_deleted.is_(False))
.offset(offset)
.limit(limit)
).scalars().all()
response = jsonify([_serialize_recipe(r) for r in recipes])
return _add_no_cache_headers(response)
@bp.get("/<string:recipe_id>/open-context")
@require_auth
def recipe_open_context(recipe_id: str):
"""Контекст для перехода к рейсу из центра уведомлений."""
recipe = db.session.get(Recipe, recipe_id)
if recipe is None or getattr(recipe, "is_deleted", False):
return _error("Рецепт не найден", 404)
pr = db.session.execute(
select(PeriodRecipe)
.where(
PeriodRecipe.recipe_id == recipe_id,
PeriodRecipe.is_deleted.is_(False),
)
.limit(1)
).scalar_one_or_none()
if pr is not None:
period = db.session.get(FeedingPeriod, pr.period_id)
if period is not None and not getattr(period, "is_deleted", False):
return jsonify(
{
"recipeId": recipe_id,
"dispenserId": period.dispenser_id,
"periodId": pr.period_id,
"isMill": False,
}
)
mill = db.session.execute(
select(FeedDispenser)
.where(
FeedDispenser.type == "mill",
FeedDispenser.is_deleted.is_(False),
)
.limit(1)
).scalar_one_or_none()
if mill is not None:
orphan = not db.session.execute(
select(
exists().where(
PeriodRecipe.recipe_id == recipe_id,
PeriodRecipe.is_deleted.is_(False),
)
)
).scalar()
if orphan:
return jsonify(
{
"recipeId": recipe_id,
"dispenserId": mill.id,
"periodId": None,
"isMill": True,
}
)
return _error("Не удалось определить расположение рецепта", 404)
@bp.get("/<string:recipe_id>")
@require_auth_or_paired_terminal
def get_recipe(recipe_id: str):
"""Получение одного рецепта по ID."""
recipe = db.session.get(Recipe, recipe_id)
if recipe is None:
return _error("Рецепт не найден", 404)
if getattr(recipe, "is_deleted", False):
return _error("Рецепт удалён", 404)
plan_date = request.args.get("date")
return jsonify(_serialize_recipe(recipe, plan_date=plan_date))
@bp.post("")
@require_auth
def create_recipe():
"""Создание рецепта без привязки к периоду (для кормоцеха)."""
data = request.get_json() or {}
name = (data.get("name") or "").strip()
if not name:
return _error("Название рецепта обязательно", 400)
recipe = Recipe(
name=name,
heads_per_trip=int(data.get("headsPerTrip", data.get("heads_count", 1))),
mixing_time=int(data.get("mixingTime", data.get("mixing_time", 0))),
trip_percent=float(data.get("tripPercent", data.get("trip_percent", 100))),
dry_matter_locked=bool(data.get("dry_matter_locked", data.get("dryMatterLocked", False))),
unloading_link_broken=bool(data.get("unloading_link_broken", False)),
target_component_id=data.get("target_component_id"),
created_by="system",
updated_by="system",
)
db.session.add(recipe)
db.session.flush()
# Ингредиенты
for idx, ing in enumerate(data.get("ingredients", []) or [], start=1):
component = None
comp_id = ing.get("component_id")
if comp_id:
component = db.session.get(Component, comp_id)
if not component and ing.get("name"):
component = db.session.execute(
select(Component).where(Component.name == ing["name"])
).scalar_one_or_none()
if not component:
return (
jsonify(
{
"error": True,
"message": (
f'Компонент не найден (id="{comp_id or ""}", '
f'name="{ing.get("name", "")}")'
),
}
),
400,
)
dmph = _ingredient_dry_matter_per_head(ing)
ingredient = Ingredient(
name=component.name,
weight_per_head=_ingredient_weight_per_head(ing),
amount=float(ing.get("amount", 0) or 0),
dry_matter=float(ing.get("dry_matter", component.dry_matter) or 0),
dry_matter_per_head=dmph,
component_id=component.id,
order=idx,
recipe_id=recipe.id,
created_by="system",
updated_by="system",
)
db.session.add(ingredient)
db.session.flush()
for idx, group in enumerate(_unloading_groups_from_payload(data), start=1):
unloading_group = UnloadingGroup(
name=group.get("name", ""),
distribution_type=_group_distribution_type(group),
value=float(group.get("value", 0) or 0),
weight=float(group["weight"]) if group.get("weight") not in (None, "") else None,
order=int(group.get("order", idx)),
recipe_id=recipe.id,
created_by="system",
updated_by="system",
)
db.session.add(unloading_group)
db.session.flush()
db.session.commit()
logger.info(
"[RECIPES-DB] create_recipe committed id=%s name=%s path=%s remote=%s",
recipe.id,
recipe.name,
request.path,
request.remote_addr,
)
return jsonify({"message": "Рецепт добавлен", "id": recipe.id}), 201
@bp.put("/<string:recipe_id>")
@require_auth
def update_recipe(recipe_id: str):
"""Обновление рецепта (логика как legacy/proga_monolith.update_recipe)."""
data = request.get_json()
try:
result = update_recipe_from_payload(recipe_id, data)
db.session.commit()
logger.info(
"[RECIPES-DB] update_recipe committed id=%s path=%s remote=%s",
recipe_id,
request.path,
request.remote_addr,
)
except RecipeUpdateError as e:
db.session.rollback()
return _error(e.message, e.status_code)
except Exception as e:
db.session.rollback()
logger.exception("Ошибка при обновлении рецепта %s", recipe_id)
return _error(str(e), 500)
return jsonify(
{
"success": True,
"message": result["message"],
"id": result["id"],
"stats": result["stats"],
}
)
@bp.delete("/<string:recipe_id>")
@require_auth
def delete_recipe(recipe_id: str):
"""Мягкое удаление рецепта (soft-delete, каскад делает событие)."""
recipe = db.session.get(Recipe, recipe_id)
if recipe is None:
return _error("Рецепт не найден", 404)
if getattr(recipe, "is_deleted", False):
return jsonify({"success": True, "message": "Рецепт уже удалён"})
rid = recipe.id
if hasattr(recipe, "soft_delete"):
recipe.soft_delete(deleted_by_user="api")
else:
db.session.delete(recipe)
enqueue_sync_queue_task("recipe", rid, "delete", priority=1)
db.session.commit()
logger.info(
"[RECIPES-DB] delete_recipe committed id=%s path=%s remote=%s",
rid,
request.path,
request.remote_addr,
)
return jsonify({"success": True, "message": "Рецепт удалён"})
@bp.post("/calculate")
@require_auth
def calculate_recipe_endpoint():
"""Как legacy/proga_monolith.calculate_recipe_endpoint: те же ключи тела и нормализация."""
data = request.get_json()
if not data:
return _error("Данные не предоставлены", 400)
try:
heads_count = int(
data.get("headsCount")
or data.get("heads_count")
or data.get("headsPerTrip")
or 0
)
trip_percent = float(
data.get("tripPercent") or data.get("trip_percent") or 100
)
except (TypeError, ValueError):
return _error("Некорректные числовые параметры", 400)
ingredients = data.get("ingredients", []) or []
unloading_groups = data.get("unloadingGroups") or data.get("unloading_groups") or []
calculate_from_dry_matter = bool(
data.get("calculateFromDryMatter")
if data.get("calculateFromDryMatter") is not None
else data.get("calculate_from_dry_matter", False)
)
normalized_ingredients = []
for ing in ingredients:
if not isinstance(ing, dict):
continue
ing = dict(ing)
if "component_id" not in ing and "componentId" in ing:
ing["component_id"] = ing.get("componentId")
if "dryMatterPerHead" not in ing and "dry_matter_per_head" in ing:
ing["dryMatterPerHead"] = ing.get("dry_matter_per_head")
if "dryMatter" not in ing and "dry_matter" in ing:
ing["dryMatter"] = ing.get("dry_matter")
if "weightPerHead" not in ing and "weight_per_head" in ing:
ing["weightPerHead"] = ing.get("weight_per_head")
normalized_ingredients.append(ing)
ingredients = normalized_ingredients
normalized_groups = []
for g in unloading_groups:
if not isinstance(g, dict):
continue
g = dict(g)
if "distributionType" not in g and "distribution_type" in g:
g["distributionType"] = g.get("distribution_type")
normalized_groups.append(g)
unloading_groups = normalized_groups
component_ids = [i.get("component_id") for i in ingredients if i.get("component_id")]
component_dry_matter_map = {}
if component_ids:
comps = db.session.execute(
select(Component).where(Component.id.in_(component_ids))
).scalars().all()
component_dry_matter_map = {c.id: c.dry_matter for c in comps}
result = calculate_recipe(
ingredients=ingredients,
heads_count=heads_count,
trip_percent=trip_percent,
unloading_groups=unloading_groups,
component_dry_matter_map=component_dry_matter_map or None,
calculate_from_dry_matter=calculate_from_dry_matter,
)
def to_float2(x):
try:
return round(float(x), 2)
except (TypeError, ValueError):
return 0.0
def truncate2(x):
try:
v = float(x)
return float(int(v * 100)) / 100.0
except (TypeError, ValueError):
return 0.0
if result.get("ingredients"):
for ing in result["ingredients"]:
for key in ("weightPerHead", "tripWeight", "totalWeight", "dryMatterPerHead"):
if key in ing and ing[key] is not None:
raw_val = ing[key]
v = truncate2(raw_val) if key == "weightPerHead" else to_float2(raw_val)
ing[key] = f"{v:.2f}" if key == "weightPerHead" else v
if result.get("totals"):
for key in (
"totalWeight",
"totalTripWeight",
"totalDryMatterPerHead",
"totalWeightPerHead",
):
if key in result["totals"] and result["totals"][key] is not None:
result["totals"][key] = to_float2(result["totals"][key])
return jsonify(result)
def _period_recipe_order_content_hash(period_id: str, recipe_id: str, order: int) -> str:
return compute_content_hash_hex(
stable_payload_for_hash(
{
"period_id": period_id,
"recipe_id": recipe_id,
"order": order,
}
)
)
def _resolve_period_recipe_move(rows, recipe_id: str, req_from_index: int, req_to_index: int):
"""rows: list of (recipe_id, order). Returns (from_index, to_index) or (None, None) if not found."""
if not rows:
return None, None
from_index = req_from_index
if from_index < 0 or from_index >= len(rows):
from_index = next((i for i, row in enumerate(rows) if row[0] == recipe_id), -1)
elif rows[from_index][0] != recipe_id:
from_index = next((i for i, row in enumerate(rows) if row[0] == recipe_id), -1)
if from_index < 0:
return None, None
to_index = max(0, min(int(req_to_index), len(rows) - 1))
return from_index, to_index
def _period_recipe_order_updates(rows, from_index: int, to_index: int):
"""Return [(recipe_id, new_order), ...] only for rows whose order changes."""
recipe_ids = [row[0] for row in rows]
current_order = {row[0]: row[1] for row in rows}
moved = recipe_ids.pop(from_index)
recipe_ids.insert(to_index, moved)
updates = []
for idx, rid in enumerate(recipe_ids, start=1):
if current_order.get(rid) != idx:
updates.append((rid, idx))
return updates
def _load_period_recipe_order_rows(period_id: str):
return db.session.execute(
select(PeriodRecipe.recipe_id, PeriodRecipe.order)
.where(PeriodRecipe.period_id == period_id, PeriodRecipe.is_deleted.is_(False))
.order_by(PeriodRecipe.order.asc(), PeriodRecipe.created_at.asc())
).all()
def _commit_period_recipe_reorder(period_id: str, order_updates):
"""Отдельное короткое соединение: не конкурирует с сессией sync pull."""
now = datetime.now()
attempts = int(getattr(Config, "RECIPE_REORDER_COMMIT_ATTEMPTS", 12))
base_delay = float(getattr(Config, "RECIPE_REORDER_COMMIT_BASE_DELAY", 0.12))
for attempt in range(attempts):
try:
with db.engine.begin() as conn:
for recipe_id, new_order in order_updates:
conn.execute(
update(PeriodRecipe)
.where(
PeriodRecipe.period_id == period_id,
PeriodRecipe.recipe_id == recipe_id,
)
.values(
order=new_order,
content_hash=_period_recipe_order_content_hash(period_id, recipe_id, new_order),
updated_by="system",
updated_at=now,
)
)
db.session.rollback()
return
except OperationalError as exc:
db.session.rollback()
if _is_sqlite_lock_message(exc) and attempt < attempts - 1:
logger.warning(
"move_recipe_in_period: SQLite locked, retry %s/%s period=%s",
attempt + 1,
attempts,
period_id,
)
time.sleep(base_delay * (2 ** min(attempt, 5)))
continue
raise
def _enqueue_period_recipe_reorder_sync(period_id: str, order_updates) -> None:
db.session.rollback()
for recipe_id, _ in order_updates:
enqueue_sync_queue_task(
"period_recipes",
f"{period_id}:{recipe_id}",
"update",
priority=4,
)
db_commit_with_retry()
def _defer_period_recipe_reorder_sync(app, period_id: str, order_updates) -> None:
"""Sync queue — после ответа клиенту, чтобы не держать lock вместе с pull."""
def _worker() -> None:
with app.app_context():
try:
_enqueue_period_recipe_reorder_sync(period_id, order_updates)
except Exception:
logger.exception(
"move_recipe_in_period: deferred sync enqueue failed period=%s",
period_id,
)
threading.Thread(
target=_worker,
name=f"recipe-reorder-sync-{period_id[:8]}",
daemon=True,
).start()
@bp.put("/<string:recipe_id>/move")
@require_auth
def move_recipe_in_period(recipe_id: str):
"""Legacy-compatible reorder endpoint for recipes list in a period."""
data = request.get_json() or {}
period_id = data.get("period_id")
if not period_id:
return jsonify({"error": True, "message": "period_id обязателен"}), 400
try:
req_from_index = int(data.get("from_index"))
req_to_index = int(data.get("to_index"))
except (TypeError, ValueError):
return jsonify({"error": True, "message": "from_index/to_index должны быть числами"}), 400
try:
db.session.rollback()
rows = _load_period_recipe_order_rows(period_id)
if not rows:
return jsonify({"error": True, "message": "Рецепты периода не найдены"}), 404
from_index, to_index = _resolve_period_recipe_move(rows, recipe_id, req_from_index, req_to_index)
if from_index is None:
return jsonify({"error": True, "message": "Рецепт не найден в периоде"}), 404
if from_index == to_index:
return jsonify({"success": True, "message": "Порядок рейсов без изменений"}), 200
order_updates = _period_recipe_order_updates(rows, from_index, to_index)
if not order_updates:
return jsonify({"success": True, "message": "Порядок рейсов без изменений"}), 200
_commit_period_recipe_reorder(period_id, order_updates)
except OperationalError as exc:
db.session.rollback()
if _is_sqlite_lock_message(exc):
logger.warning(
"move_recipe_in_period: SQLite locked period=%s recipe=%s",
period_id,
recipe_id,
)
return jsonify({"error": True, "message": "База занята, повторите через секунду"}), 503
raise
_defer_period_recipe_reorder_sync(current_app._get_current_object(), period_id, order_updates)
return jsonify({"success": True, "message": "Порядок рейсов обновлён"})
+256
View File
@@ -0,0 +1,256 @@
from datetime import datetime, timedelta
from flask import Blueprint, jsonify, request
from sqlalchemy import select
from app import db
from app.models import (
ComponentLoadingTime,
LoadingReport,
LoadingReportComponent,
UnloadingReport,
UnloadingReportGroup,
)
from app.routes.auth_decorators import require_auth
bp = Blueprint("reports", __name__, url_prefix="/api/reports")
def _error(message: str, status_code: int = 400):
return jsonify({"error": True, "message": message}), status_code
def _parse_date_range():
date_from = (request.args.get("date_from") or "").strip()
date_to = (request.args.get("date_to") or "").strip()
if not date_from or not date_to:
# Legacy fallback: last 24h when explicit range is missing.
end = datetime.now()
return end - timedelta(hours=24), end + timedelta(seconds=1), None
try:
start = datetime.strptime(date_from, "%Y-%m-%d")
end = datetime.strptime(date_to, "%Y-%m-%d") + timedelta(days=1)
return start, end, None
except ValueError:
return None, None, _error("Некорректный формат date_from/date_to (YYYY-MM-DD)", 400)
@bp.get("/ping")
def reports_ping():
"""Временный health-check эндпоинт для модуля отчетов."""
return jsonify({"status": "ok"}), 200
@bp.get("/loading")
@require_auth
def list_loading_reports():
"""Список отчётов загрузки с пагинацией."""
try:
limit = int(request.args.get("limit", 100))
offset = int(request.args.get("offset", 0))
except ValueError:
return _error("Некорректные параметры пагинации", 400)
reports = db.session.execute(
select(LoadingReport)
.where(LoadingReport.is_deleted.is_(False))
.order_by(LoadingReport.start_time.desc())
.offset(offset)
.limit(limit)
).scalars().all()
return jsonify(
[
{
"id": r.id,
"recipe_id": r.recipe_id,
"recipe_name": r.recipe_name,
"start_time": r.start_time.isoformat() if r.start_time else None,
"end_time": r.end_time.isoformat() if r.end_time else None,
"total_weight": r.total_weight,
"version": r.version,
}
for r in reports
]
)
@bp.get("")
@require_auth
def list_reports_legacy():
"""Legacy-compatible aggregated reports endpoint used by reports UI."""
start, end, range_error = _parse_date_range()
if range_error:
return range_error
try:
limit = int(request.args.get("limit", 500))
offset = int(request.args.get("offset", 0))
except ValueError:
return _error("Некорректные параметры пагинации", 400)
reports = db.session.execute(
select(LoadingReport)
.where(
LoadingReport.is_deleted.is_(False),
LoadingReport.start_time >= start,
LoadingReport.start_time < end,
)
.order_by(LoadingReport.start_time.desc())
.offset(offset)
.limit(limit)
).scalars().all()
if not reports:
return jsonify([])
report_ids = [r.id for r in reports]
components = db.session.execute(
select(LoadingReportComponent)
.where(
LoadingReportComponent.report_id.in_(report_ids),
LoadingReportComponent.is_deleted.is_(False),
)
.order_by(LoadingReportComponent.loading_order.asc())
).scalars().all()
loading_times = db.session.execute(
select(ComponentLoadingTime)
.where(
ComponentLoadingTime.report_id.in_(report_ids),
ComponentLoadingTime.is_deleted.is_(False),
)
.order_by(ComponentLoadingTime.loading_order.asc())
).scalars().all()
unloading_rows = db.session.execute(
select(UnloadingReport)
.where(
UnloadingReport.loading_report_id.in_(report_ids),
UnloadingReport.is_deleted.is_(False),
)
.order_by(UnloadingReport.start_time.desc())
).scalars().all()
unloading_by_loading_id = {u.loading_report_id: u for u in unloading_rows}
unloading_ids = [u.id for u in unloading_rows]
unloading_groups = (
db.session.execute(
select(UnloadingReportGroup)
.where(
UnloadingReportGroup.report_id.in_(unloading_ids),
UnloadingReportGroup.is_deleted.is_(False),
)
.order_by(UnloadingReportGroup.order.asc())
).scalars().all()
if unloading_ids
else []
)
components_map = {}
for c in components:
components_map.setdefault(c.report_id, []).append(
{
"id": c.id,
"component_id": c.component_id,
"component_name": c.component_name,
"target_weight": c.target_weight,
"actual_weight": c.actual_weight,
"overload": c.overload,
"loading_order": c.loading_order,
}
)
loading_times_map = {}
for t in loading_times:
loading_times_map.setdefault(t.report_id, []).append(
{
"id": t.id,
"component_name": t.component_name,
"start_time": t.start_time.isoformat() if t.start_time else None,
"end_time": t.end_time.isoformat() if t.end_time else None,
"loading_duration": t.loading_duration,
"loading_order": t.loading_order,
}
)
unloading_groups_map = {}
for g in unloading_groups:
unloading_groups_map.setdefault(g.report_id, []).append(
{
"id": g.id,
"name": g.name,
"target_weight": g.target_weight,
"unloaded_weight": g.unloaded_weight,
"remaining_weight": g.remaining_weight,
"distribution_type": g.distribution_type,
"distribution_value": g.distribution_value,
"order": g.order,
}
)
payload = []
for r in reports:
unloading = unloading_by_loading_id.get(r.id)
unloading_payload = None
if unloading:
unloading_payload = {
"id": unloading.id,
"start_time": unloading.start_time.isoformat() if unloading.start_time else None,
"end_time": unloading.end_time.isoformat() if unloading.end_time else None,
"total_weight": unloading.total_weight,
"total_unloaded_weight": unloading.total_unloaded_weight,
"remaining_weight": unloading.remaining_weight,
"unloading_groups": unloading_groups_map.get(unloading.id, []),
}
payload.append(
{
"id": r.id,
"recipe_id": r.recipe_id,
"recipe_name": r.recipe_name,
"start_time": r.start_time.isoformat() if r.start_time else None,
"end_time": r.end_time.isoformat() if r.end_time else None,
"target_mixing_time": r.target_mixing_time,
"actual_mixing_time": r.actual_mixing_time,
"total_weight": r.total_weight,
"dispenser_type": r.dispenser_type,
"components": components_map.get(r.id, []),
"component_loading_times": loading_times_map.get(r.id, []),
"unloading_data": unloading_payload,
}
)
return jsonify(payload)
@bp.get("/unloading")
@require_auth
def list_unloading_reports():
"""Список отчётов выгрузки с пагинацией."""
try:
limit = int(request.args.get("limit", 100))
offset = int(request.args.get("offset", 0))
except ValueError:
return _error("Некорректные параметры пагинации", 400)
reports = db.session.execute(
select(UnloadingReport)
.where(UnloadingReport.is_deleted.is_(False))
.order_by(UnloadingReport.start_time.desc())
.offset(offset)
.limit(limit)
).scalars().all()
return jsonify(
[
{
"id": r.id,
"recipe_id": r.recipe_id,
"recipe_name": r.recipe_name,
"start_time": r.start_time.isoformat() if r.start_time else None,
"end_time": r.end_time.isoformat() if r.end_time else None,
"total_weight": r.total_weight,
"total_unloaded_weight": r.total_unloaded_weight,
"remaining_weight": r.remaining_weight,
"version": r.version,
}
for r in reports
]
)
+317
View File
@@ -0,0 +1,317 @@
import logging
from datetime import datetime
from flask import Blueprint, jsonify, request
logger = logging.getLogger(__name__)
from sqlalchemy import func, select
from app import db
from app.models import (
ComponentLoadingTime,
LoadingReport,
LoadingReportComponent,
Recipe,
UnloadingReport,
UnloadingReportGroup,
)
from app.routes.auth_decorators import require_auth, require_paired_terminal
bp = Blueprint("reports_legacy", __name__, url_prefix="/api")
def _error(message: str, status_code: int = 400):
return jsonify({"error": True, "message": message}), status_code
@bp.post("/save_report")
@require_paired_terminal
def save_report_legacy():
data = request.get_json() or {}
recipe_id = data.get("recipe_id")
if not recipe_id:
return _error("Не указан ID рецепта", 400)
recipe = db.session.execute(
select(Recipe).where(Recipe.id == recipe_id, Recipe.is_deleted.is_(False))
).scalar_one_or_none()
if not recipe:
return _error("Рецепт не найден", 404)
try:
total_weight = float(data.get("total_weight", 0))
target_mixing_time = int(data.get("target_mixing_time", 0))
actual_mixing_time = int(data.get("actual_mixing_time", 0))
except (TypeError, ValueError):
return _error("Некорректные числовые поля отчёта", 400)
components = data.get("components", []) or []
if not components:
return _error("Нет данных о компонентах", 400)
report = LoadingReport(
recipe_id=recipe_id,
recipe_name=recipe.name,
start_time=datetime.now(),
end_time=datetime.now(),
target_mixing_time=target_mixing_time,
actual_mixing_time=actual_mixing_time,
total_weight=total_weight,
dispenser_type=(data.get("dispenser_type") or "dispenser"),
created_by="system",
updated_by="system",
)
db.session.add(report)
db.session.flush()
for idx, comp in enumerate(components, start=1):
db.session.add(
LoadingReportComponent(
report_id=report.id,
component_id=comp.get("component_id") or comp.get("componentId"),
component_name=str(comp.get("name") or comp.get("component_name") or ""),
target_weight=float(comp.get("target_weight", 0) or 0),
actual_weight=float(comp.get("actual_weight", 0) or 0),
overload=float(comp.get("overload", 0) or 0),
loading_order=idx,
created_by="system",
updated_by="system",
)
)
for item in data.get("component_loading_times", []) or []:
try:
start_time = datetime.fromisoformat(item["start_time"])
end_time = datetime.fromisoformat(item["end_time"])
except Exception:
continue
db.session.add(
ComponentLoadingTime(
report_id=report.id,
component_name=str(item.get("component_name") or ""),
start_time=start_time,
end_time=end_time,
loading_duration=float(item.get("loading_duration", 0) or 0),
loading_order=int(item.get("loading_order", 0) or 0),
created_by="system",
updated_by="system",
)
)
db.session.commit()
try:
from app.services.feed_quality.evaluator import evaluate_loading_report
evaluate_loading_report(report.id, send_notifications=True)
except Exception:
logger.exception("[FEED-QUALITY] save_report evaluate failed report_id=%s", report.id)
return jsonify({"status": "success", "message": "Отчёт успешно сохранён", "report_id": report.id})
@bp.get("/consumption_by_component")
@require_auth
def consumption_by_component():
rows = db.session.execute(
select(
LoadingReportComponent.component_id,
LoadingReportComponent.component_name,
func.coalesce(
func.sum(
LoadingReportComponent.actual_weight
+ func.coalesce(LoadingReportComponent.overload, 0)
),
0,
).label("total_actual_weight"),
func.count(LoadingReportComponent.id).label("report_count"),
)
.join(LoadingReport, LoadingReport.id == LoadingReportComponent.report_id)
.where(LoadingReportComponent.is_deleted.is_(False))
.where(LoadingReport.is_deleted.is_(False))
.group_by(
LoadingReportComponent.component_id,
LoadingReportComponent.component_name,
)
).all()
return jsonify(
[
{
"component_id": r.component_id,
"component_name": r.component_name or "",
"total_actual_weight": round(float(r.total_actual_weight or 0), 2),
"report_count": int(r.report_count or 0),
}
for r in rows
]
)
@bp.get("/reports/<string:report_id>/loading_times")
@require_auth
def report_loading_times(report_id: str):
report = db.session.execute(
select(LoadingReport).where(LoadingReport.id == report_id, LoadingReport.is_deleted.is_(False))
).scalar_one_or_none()
if not report:
return _error("Отчёт не найден", 404)
rows = db.session.execute(
select(ComponentLoadingTime)
.where(
ComponentLoadingTime.report_id == report_id,
ComponentLoadingTime.is_deleted.is_(False),
)
.order_by(ComponentLoadingTime.loading_order.asc())
).scalars().all()
return jsonify(
{
"status": "success",
"loading_times": [
{
"component_name": lt.component_name,
"start_time": lt.start_time.isoformat() if lt.start_time else None,
"end_time": lt.end_time.isoformat() if lt.end_time else None,
"loading_duration": float(lt.loading_duration or 0),
"loading_order": lt.loading_order,
}
for lt in rows
],
}
)
@bp.post("/save_unloading_report")
@require_paired_terminal
def save_unloading_report_legacy():
data = request.get_json() or {}
loading_report_id = data.get("loading_report_id")
recipe_id = data.get("recipe_id")
if not loading_report_id:
return _error("Не указан ID отчёта о загрузке", 400)
if not recipe_id:
return _error("Не указан ID рецепта", 400)
recipe = db.session.execute(
select(Recipe).where(Recipe.id == recipe_id, Recipe.is_deleted.is_(False))
).scalar_one_or_none()
if not recipe:
return _error("Рецепт не найден", 404)
report = UnloadingReport(
recipe_id=recipe_id,
recipe_name=recipe.name,
loading_report_id=loading_report_id,
start_time=datetime.now(),
end_time=datetime.now(),
total_weight=float(data.get("total_weight", 0) or 0),
total_unloaded_weight=float(data.get("total_unloaded_weight", 0) or 0),
remaining_weight=float(data.get("remaining_weight", 0) or 0),
created_by="system",
updated_by="system",
)
db.session.add(report)
db.session.flush()
for group in data.get("unloading_groups", []) or []:
db.session.add(
UnloadingReportGroup(
report_id=report.id,
name=str(group.get("name") or ""),
target_weight=float(group.get("target_weight", 0) or 0),
unloaded_weight=float(group.get("unloaded_weight", 0) or 0),
remaining_weight=float(group.get("remaining_weight", 0) or 0),
distribution_type=str(group.get("distribution_type") or "percent"),
distribution_value=float(group.get("distribution_value", 0) or 0),
order=int(group.get("order", 0) or 0),
created_by="system",
updated_by="system",
)
)
db.session.commit()
try:
from app.services.feed_quality.evaluator import evaluate_unloading_report
evaluate_unloading_report(report.id, send_notifications=True)
except Exception:
logger.exception(
"[FEED-QUALITY] save_unloading_report evaluate failed unloading_id=%s", report.id
)
return jsonify(
{
"status": "success",
"message": "Отчёт о выгрузке сохранён",
"unloading_report_id": report.id,
}
)
@bp.get("/unloading_reports")
@require_auth
def unloading_reports_legacy():
rows = db.session.execute(
select(UnloadingReport)
.where(UnloadingReport.is_deleted.is_(False))
.order_by(UnloadingReport.start_time.desc())
).scalars().all()
return jsonify(
[
{
"id": r.id,
"recipe_id": r.recipe_id,
"recipe_name": r.recipe_name,
"loading_report_id": r.loading_report_id,
"start_time": r.start_time.isoformat() if r.start_time else None,
"end_time": r.end_time.isoformat() if r.end_time else None,
"total_weight": r.total_weight,
"total_unloaded_weight": r.total_unloaded_weight,
"remaining_weight": r.remaining_weight,
"unloading_groups": [
{
"name": g.name,
"target_weight": g.target_weight,
"unloaded_weight": g.unloaded_weight,
"remaining_weight": g.remaining_weight,
"distribution_type": g.distribution_type,
"distribution_value": g.distribution_value,
"order": g.order,
}
for g in sorted(r.groups, key=lambda x: x.order)
if not g.is_deleted
],
}
for r in rows
]
)
@bp.get("/reports/<string:report_id>/unloading_groups")
@require_auth
def report_unloading_groups(report_id: str):
report = db.session.execute(
select(UnloadingReport).where(
UnloadingReport.id == report_id, UnloadingReport.is_deleted.is_(False)
)
).scalar_one_or_none()
if not report:
return _error("Отчёт выгрузки не найден", 404)
return jsonify(
{
"status": "success",
"recipe_name": report.recipe_name,
"total_weight": report.total_weight,
"total_unloaded_weight": report.total_unloaded_weight,
"remaining_weight": report.remaining_weight,
"unloading_groups": [
{
"name": g.name,
"target_weight": g.target_weight,
"unloaded_weight": g.unloaded_weight,
"remaining_weight": g.remaining_weight,
"distribution_type": g.distribution_type,
"distribution_value": g.distribution_value,
"order": g.order,
}
for g in sorted(report.groups, key=lambda x: x.order)
if not g.is_deleted
],
}
)
+152
View File
@@ -0,0 +1,152 @@
import time
import json
import threading
from flask import Blueprint, Response, current_app, jsonify, request
from app.routes.auth_decorators import require_paired_terminal
from app.services.hardware import ScalesReader
bp = Blueprint("scales", __name__)
_scales_reader = None
_calibration_state = {"target_weight": None, "initial_raw": None}
_calibration_lock = threading.Lock()
def _error(message: str, status_code: int = 400):
return jsonify({"error": True, "status": "error", "message": message}), status_code
def _get_reader() -> ScalesReader:
global _scales_reader
if _scales_reader is None:
_scales_reader = ScalesReader.from_config(current_app.config)
_scales_reader.bind_flask_app(current_app._get_current_object())
_scales_reader.start()
return _scales_reader
@bp.get("/current_weight")
@require_paired_terminal
def current_weight():
reader = _get_reader()
payload = {"weight": reader.get_current_weight(), **reader.get_scale_health()}
return jsonify(payload), 200
@bp.post("/tare")
@require_paired_terminal
def tare_weight():
reader = _get_reader()
try:
tare_value = reader.tare()
except ValueError as e:
return jsonify({"status": "error", "message": str(e)}), 400
return jsonify(
{
"status": "success",
"message": "Новый ноль сохранён",
"new_zero": round(float(tare_value), 4),
"success": True,
}
)
@bp.post("/calibrate")
@require_paired_terminal
def calibrate_weight():
data = request.get_json(silent=True) or {}
reader = _get_reader()
if data.get("target_weight") is not None:
try:
target_weight = float(data.get("target_weight"))
except (TypeError, ValueError):
return _error("target_weight должен быть числом", 400)
if target_weight <= 0:
return _error("target_weight должен быть больше 0", 400)
try:
initial_raw = reader.average_recent_raw(
int(current_app.config.get("TARE_RAW_WINDOW", 2))
)
except ValueError as e:
return _error(str(e), 400)
with _calibration_lock:
_calibration_state["target_weight"] = target_weight
_calibration_state["initial_raw"] = initial_raw
return jsonify(
{
"status": "confirm",
"message": f"Поместите груз {target_weight}кг и подтвердите",
}
)
raw_factor = data.get("calibration_factor")
if raw_factor is None:
return _error("Нужно указать calibration_factor или target_weight", 400)
try:
factor = float(raw_factor)
except (TypeError, ValueError):
return _error("calibration_factor должен быть числом", 400)
try:
applied = reader.calibrate(factor)
except ValueError as e:
return _error(str(e), 400)
return jsonify({"status": "success", "success": True, "calibration_factor": applied})
@bp.post("/calibrate/continue")
@require_paired_terminal
def calibrate_continue():
reader = _get_reader()
with _calibration_lock:
target_weight = _calibration_state.get("target_weight")
initial_raw = _calibration_state.get("initial_raw")
if not target_weight or initial_raw is None:
return _error("Калибровка не начата", 400)
try:
final_raw = reader.average_recent_raw(int(current_app.config.get("TARE_RAW_WINDOW", 2)))
counts_per_kg = (final_raw - float(initial_raw)) / float(target_weight)
applied = reader.set_counts_per_kg(float(counts_per_kg))
except ValueError as e:
return _error(str(e), 400)
with _calibration_lock:
_calibration_state["target_weight"] = None
_calibration_state["initial_raw"] = None
return jsonify(
{
"status": "success",
"message": "Калибровка завершена!",
"calibration_factor": round(float(applied), 6),
}
)
@bp.get("/current_raw_data")
@require_paired_terminal
def current_raw_data():
reader = _get_reader()
return jsonify(reader.calibration_debug_payload())
@bp.get("/stream_weight")
@require_paired_terminal
def stream_weight():
reader = _get_reader()
# Интервал нужно взять здесь: внутри генератора контекст приложения уже снят.
read_interval = float(current_app.config.get("READ_INTERVAL", 0.05))
def _events():
while True:
payload = {"weight": reader.get_current_weight(), **reader.get_scale_health()}
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
time.sleep(read_interval)
return Response(_events(), mimetype="text/event-stream")
+781
View File
@@ -0,0 +1,781 @@
"""Setup wizard API (/api/setup/*)."""
from __future__ import annotations
import logging
import platform
import threading
import uuid
from typing import Any, Dict
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from flask import Blueprint, current_app, jsonify, make_response, request
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from werkzeug.security import generate_password_hash
from app import db
from app.kiosk_device_cookie import set_kiosk_device_cookie
from app.models import KioskDevice, WebUser
from app.routes.auth import _ensure_default_superuser, load_credentials
from app.routes.admin import (
_WEB_USER_PASSWORD_MIN_LEN,
_effective_sync_connection,
_reject_if_weak_login_or_password,
_valid_login,
)
from app.routes.auth_decorators import _is_localhost_request, is_superuser_session, require_superuser
from app.services.admin_dashboard_service import get_install_and_warranty
from app.services.admin_peripheral_monitor import build_hardware_status
from app.services.hardware_settings_service import (
is_scale_hardware_platform,
setup_complete_redirect,
)
from app.services.lan_network import parse_wesp_listen
from app.services.network_settings_service import build_public_url_from_hostname, network_settings_snapshot
from app.services.setup_state import (
is_setup_completed,
mark_setup_complete,
mark_step_completed,
read_install_state,
reset_setup,
setup_snapshot,
write_install_state,
)
from app.services.mdns_service import reload_mdns
from config import (
DEFAULT_NETWORK_LOCAL_HOSTNAME,
apply_network_settings_to_app,
coerce_security_bool,
normalize_local_hostname,
read_sync_client_state,
validate_local_hostname,
write_network_settings,
write_sync_client_state,
)
bp = Blueprint("setup", __name__, url_prefix="/api/setup")
logger = logging.getLogger(__name__)
_USER_CREATE_ATTEMPTS: Dict[str, int] = {}
_USER_CREATE_LIMIT = 20
def _setup_connection_snapshot() -> dict:
conn = _effective_sync_connection(current_app)
state = read_sync_client_state()
return {
**conn,
"client_id": state.get("client_id"),
"client_name": state.get("client_name"),
}
def _default_setup_client_name() -> str:
snap = network_settings_snapshot(current_app)
host = normalize_local_hostname(str(snap.get("local_hostname") or ""))
default_host = normalize_local_hostname(DEFAULT_NETWORK_LOCAL_HOSTNAME)
if host and host != default_host:
return host.replace(".local", "")[:200]
os_h = str((snap.get("detected") or {}).get("os_hostname") or "").strip().lower()
os_h = os_h.replace(" ", "_")[:200]
return os_h or "vesy_1"
def _setup_blocked_response():
return jsonify({"status": "error", "message": "Первоначальная настройка уже завершена."}), 403
def _localhost_required_response():
return jsonify(
{
"status": "error",
"message": "Настройка доступна только с localhost или для суперпользователя.",
}
), 403
def _is_setup_local_request() -> bool:
if bool(current_app.config.get("TESTING")):
return True
return _is_localhost_request()
def _require_setup_access(*, allow_completed: bool = False):
if not allow_completed and is_setup_completed(current_app):
return _setup_blocked_response()
if is_superuser_session():
return None
if not is_setup_completed(current_app):
# Wizard is open to the LAN until first-time setup finishes (see pages guard).
return None
if not _is_setup_local_request():
return _localhost_required_response()
return None
def _require_setup_mutation():
return _require_setup_access(allow_completed=False)
def _rate_limit_user_create() -> tuple[dict | None, int]:
key = request.remote_addr or "unknown"
count = _USER_CREATE_ATTEMPTS.get(key, 0) + 1
_USER_CREATE_ATTEMPTS[key] = count
if count > _USER_CREATE_LIMIT:
return {"status": "error", "message": "Слишком много попыток создания пользователей."}, 429
return None, 0
def _validate_user_fields(login: str, password: str, confirm: str, label: str) -> tuple[dict | None, int]:
login = (login or "").strip()
password = password or ""
confirm = confirm or ""
if not login:
return {"status": "error", "message": f"{label}: укажите логин."}, 400
if not _valid_login(login):
return {
"status": "error",
"message": f"{label}: логин не длиннее 64 символов.",
}, 400
if not password:
return {"status": "error", "message": f"{label}: укажите пароль."}, 400
if password != confirm:
return {"status": "error", "message": f"{label}: пароли не совпадают."}, 400
if len(password) < _WEB_USER_PASSWORD_MIN_LEN:
return {
"status": "error",
"message": f"{label}: пароль не короче {_WEB_USER_PASSWORD_MIN_LEN} символов.",
}, 400
err_body, err_code = _reject_if_weak_login_or_password(login, password)
if err_body is not None:
err_body["message"] = f"{label}: {err_body.get('message', 'слабый пароль')}"
return err_body, err_code
return None, 0
def _hardware_platform_snapshot() -> dict:
machine = platform.machine() or "unknown"
system = platform.system() or "unknown"
arm = is_scale_hardware_platform()
return {
"scale_hardware_platform": arm,
"machine": machine,
"system": system,
"label": f"{machine} ({system})",
"message": (
"Платформа ARM — можно подключить весы HX711 к Raspberry Pi."
if arm
else "Весы не найдены"
),
}
def _ping_sync_server_once(server_url: str, *, timeout: float = 5.0) -> tuple[bool, str]:
url = str(server_url or "").strip().rstrip("/")
if not url:
return False, "Укажите адрес сервера."
ping_url = f"{url}/api/sync/ping"
try:
req = Request(ping_url, method="GET", headers={"Accept": "application/json"})
with urlopen(req, timeout=timeout) as resp:
resp.read()
return True, ""
except HTTPError as exc:
if exc.code in (401, 403, 404, 405):
return True, ""
return False, f"Сервер ответил с ошибкой HTTP {exc.code}."
except URLError as exc:
reason = str(exc.reason or exc)
low = reason.lower()
if "timed out" in low or "timeout" in low:
return False, "Сервер не отвечает. Проверьте адрес и что главный сервер включён в сети."
if "name or service not known" in low or "nodename nor servname" in low:
return False, "Сервер не найден. Проверьте имя (например komton_srv_1.local) или IP."
return False, f"Не удалось подключиться: {reason}."
except Exception as exc:
return False, f"Не удалось подключиться: {exc}"
def _ping_sync_server(server_url: str, *, timeout: float = 5.0) -> tuple[bool, str]:
from app.services.sync_url_resolve import first_reachable_sync_url
ok, err, _effective = first_reachable_sync_url(
server_url,
ping_fn=lambda u: _ping_sync_server_once(u, timeout=timeout),
)
return ok, err
def _ping_sync_server_with_effective(
server_url: str, *, timeout: float = 5.0
) -> tuple[bool, str, str]:
from app.services.sync_url_resolve import first_reachable_sync_url
return first_reachable_sync_url(
server_url,
ping_fn=lambda u: _ping_sync_server_once(u, timeout=timeout),
)
def _restart_local_sync_if_needed(*, async_start: bool = False) -> bool:
if current_app.config.get("TESTING"):
return False
try:
from sync_client import apply_sync_client_runtime
app = current_app._get_current_object()
if async_start:
def _run() -> None:
with app.app_context():
try:
apply_sync_client_runtime(app)
except Exception:
logger.exception("setup: фоновый перезапуск sync_client")
threading.Thread(
target=_run,
daemon=True,
name="wesp-setup-sync-restart",
).start()
return True
return bool(apply_sync_client_runtime(app))
except Exception:
logger.exception("setup: не удалось перезапустить sync_client")
return False
def _sync_progress_payload() -> dict:
state = read_sync_client_state()
progress: dict = {"active": False}
try:
from sync_client import get_initial_sync_progress_snapshot
progress = get_initial_sync_progress_snapshot()
except Exception:
logger.debug("setup: initial sync progress unavailable", exc_info=True)
return {
"first_bootstrap_done": bool(state.get("first_bootstrap_done")),
"initial_sync_progress": progress,
"connection": _setup_connection_snapshot(),
}
@bp.get("/status")
def setup_status():
err = _require_setup_access(allow_completed=True)
if err is not None:
return err
install_w = get_install_and_warranty(current_app)
snap = setup_snapshot(current_app)
snap["first_launch_at"] = install_w.get("first_launch_at")
user_count = db.session.scalar(select(func.count()).select_from(WebUser)) or 0
try:
from app.services.update_health import build_health_payload
health = build_health_payload(current_app)
except Exception:
health = {"ok": False}
zootech_logins = list(
db.session.scalars(
select(WebUser.login).where(WebUser.is_superuser.is_(False)).order_by(WebUser.login)
).all()
)
return jsonify(
{
"status": "success",
"setup": snap,
"health": health,
"network": network_settings_snapshot(current_app),
"sync": _sync_progress_payload(),
"connection": _setup_connection_snapshot(),
"users_exist": int(user_count) > 0,
"zootech_users": zootech_logins,
"user_rules": {
"password_min_len": _WEB_USER_PASSWORD_MIN_LEN,
},
"hardware": _hardware_platform_snapshot(),
}
)
@bp.post("/device-role")
def setup_device_role():
err = _require_setup_mutation()
if err is not None:
return err
payload = request.get_json(silent=True) or {}
role = str(payload.get("device_role") or payload.get("role") or "").strip().lower()
if role not in ("server", "client"):
return jsonify(
{"status": "error", "message": "device_role: server или client"},
), 400
conn = _effective_sync_connection(current_app)
if conn.get("role_locked_by_env") and role in ("server", "client"):
env_role = str(conn.get("env_role") or conn.get("role") or "").strip().lower()
if env_role and env_role != role:
return jsonify(
{
"status": "error",
"message": "Роль задана в WESP_SYNC_ROLE — измените окружение или выберите совпадающую роль.",
}
), 400
updates: Dict[str, Any] = {}
if role == "server":
updates["role"] = "server"
updates["server_url"] = ""
elif role == "client":
updates["role"] = "client"
if updates:
try:
write_sync_client_state(updates)
except ValueError as exc:
return jsonify({"status": "error", "message": str(exc)}), 400
if role == "server" and not current_app.config.get("TESTING"):
try:
from sync_client import stop_sync_client
stop_sync_client()
except Exception:
pass
# Sync перезапускается на шаге «Подключение к серверу» (async). Здесь не трогаем:
# при role=client и сохранённом server_url register() блокирует HTTP-ответ на таймауты.
write_install_state(current_app, {"device_role": role})
mark_step_completed(current_app, "device")
return jsonify(
{
"status": "success",
"device_role": role,
"connection": _setup_connection_snapshot(),
}
)
@bp.post("/network")
def setup_network():
err = _require_setup_mutation()
if err is not None:
return err
payload = request.get_json(silent=True) or {}
snap = network_settings_snapshot(current_app)
updates: dict = {}
if "local_hostname" in payload:
if snap.get("local_hostname_locked_by_env"):
return jsonify(
{"status": "error", "message": "local_hostname задан в окружении."},
), 400
ok, host_or_err = validate_local_hostname(payload.get("local_hostname"))
if not ok:
return jsonify({"status": "error", "message": host_or_err}), 400
updates["local_hostname"] = host_or_err
if not snap.get("public_base_url_locked_by_env"):
_, listen_port = parse_wesp_listen(str(current_app.config.get("WESP_LISTEN") or ""))
updates["public_base_url"] = build_public_url_from_hostname(
updates["local_hostname"],
listen_port,
)
if not snap.get("mdns_enabled_locked_by_env"):
updates["mdns_enabled"] = True
elif "public_base_url" in payload:
if snap.get("public_base_url_locked_by_env"):
return jsonify(
{"status": "error", "message": "public_base_url задан в окружении."},
), 400
url = str(payload.get("public_base_url") or "").strip().rstrip("/")
if url and not (url.startswith("http://") or url.startswith("https://")):
return jsonify(
{"status": "error", "message": "Публичный URL: пусто или http(s)://…"},
), 400
updates["public_base_url"] = url
if "mdns_enabled" in payload:
if snap.get("mdns_enabled_locked_by_env"):
return jsonify(
{"status": "error", "message": "mdns_enabled задан в окружении."},
), 400
updates["mdns_enabled"] = coerce_security_bool(payload.get("mdns_enabled"))
if not updates:
return jsonify(
{"status": "error", "message": "Передайте local_hostname, public_base_url и/или mdns_enabled."},
), 400
write_network_settings(updates)
apply_network_settings_to_app(current_app)
if not current_app.config.get("TESTING"):
try:
if any(k in updates for k in ("mdns_enabled", "local_hostname")):
reload_mdns(current_app)
except Exception as exc:
logger.warning("setup network: mDNS reload failed: %s", exc)
mark_step_completed(current_app, "network")
out = network_settings_snapshot(current_app)
out["settings_path"] = str(current_app.config.get("DATA_DIR", ""))
return jsonify({"status": "success", "network": out})
@bp.post("/sync")
def setup_sync():
err = _require_setup_mutation()
if err is not None:
return err
payload = request.get_json(silent=True) or {}
conn = _effective_sync_connection(current_app)
updates: dict = {"role": "client"}
offline = bool(payload.get("offline"))
if conn.get("server_url_locked_by_env"):
if "server_url" in payload and str(payload.get("server_url") or "").strip():
return jsonify(
{"status": "error", "message": "server_url задан в WESP_SYNC_SERVER_URL."},
), 400
elif "server_url" in payload:
raw = str(payload.get("server_url") or "").strip().rstrip("/")
if raw and not (raw.startswith("http://") or raw.startswith("https://")):
return jsonify(
{"status": "error", "message": "Адрес сервера должен начинаться с http:// или https://"},
), 400
updates["server_url"] = raw
server_url_check = str(updates.get("server_url") or conn.get("server_url") or "").strip().rstrip("/")
if not offline and server_url_check and not current_app.config.get("TESTING"):
reachable, ping_error, effective_url = _ping_sync_server_with_effective(
server_url_check, timeout=5.0
)
if not reachable:
return jsonify(
{
"status": "error",
"reachable": False,
"message": ping_error,
"server_url": server_url_check,
}
), 502
if effective_url and effective_url != server_url_check:
updates["server_url"] = effective_url
logger.info(
"setup: имя %s не резолвится — сохранён адрес по IP %s",
server_url_check,
effective_url,
)
if "client_id" in payload:
cid = str(payload.get("client_id") or "").strip()
if cid:
try:
uuid.UUID(cid)
except ValueError:
return jsonify(
{"status": "error", "message": "client_id: ожидается UUID."},
), 400
else:
cid = str(uuid.uuid4())
updates["client_id"] = cid
elif not read_sync_client_state().get("client_id"):
updates["client_id"] = str(uuid.uuid4())
if "client_name" in payload:
name = str(payload.get("client_name") or "").strip()
if len(name) > 200:
return jsonify(
{"status": "error", "message": "Название терминала: не длиннее 200 символов."},
), 400
if name:
updates["client_name"] = name
if "client_name" not in updates and not read_sync_client_state().get("client_name"):
updates["client_name"] = _default_setup_client_name()
write_sync_client_state(updates)
reloaded = _restart_local_sync_if_needed(async_start=True)
mark_step_completed(current_app, "sync")
write_install_state(
current_app,
{"sync_server_connected": (not offline and bool(server_url_check))},
)
state = read_sync_client_state()
return jsonify(
{
"status": "success",
"sync_reloaded": reloaded,
"offline": offline,
"reachable": not offline and bool(server_url_check),
"client_id": state.get("client_id"),
"connection": _setup_connection_snapshot(),
}
)
@bp.post("/sync/test")
def setup_sync_test():
err = _require_setup_access(allow_completed=True)
if err is not None:
return err
payload = request.get_json(silent=True) or {}
server_url = str(payload.get("server_url") or "").strip().rstrip("/")
if not server_url:
conn = _effective_sync_connection(current_app)
server_url = str(conn.get("server_url") or "").strip().rstrip("/")
if not server_url:
return jsonify({"status": "error", "message": "Укажите адрес сервера."}), 400
reachable, ping_error, effective_url = _ping_sync_server_with_effective(
server_url, timeout=5.0
)
ping_url = f"{(effective_url or server_url)}/api/sync/ping"
if not reachable:
return jsonify(
{
"status": "error",
"reachable": False,
"message": ping_error,
"ping_url": ping_url,
}
), 502
return jsonify(
{
"status": "success",
"reachable": True,
"ping_url": ping_url,
"effective_server_url": effective_url if effective_url != server_url else None,
}
)
@bp.get("/sync/progress")
def setup_sync_progress():
err = _require_setup_access(allow_completed=True)
if err is not None:
return err
return jsonify({"status": "success", **_sync_progress_payload()})
@bp.post("/users")
def setup_users():
err = _require_setup_mutation()
if err is not None:
return err
rate_err, rate_code = _rate_limit_user_create()
if rate_err is not None:
return jsonify(rate_err), rate_code
payload = request.get_json(silent=True) or {}
zootech = payload.get("zootech") if isinstance(payload.get("zootech"), dict) else payload
err_body, err_code = _validate_user_fields(
zootech.get("login"),
zootech.get("password"),
zootech.get("confirm") or zootech.get("password_confirm"),
"Зоотехник",
)
if err_body is not None:
return jsonify(err_body), err_code
zootech_login = zootech.get("login", "").strip()
env_login, _ = load_credentials()
if zootech_login.lower() == env_login.strip().lower():
return jsonify(
{
"status": "error",
"message": "Логин зоотехника не должен совпадать с учётной записью администратора.",
},
), 400
_ensure_default_superuser()
existing = db.session.execute(
select(WebUser).where(WebUser.login == zootech_login)
).scalar_one_or_none()
try:
if existing is not None:
if existing.is_superuser:
return jsonify(
{
"status": "error",
"message": "Этот логин занят учётной записью администратора.",
},
), 400
existing.password_hash = generate_password_hash(zootech["password"])
db.session.commit()
action = "updated"
message = f"Пароль зоотехника «{zootech_login}» обновлён."
else:
user = WebUser(
login=zootech_login,
password_hash=generate_password_hash(zootech["password"]),
is_superuser=False,
)
db.session.add(user)
db.session.commit()
action = "created"
message = "Создана учётная запись зоотехника."
except IntegrityError:
db.session.rollback()
return jsonify(
{"status": "error", "message": "Не удалось сохранить пользователя (дубликат логина?)."},
), 409
mark_step_completed(current_app, "users")
return jsonify(
{
"status": "success",
"action": action,
"login": zootech_login,
"message": message,
}
)
@bp.post("/hardware/check")
def setup_hardware_check():
err = _require_setup_access(allow_completed=True)
if err is not None:
return err
payload = build_hardware_status(current_app)
scales = payload.get("scales") or {}
platform_info = _hardware_platform_snapshot()
ok = bool(scales.get("available")) and not scales.get("error")
weight = scales.get("weight_kg")
return jsonify(
{
"status": "success",
"ok": ok,
"platform": platform_info,
"hardware": {
"simulation_mode": scales.get("simulation_mode"),
"current_weight_kg": weight,
"available": scales.get("available"),
"error": scales.get("error"),
},
}
)
@bp.post("/kiosk/prepare")
def setup_kiosk_prepare():
err = _require_setup_mutation()
if err is not None:
return err
from datetime import datetime
device_id = (request.cookies.get("wesp_kiosk_device_id") or "").strip()
if not device_id:
device_id = str(uuid.uuid4())
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()
response = make_response(
jsonify({"status": "success", "device_id": device_id, "paired": row.status == "active"})
)
set_kiosk_device_cookie(response, device_id)
return response
@bp.post("/complete")
def setup_complete():
err = _require_setup_mutation()
if err is not None:
return err
state = read_install_state(current_app)
device_role = state.get("device_role")
if not device_role:
return jsonify({"status": "error", "message": "Сначала выберите тип устройства."}), 400
if device_role == "server":
has_users = db.session.scalar(select(WebUser.id).limit(1)) is not None
if not has_users:
return jsonify(
{"status": "error", "message": "Создайте учётную запись зоотехника."},
), 400
write_sync_client_state({"role": "server", "server_url": ""})
if not current_app.config.get("TESTING"):
try:
from sync_client import stop_sync_client
stop_sync_client()
except Exception:
pass
mark_step_completed(current_app, "done")
final = mark_setup_complete(current_app)
return jsonify(
{
"status": "success",
"setup": setup_snapshot(current_app),
"redirect": setup_complete_redirect(device_role),
"message": "Первоначальная настройка завершена.",
}
)
@bp.post("/reset")
@require_superuser
def setup_reset():
"""Только флаг мастера (БД не трогает). Для полного сброса — POST /api/setup/factory-reset."""
reset_setup(current_app)
return jsonify(
{
"status": "success",
"message": "Флаг setup_completed сброшен. Откройте /setup для повторной настройки.",
"setup": setup_snapshot(current_app),
}
)
@bp.post("/factory-reset")
@require_superuser
def setup_factory_reset():
"""Удаляет БД, JSON в data/, логи; sync по умолчанию role=server. Нужен перезапуск процесса."""
from app.services.factory_reset import (
factory_reset_app,
validate_factory_reset_request,
)
payload = request.get_json(silent=True) or {}
ok, err, remove_logs, safety_backup = validate_factory_reset_request(payload)
if not ok:
return jsonify({"status": "error", "message": err}), 400
report = factory_reset_app(
current_app,
remove_logs=remove_logs,
safety_sqlite_backup=safety_backup,
)
code = 200 if report.get("ok") else 500
return jsonify({"status": "success" if report.get("ok") else "error", **report}), code
+57
View File
@@ -0,0 +1,57 @@
from flask import Blueprint, jsonify, request
from sqlalchemy import select
from app import db
from app.models import ComponentStock
from app.routes.auth_decorators import require_auth
bp = Blueprint("sklad", __name__, url_prefix="/api/sklad")
def _error(message: str, status_code: int = 400):
return jsonify({"error": True, "message": message}), status_code
@bp.get("/ping")
def sklad_ping():
"""Временный health-check эндпоинт для модуля склада."""
return jsonify({"status": "ok"}), 200
@bp.get("/components")
@require_auth
def list_component_stock():
"""Список складских остатков с пагинацией."""
try:
limit = int(request.args.get("limit", 100))
offset = int(request.args.get("offset", 0))
except ValueError:
return _error("Некорректные параметры пагинации", 400)
rows = db.session.execute(
select(ComponentStock)
.where(ComponentStock.is_deleted.is_(False))
.order_by(
ComponentStock.sort_order.asc(),
ComponentStock.updated_at.desc(),
)
.offset(offset)
.limit(limit)
).scalars().all()
return jsonify(
[
{
"id": s.id,
"component_id": s.component_id,
"component_name": s.component_name,
"total_kg": s.total_kg,
"inflow_kg": s.inflow_kg,
"baseline_consumed_kg": s.baseline_consumed_kg,
"stocktake_at": s.stocktake_at.isoformat() if s.stocktake_at else None,
"updated_at": s.updated_at.isoformat() if s.updated_at else None,
"updated_by": s.updated_by,
}
for s in rows
]
)
+19
View File
@@ -0,0 +1,19 @@
"""Статус фоновой инициализации (доступен до готовности остальных API)."""
from flask import Blueprint, current_app, jsonify
from app.services.hardware_settings_service import default_app_landing_path
from app.services.startup_background import is_startup_ready_implicit
from app.services.startup_state import startup_status_payload
bp = Blueprint("startup", __name__, url_prefix="/api/startup")
@bp.get("/status")
def startup_status():
payload = startup_status_payload()
payload["default_next"] = default_app_landing_path()
if is_startup_ready_implicit(current_app):
payload = {**payload, "ready": True, "phase": "ready"}
code = 200 if payload.get("ready") else 503
return jsonify({"status": "success" if payload.get("ready") else "starting", **payload}), code
+960
View File
@@ -0,0 +1,960 @@
import json
import logging
import time
from datetime import datetime
from typing import Any, Dict
import gzip
from flask import Blueprint, current_app, jsonify, request
from sqlalchemy import and_, func, select
from app import db
from app.models import (
SyncClient,
SyncClientDisplayName,
SyncConflict,
SyncDelivery,
SyncMetadata,
SyncQueue,
)
from app.routes.auth_decorators import require_auth, require_paired_terminal
from app.services.sync_manager import (
REPORT_TABLES,
SERVER_MASTER_TABLES,
SyncManager,
apply_sync_change,
build_consistency_snapshot,
bootstrap_universal_sync,
format_sync_pull_out_line,
)
from app.services.client_log_upload import store_client_log
from app.services.sync_error_display import sync_error_text_for_display
from app.services.sync_runtime import sync_runtime
from app.routes.sync_request_parser import (
parse_confirm_payload,
parse_json_request,
parse_pull_payload,
parse_push_payload,
)
from config import Config
bp = Blueprint("sync", __name__, url_prefix="/api/sync")
logger = logging.getLogger(__name__)
def _sync_payload_message(payload: Any) -> str:
if isinstance(payload, dict):
m = payload.get("message")
if m is not None and str(m).strip():
return str(m).strip()[:2000]
err = payload.get("error")
if err is not None and err is not True:
return str(err)[:2000]
return str(payload)[:2000] if payload is not None else ""
def _log_sync_manager_result(operation: str, client_id: str, result: dict) -> None:
"""process_* часто возвращает 4xx/5xx без исключения — без этой строки в логах пусто."""
code = int(result.get("status_code") or 200)
if code < 400:
return
pl = result.get("payload")
msg = _sync_payload_message(pl)
remote = (getattr(request, "remote_addr", None) or "")[:48]
log_fn = logger.error if code >= 500 else logger.warning
log_fn(
"[%s-FAIL] HTTP %s client_id=%s remote=%s %s",
operation,
code,
(client_id or "")[:36],
remote,
msg or "",
)
def error_response(message: str, status_code: int = 400, **extra: Any):
payload: Dict[str, Any] = {"error": True, "message": message}
if extra:
payload.update(extra)
return jsonify(payload), status_code
@bp.get("/ping")
def sync_ping():
from app.services.lan_network import detect_lan_ip
return jsonify({"status": "ok", "lan_ip": detect_lan_ip() or None}), 200
@bp.get("/clients")
@require_auth
def sync_clients():
clients = db.session.execute(
select(SyncClient)
.where(SyncClient.is_deleted.is_(False))
.order_by(SyncClient.last_seen.desc().nullslast(), SyncClient.created_at.desc())
).scalars().all()
names = {
n.node_id: n.display_name
for n in db.session.execute(select(SyncClientDisplayName)).scalars().all()
}
return jsonify(
[
{
"node_id": c.node_id,
"client_name": c.client_name,
"display_name": names.get(c.node_id),
"ip_address": c.ip_address,
"status": c.status,
"last_seen": c.last_seen.isoformat() if c.last_seen else None,
"is_enabled": c.is_enabled,
"total_syncs": c.total_syncs,
"sync_error": sync_error_text_for_display(c.last_error),
}
for c in clients
]
)
@bp.get("/clients/<string:node_id>/deliveries")
@require_auth
def sync_client_deliveries(node_id: str):
try:
hours = int(request.args.get("hours", 24))
except ValueError:
return error_response("hours должен быть числом", 400)
if hours <= 0:
hours = 24
client = db.session.execute(
select(SyncClient).where(
SyncClient.node_id == node_id, SyncClient.is_deleted.is_(False)
)
).scalar_one_or_none()
if not client:
return error_response("Клиент не найден", 404)
from datetime import datetime, timedelta
cutoff = datetime.now() - timedelta(hours=hours)
rows = db.session.execute(
select(SyncDelivery, SyncQueue)
.join(SyncQueue, SyncDelivery.task_id == SyncQueue.id)
.where(SyncDelivery.client_id == client.id)
.where(SyncDelivery.delivered_at >= cutoff)
.order_by(SyncDelivery.delivered_at.desc())
.limit(500)
).all()
deliveries = []
for d, task in rows:
deliveries.append(
{
"task_id": d.task_id,
"table_name": task.table_name,
"action": task.action,
"record_id": task.record_id,
"created_at": task.created_at.isoformat() if task.created_at else None,
"processed_at": task.processed_at.isoformat() if task.processed_at else None,
"delivered_at": d.delivered_at.isoformat() if d.delivered_at else None,
"name": task.record_id,
"context": task.table_name,
}
)
return jsonify({"deliveries": deliveries})
@bp.put("/clients/<string:node_id>/display_name")
@require_auth
def sync_client_set_display_name(node_id: str):
data = request.get_json() or {}
display_name = (data.get("display_name") or "").strip()
client = db.session.execute(
select(SyncClient).where(
SyncClient.node_id == node_id, SyncClient.is_deleted.is_(False)
)
).scalar_one_or_none()
if not client:
return error_response("Клиент не найден", 404)
from datetime import datetime
row = db.session.execute(
select(SyncClientDisplayName).where(SyncClientDisplayName.node_id == node_id)
).scalar_one_or_none()
if not display_name:
if row:
db.session.delete(row)
db.session.commit()
return jsonify({"success": True, "message": "Имя клиента сброшено"})
if not row:
row = SyncClientDisplayName(
node_id=node_id,
display_name=display_name,
updated_at=datetime.now(),
)
db.session.add(row)
else:
row.display_name = display_name
row.updated_at = datetime.now()
db.session.commit()
return jsonify({"success": True, "display_name": display_name})
@bp.delete("/clients/<string:node_id>")
@require_auth
def sync_client_delete(node_id: str):
client = db.session.execute(
select(SyncClient).where(
SyncClient.node_id == node_id, SyncClient.is_deleted.is_(False)
)
).scalar_one_or_none()
if not client:
return error_response("Клиент не найден", 404)
client.is_deleted = True
client.status = "disabled"
client.is_enabled = False
db.session.commit()
return jsonify({"success": True, "message": "Клиент удалён"})
@bp.get("/metadata")
@require_auth
def sync_metadata():
row = db.session.execute(
select(SyncMetadata)
.where(SyncMetadata.is_deleted.is_(False))
.order_by(SyncMetadata.created_at.asc())
).scalar_one_or_none()
if not row:
return error_response("Метаданные синхронизации не найдены", 404)
return jsonify(
{
"id": row.id,
"node_id": row.node_id,
"node_type": row.node_type,
"node_name": row.node_name,
"node_status": row.node_status,
"last_heartbeat": row.last_heartbeat.isoformat() if row.last_heartbeat else None,
"last_sync": row.last_sync.isoformat() if row.last_sync else None,
"sync_status": row.sync_status,
"is_enabled": row.is_enabled,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
)
@bp.get("/clients/<string:node_id>")
@require_auth
def sync_client_details(node_id: str):
client = db.session.execute(
select(SyncClient).where(
SyncClient.node_id == node_id, SyncClient.is_deleted.is_(False)
)
).scalar_one_or_none()
if not client:
return error_response("Клиент не найден", 404)
return jsonify(
{
"id": client.id,
"node_id": client.node_id,
"client_name": client.client_name,
"ip_address": client.ip_address,
"port": client.port,
"status": client.status,
"last_seen": client.last_seen.isoformat() if client.last_seen else None,
"total_syncs": client.total_syncs,
"last_error": client.last_error,
"sync_error": sync_error_text_for_display(client.last_error),
"is_enabled": client.is_enabled,
"created_at": client.created_at.isoformat() if client.created_at else None,
"updated_at": client.updated_at.isoformat() if client.updated_at else None,
}
)
@bp.get("/queue")
@require_auth
def sync_queue_list():
status = (request.args.get("status") or "").strip()
priority_raw = (request.args.get("priority") or "").strip()
try:
limit = int(request.args.get("limit", 100))
except ValueError:
return error_response("limit должен быть числом", 400)
query = select(SyncQueue).where(SyncQueue.is_deleted.is_(False))
if status:
query = query.where(SyncQueue.status == status)
if priority_raw:
try:
priority = int(priority_raw)
except ValueError:
return error_response("priority должен быть числом", 400)
query = query.where(SyncQueue.priority == priority)
rows = db.session.execute(
query.order_by(SyncQueue.priority.asc(), SyncQueue.created_at.asc()).limit(
max(1, min(limit, 1000))
)
).scalars().all()
return jsonify(
[
{
"id": r.id,
"table_name": r.table_name,
"record_id": r.record_id,
"action": r.action,
"status": r.status,
"target_node_id": r.target_node_id,
"source_node_id": r.source_node_id,
"priority": r.priority,
"retry_count": r.retry_count,
"max_retries": r.max_retries,
"error_message": r.error_message,
"created_at": r.created_at.isoformat() if r.created_at else None,
"processed_at": r.processed_at.isoformat() if r.processed_at else None,
"completed_at": r.completed_at.isoformat() if r.completed_at else None,
}
for r in rows
]
)
@bp.get("/queue/stats")
@require_auth
def sync_queue_stats():
base = select(SyncQueue).where(SyncQueue.is_deleted.is_(False)).subquery()
stats = {
"total": db.session.scalar(select(func.count()).select_from(base)) or 0,
"pending": db.session.scalar(
select(func.count()).select_from(base).where(base.c.status == "pending")
)
or 0,
"processing": db.session.scalar(
select(func.count()).select_from(base).where(base.c.status == "processing")
)
or 0,
"completed": db.session.scalar(
select(func.count()).select_from(base).where(base.c.status == "completed")
)
or 0,
"failed": db.session.scalar(
select(func.count()).select_from(base).where(base.c.status == "failed")
)
or 0,
"by_priority": {},
}
for p in range(1, 6):
stats["by_priority"][f"priority_{p}"] = (
db.session.scalar(
select(func.count()).select_from(base).where(base.c.priority == p)
)
or 0
)
return jsonify(stats)
@bp.post("/bootstrap")
@require_auth
def sync_bootstrap():
created = bootstrap_universal_sync()
db.session.commit()
return jsonify({"success": True, "created": created})
@bp.get("/conflicts")
@require_auth
def sync_conflicts():
resolution = (request.args.get("resolution") or "").strip()
conflict_type = (request.args.get("conflict_type") or "").strip()
try:
limit = int(request.args.get("limit", 50))
except ValueError:
return error_response("limit должен быть числом", 400)
query = select(SyncConflict).where(SyncConflict.is_deleted.is_(False))
if resolution:
query = query.where(SyncConflict.resolution == resolution)
if conflict_type:
query = query.where(SyncConflict.conflict_type == conflict_type)
rows = db.session.execute(
query.order_by(SyncConflict.created_at.desc()).limit(max(1, min(limit, 1000)))
).scalars().all()
return jsonify(
[
{
"id": c.id,
"table_name": c.table_name,
"record_id": c.record_id,
"conflict_type": c.conflict_type,
"local_data": c.local_data,
"remote_data": c.remote_data,
"resolution": c.resolution,
"resolved_by": c.resolved_by,
"resolved_at": c.resolved_at.isoformat() if c.resolved_at else None,
"created_at": c.created_at.isoformat() if c.created_at else None,
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
}
for c in rows
]
)
@bp.post("/conflicts/<string:conflict_id>/resolve")
@require_auth
def sync_conflict_resolve(conflict_id: str):
row = db.session.execute(
select(SyncConflict).where(
SyncConflict.id == conflict_id, SyncConflict.is_deleted.is_(False)
)
).scalar_one_or_none()
if not row:
return error_response("Конфликт не найден", 404)
data = request.get_json() or {}
resolution = (data.get("resolution") or "").strip()
if resolution not in {"local", "remote", "merged"}:
return error_response("Неверное разрешение конфликта", 400)
local_data = json.loads(row.local_data) if row.local_data else None
remote_data = json.loads(row.remote_data) if row.remote_data else None
is_server_master = row.table_name in SERVER_MASTER_TABLES
is_client_master = row.table_name in REPORT_TABLES
applied_payload: Any = None
if resolution == "local":
if local_data:
applied_payload = local_data
elif resolution == "remote":
if remote_data:
applied_payload = remote_data
elif resolution == "merged":
if is_server_master and remote_data:
applied_payload = remote_data
elif is_client_master and local_data:
applied_payload = local_data
elif remote_data:
applied_payload = remote_data
if applied_payload:
result = apply_sync_change(row.table_name, row.record_id, "update", applied_payload)
if not result["success"]:
return error_response(
f"Ошибка применения изменений: {result.get('error', 'unknown')}",
500,
)
row.resolution = resolution
row.resolved_by = (data.get("resolved_by") or "admin").strip() or "admin"
row.resolved_at = datetime.now()
db.session.commit()
return jsonify(
{
"success": True,
"message": f"Конфликт разрешен: {resolution}",
"conflict_id": row.id,
"resolved_by": row.resolved_by,
"resolved_at": row.resolved_at.isoformat() if row.resolved_at else None,
"applied_data": applied_payload is not None,
}
)
@bp.get("/status")
@require_auth
def sync_status():
metadata = db.session.execute(
select(SyncMetadata).where(SyncMetadata.is_deleted.is_(False))
).scalar_one_or_none()
clients_base = select(SyncClient).where(SyncClient.is_deleted.is_(False)).subquery()
clients_stats = {
"total": db.session.scalar(select(func.count()).select_from(clients_base)) or 0,
"active": db.session.scalar(
select(func.count()).select_from(clients_base).where(clients_base.c.status == "active")
)
or 0,
"offline": db.session.scalar(
select(func.count()).select_from(clients_base).where(clients_base.c.status == "offline")
)
or 0,
"disabled": db.session.scalar(
select(func.count()).select_from(clients_base).where(clients_base.c.status == "disabled")
)
or 0,
}
queue_base = select(SyncQueue).where(SyncQueue.is_deleted.is_(False)).subquery()
queue_stats = {
"total": db.session.scalar(select(func.count()).select_from(queue_base)) or 0,
"pending": db.session.scalar(
select(func.count()).select_from(queue_base).where(queue_base.c.status == "pending")
)
or 0,
"processing": db.session.scalar(
select(func.count()).select_from(queue_base).where(queue_base.c.status == "processing")
)
or 0,
"completed": db.session.scalar(
select(func.count()).select_from(queue_base).where(queue_base.c.status == "completed")
)
or 0,
"failed": db.session.scalar(
select(func.count()).select_from(queue_base).where(queue_base.c.status == "failed")
)
or 0,
}
conflicts_base = select(SyncConflict).where(SyncConflict.is_deleted.is_(False)).subquery()
conflicts_stats = {
"total": db.session.scalar(select(func.count()).select_from(conflicts_base)) or 0,
"pending": db.session.scalar(
select(func.count())
.select_from(conflicts_base)
.where(
(conflicts_base.c.resolution == "pending")
| (conflicts_base.c.resolution.is_(None))
)
)
or 0,
"resolved": db.session.scalar(
select(func.count())
.select_from(conflicts_base)
.where(
(conflicts_base.c.resolution != "pending")
& (conflicts_base.c.resolution.is_not(None))
)
)
or 0,
"by_type": {
"version_mismatch": db.session.scalar(
select(func.count())
.select_from(conflicts_base)
.where(conflicts_base.c.conflict_type == "version_mismatch")
)
or 0,
"application_error": db.session.scalar(
select(func.count())
.select_from(conflicts_base)
.where(conflicts_base.c.conflict_type == "application_error")
)
or 0,
"deletion": db.session.scalar(
select(func.count())
.select_from(conflicts_base)
.where(conflicts_base.c.conflict_type == "deletion")
)
or 0,
"hash": db.session.scalar(
select(func.count())
.select_from(conflicts_base)
.where(conflicts_base.c.conflict_type == "hash")
)
or 0,
},
}
return jsonify(
{
"metadata": {
"node_id": metadata.node_id if metadata else None,
"node_type": metadata.node_type if metadata else None,
"sync_status": metadata.sync_status if metadata else None,
"last_sync": metadata.last_sync.isoformat()
if metadata and metadata.last_sync
else None,
},
"clients": clients_stats,
"queue": queue_stats,
"conflicts": conflicts_stats,
"timestamp": datetime.now().isoformat(),
}
)
@bp.get("/consistency")
@require_auth
def sync_consistency():
"""Read-only checksum summary for master-data consistency checks."""
return jsonify({"success": True, "snapshot": build_consistency_snapshot()})
@bp.post("/init")
@require_auth
def sync_init_metadata():
row = db.session.execute(
select(SyncMetadata).where(SyncMetadata.is_deleted.is_(False))
).scalar_one_or_none()
if not row:
row = SyncMetadata(
node_id="server-local",
node_type="server",
node_name="WESP Server",
node_status="active",
sync_status="idle",
is_enabled=True,
)
db.session.add(row)
row.last_heartbeat = datetime.now()
db.session.commit()
return jsonify(
{
"success": True,
"message": "Метаданные синхронизации инициализированы",
"node_id": row.node_id,
"node_type": row.node_type,
}
)
@bp.post("/register")
def sync_register_client():
data, parse_error = parse_json_request(request)
if parse_error:
return error_response(parse_error, 400)
assert data is not None
client_id = (data.get("client_id") or "").strip()
if not client_id:
return error_response("client_id обязателен", 400)
client = db.session.execute(
select(SyncClient).where(SyncClient.node_id == client_id)
).scalar_one_or_none()
now = datetime.now()
if client:
client.client_name = (data.get("client_name") or client.client_name or "Unknown").strip()
client.ip_address = request.remote_addr
client.port = int(data.get("port", client.port or 80))
client.status = "active"
client.last_seen = now
client.is_enabled = True
client.is_deleted = False
else:
client = SyncClient(
node_id=client_id,
client_name=(data.get("client_name") or "Unknown").strip() or "Unknown",
ip_address=request.remote_addr,
port=int(data.get("port", 80)),
status="active",
last_seen=now,
total_syncs=0,
is_enabled=True,
)
db.session.add(client)
db.session.commit()
return jsonify({"success": True, "message": "Клиент зарегистрирован", "client_id": client_id})
@bp.post("/check-db")
@require_auth
def sync_check_db():
payload = {
"success": True,
"checks": {
"sync_clients": db.session.scalar(
select(func.count()).select_from(SyncClient).where(SyncClient.is_deleted.is_(False))
)
or 0,
"sync_queue": db.session.scalar(
select(func.count()).select_from(SyncQueue).where(SyncQueue.is_deleted.is_(False))
)
or 0,
"sync_conflicts": db.session.scalar(
select(func.count())
.select_from(SyncConflict)
.where(SyncConflict.is_deleted.is_(False))
)
or 0,
},
}
return jsonify(payload)
@bp.post("/check-db-client")
@require_paired_terminal
def sync_check_db_client():
data = request.get_json(silent=True) or {}
client_id = (data.get("client_id") or "").strip()
if not client_id:
return error_response("client_id обязателен", 400)
client = db.session.execute(
select(SyncClient).where(
SyncClient.node_id == client_id, SyncClient.is_deleted.is_(False)
)
).scalar_one_or_none()
if not client:
return error_response("Клиент не найден", 404)
tasks = db.session.scalar(
select(func.count()).select_from(SyncQueue).where(
SyncQueue.is_deleted.is_(False),
(SyncQueue.target_node_id == client_id) | (SyncQueue.target_node_id.is_(None)),
)
) or 0
return jsonify({"success": True, "client_id": client_id, "pending_tasks": tasks})
@bp.post("/restore-db")
@require_paired_terminal
def sync_restore_db():
# Legacy endpoint compatibility: explicit restore action is intentionally disabled
# in refactored architecture to avoid unsafe ad-hoc DB replacement.
return jsonify({"success": False, "message": "Операция restore-db отключена"}), 501
@bp.post("/pull")
def sync_pull():
start_time = time.time()
client_id_for_lock = ""
pull_lock_acquired = False
slot_acquired = False
try:
data, parse_error = parse_json_request(request)
if parse_error:
return error_response(parse_error, 400)
assert data is not None
client_id, limit, pull_client_name, payload_error = parse_pull_payload(data)
if payload_error:
return error_response(payload_error, 400)
assert client_id is not None
client_id_for_lock = client_id
init_hdr = (request.headers.get("X-WESP-Initial-Sync") or "").strip().lower() in (
"1",
"true",
"yes",
"on",
)
steady_wait = float(getattr(Config, "SYNC_PULL_CLIENT_LOCK_WAIT_SEC", 180.0))
initial_wait = float(getattr(Config, "SYNC_PULL_INITIAL_LOCK_WAIT_SEC", 600.0))
lock_wait = initial_wait if init_hdr else steady_wait
if not sync_runtime.acquire_pull_client(client_id, wait_sec=lock_wait):
logger.warning(
"[SYNC-PULL-CLIENT-TIMEOUT] Не получен lock pull за %ss (%s) client_id=%s",
lock_wait,
"initial" if init_hdr else "steady",
(client_id or "")[:12],
)
return error_response(
"Предыдущая синхронизация этого узла ещё выполняется (превышено время ожидания). Повторите позже.",
503,
)
pull_lock_acquired = True
used_slots, available_slots = sync_runtime.concurrency_status()
if not sync_runtime.acquire_slot():
logger.warning(
"[SYNC-PULL-BLOCKED] Все слоты заняты (%s/%s)",
used_slots,
Config.SYNC_MAX_CONCURRENT,
)
return error_response(
"Слишком много одновременных синхронизаций. Попробуйте позже.",
429,
)
slot_acquired = True
logger.info(
"[SYNC-PULL-START] client_id=%s… доступно слотов: %s/%s",
(client_id or "")[:12],
available_slots - 1,
Config.SYNC_MAX_CONCURRENT,
)
result = SyncManager.process_pull(
client_id=client_id,
limit=limit,
client_name=pull_client_name,
client_ip=request.remote_addr,
initial_sync_header=init_hdr,
)
_log_sync_manager_result("SYNC-PULL", client_id, result)
status_code = int(result["status_code"])
payload = result["payload"]
if isinstance(payload, dict) and status_code in (200, 202):
logger.info(format_sync_pull_out_line(client_id, status_code, payload))
elapsed = time.time() - start_time
sync_runtime.record_pull_duration(elapsed)
p95 = sync_runtime.pull_duration_p95()
dur_ms = str(int(max(0.0, elapsed) * 1000))
p95_ms = str(int(max(0.0, p95) * 1000))
if status_code == 202:
resp = jsonify(payload)
retry_after = int((payload or {}).get("retry_after_sec") or 2)
resp.headers["Retry-After"] = str(max(1, retry_after))
resp.headers["X-WESP-Pull-Duration-Ms"] = dur_ms
resp.headers["X-WESP-Pull-P95-Ms"] = p95_ms
return resp, 202
resp = jsonify(payload)
resp.headers["X-WESP-Pull-Duration-Ms"] = dur_ms
resp.headers["X-WESP-Pull-P95-Ms"] = p95_ms
return resp, status_code
except Exception as e:
logger.error("[SYNC-PULL-ERROR] Ошибка sync_pull: %s", e, exc_info=True)
return error_response(str(e), 500)
finally:
logger.debug("[SYNC-PULL-END] client_id=%s", (client_id_for_lock or "")[:12])
if slot_acquired:
sync_runtime.release_slot()
if pull_lock_acquired and client_id_for_lock:
sync_runtime.release_pull_client(client_id_for_lock)
@bp.post("/confirm")
def sync_confirm():
start_time = time.time()
try:
data, parse_error = parse_json_request(request)
if parse_error:
return error_response(parse_error, 400)
assert data is not None
client_id, task_ids, payload_error = parse_confirm_payload(data)
if payload_error:
return error_response(payload_error, 400)
assert client_id is not None and task_ids is not None
result = SyncManager.process_confirm(client_id=client_id, task_ids=task_ids)
_log_sync_manager_result("SYNC-CONFIRM", client_id, result)
return jsonify(result["payload"]), result["status_code"]
except Exception as e:
logger.error("[SYNC-CONFIRM-ERROR] Ошибка sync_confirm: %s", e, exc_info=True)
return error_response(str(e), 500)
finally:
logger.debug("[SYNC-CONFIRM-END] duration=%.3fs", time.time() - start_time)
@bp.post("/client-log")
def sync_client_log_upload():
"""Приём файла лога с полевого клиента (отдельно от sync БД)."""
if not current_app.config.get("WESP_CLIENT_LOG_UPLOAD_ENABLED", True):
return error_response("Выгрузка логов с клиентов отключена", 403)
secret = (current_app.config.get("WESP_CLIENT_LOG_UPLOAD_SECRET") or "").strip()
if secret and request.headers.get("X-WESP-Client-Log-Secret", "").strip() != secret:
return error_response("Неверный секрет выгрузки логов", 401)
max_b = int(current_app.config.get("WESP_CLIENT_LOG_UPLOAD_MAX_BYTES") or 8_388_608)
client_id = (request.headers.get("X-WESP-Client-Id") or "").strip()
log_name = (request.headers.get("X-WESP-Log-Name") or "wesp.log").strip()
raw: bytes
if request.is_json:
data = request.get_json(silent=True) or {}
if not isinstance(data, dict):
return error_response("Ожидается JSON-объект", 400)
if not client_id:
client_id = str(data.get("client_id") or "").strip()
ln = data.get("log_name") or data.get("name")
if ln:
log_name = str(ln).strip()
b64 = data.get("content_base64")
text = data.get("content") if "content" in data else data.get("text")
if b64 is not None:
import base64
try:
raw = base64.b64decode(str(b64), validate=False)
except Exception:
return error_response("Некорректный content_base64", 400)
elif text is not None:
raw = str(text).encode("utf-8")
else:
return error_response("Укажите content (или text) либо content_base64", 400)
else:
if not client_id:
return error_response("Заголовок X-WESP-Client-Id обязателен", 400)
raw = request.get_data(cache=False) or b""
if request.headers.get("Content-Encoding", "").lower() == "gzip":
try:
raw = gzip.decompress(raw)
except Exception:
return error_response("Ошибка распаковки gzip", 400)
if not client_id:
return error_response("client_id обязателен", 400)
if len(raw) > max_b:
return error_response(f"Превышен лимит размера лога ({max_b} байт)", 413)
if not sync_runtime.check_rate_limit(client_id):
return error_response("Превышен лимит запросов", 429)
upload_dir = (current_app.config.get("WESP_CLIENT_LOG_UPLOAD_DIR") or "").strip()
if not upload_dir:
return error_response("Сервер не настроен на приём логов", 500)
try:
meta = store_client_log(
upload_dir=upload_dir,
client_id=client_id,
log_basename=log_name,
raw=raw,
)
except ValueError as e:
return error_response(str(e), 400)
logger.info(
"[CLIENT-LOG] client=%s size=%s -> %s",
client_id[:16],
meta.get("bytes_written"),
meta.get("relative"),
)
return jsonify({"success": True, **meta}), 200
@bp.post("/push")
def sync_push():
start_time = time.time()
used_slots, available_slots = sync_runtime.concurrency_status()
if not sync_runtime.acquire_slot():
logger.warning(
"[SYNC-PUSH-BLOCKED] Запрос заблокирован: все слоты заняты (%s/%s)",
used_slots,
Config.SYNC_MAX_CONCURRENT,
)
return error_response(
"Слишком много одновременных синхронизаций. Попробуйте позже.",
429,
)
logger.info(
"[SYNC-PUSH-START] Начало обработки, доступно слотов: %s/%s",
available_slots - 1,
Config.SYNC_MAX_CONCURRENT,
)
try:
data, parse_error = parse_json_request(request, allow_gzip=True)
if parse_error:
return error_response(parse_error, 400)
assert data is not None
client_id, changes, payload_error = parse_push_payload(data)
if payload_error:
return error_response(payload_error, 400)
assert client_id is not None
if not sync_runtime.check_rate_limit(client_id):
return error_response("Превышен лимит запросов. Попробуйте позже.", 429)
result = SyncManager.process_push(client_id=client_id, changes=changes)
_log_sync_manager_result("SYNC-PUSH", client_id, result)
return jsonify(result["payload"]), result["status_code"]
except Exception as e:
logger.error("[SYNC-PUSH-ERROR] Ошибка sync_push: %s", e, exc_info=True)
return error_response(str(e), 500)
finally:
logger.debug("[SYNC-PUSH-END] duration=%.3fs", time.time() - start_time)
sync_runtime.release_slot()
@@ -0,0 +1,72 @@
import json
from typing import Any, Dict, Optional, Tuple
from flask import Request
def parse_json_request(req: Request, allow_gzip: bool = False) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
"""Парсинг JSON-тела запроса с опциональной поддержкой gzip."""
if allow_gzip and req.headers.get("Content-Encoding", "") == "gzip":
import gzip
try:
payload = gzip.decompress(req.data)
data = json.loads(payload.decode("utf-8"))
except (gzip.BadGzipFile, json.JSONDecodeError, UnicodeDecodeError):
return None, "Ошибка распаковки сжатых данных"
if not isinstance(data, dict):
return None, "Тело запроса должно быть JSON-объектом"
return data, None
if not req.is_json:
return None, "Content-Type должен быть application/json"
data = req.get_json() or {}
if not isinstance(data, dict):
return None, "Тело запроса должно быть JSON-объектом"
return data, None
def parse_pull_payload(
data: Dict[str, Any],
) -> Tuple[Optional[str], Optional[int], Optional[str], Optional[str]]:
"""(client_id, limit, client_name, error). client_name — опционально для реестра на сервере."""
client_id = data.get("client_id")
if not client_id:
return None, None, None, "client_id обязателен"
limit = data.get("limit")
lim: Optional[int] = None
if limit is not None:
try:
lim = int(limit)
except (TypeError, ValueError):
return None, None, None, "limit должен быть числом"
cn_raw = data.get("client_name")
client_name: Optional[str] = None
if cn_raw is not None:
s = str(cn_raw).strip()
if s:
client_name = s[:100]
return str(client_id).strip(), lim, client_name, None
def parse_confirm_payload(data: Dict[str, Any]) -> Tuple[Optional[str], Optional[list], Optional[str]]:
client_id = data.get("client_id")
task_ids = data.get("task_ids") or []
if not client_id:
return None, None, "client_id обязателен"
if not isinstance(task_ids, list) or not task_ids:
return None, None, "task_ids обязателен и должен быть списком"
return client_id, task_ids, None
def parse_push_payload(data: Dict[str, Any]) -> Tuple[Optional[str], Any, Optional[str]]:
client_id = data.get("client_id")
if not client_id:
return None, None, "client_id обязателен"
changes = data.get("changes", [])
return client_id, changes, None