Files
ros_control/app/services/devices.py
T
ayurishchevandClaude Sonnet 5 0914209bf8 Групповое удаление бэкапов, поддержка CHR, строгое сравнение версий ROS
Групповое удаление бэкапов (docs/changes/013):
- страница «Резервные копии»: чекбоксы, «выбрать все», панель «Выбрано: N /
  Удалить / Снять выбор» вместо фильтров, подтверждение, итог удаления;
- backups.delete_many: все ключи проверяются до удаления, удаление параллельное;
  UI POST /backups/delete-many, API POST /api/v1/backups/delete;
- выбор строк в app.js обобщён (data-select) для устройств и бэкапов.

Поддержка CHR (docs/changes/014):
- у CHR нет /system/routerboard (HTTP 400): устройство больше не считается
  недоступным, версия FW не показывается, обновление FW пропускается;
- «есть обновление ROS» определяется строгим сравнением версий (на канале
  long-term последняя версия может быть старше установленной);
- в таблице указана причина недоступности: авторизация / ошибка ответа /
  нет соединения.

Тесты: 11 из 11.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 14:11:32 +03:00

170 lines
6.4 KiB
Python

import re
from dataclasses import dataclass
from app import security
from app.config import get_settings
from app.db import session_scope
from app.models import Device, Group
from app.ros.operations import version_newer
from app.ros.client import RosClient
NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$") # имя попадает в ключи S3 — только безопасные символы
@dataclass
class Conn:
id: int
name: str
host: str
port: int
username: str
password: str
verify_tls: bool
use_tls: bool
def validate_name(name: str) -> None:
if not NAME_RE.match(name):
raise ValueError("Имя: латиница, цифры, '.', '_', '-' (до 64 символов)")
def list_devices() -> list[Device]:
with session_scope() as s:
return list(s.query(Device).order_by(Device.name))
def get_device(device_id: int) -> Device:
with session_scope() as s:
d = s.get(Device, device_id)
if d is None:
raise LookupError(f"Устройство {device_id} не найдено")
return d
def _check_group(s, group_id: int | None) -> None:
if group_id is not None and s.get(Group, group_id) is None:
raise LookupError(f"Группа {group_id} не найдена")
NOTE_MAX = 500
def _clean_note(note: str | None) -> str | None:
note = (note or "").strip()
if len(note) > NOTE_MAX:
raise ValueError(f"Примечание: не более {NOTE_MAX} символов")
return note or None
def _resolve_group(s, group_id: int | None, new_group: str | None) -> int | None:
"""Группа устройства. new_group создаёт группу (или берёт существующую с таким именем)
в той же транзакции, что и устройство: при ошибке не остаётся лишней группы."""
name = (new_group or "").strip()
if name:
if len(name) > 64:
raise ValueError("Название группы: от 1 до 64 символов")
g = s.query(Group).filter_by(name=name).first()
if g is None:
g = Group(name=name)
s.add(g)
s.flush()
return g.id
_check_group(s, group_id)
return group_id
def create_device(name, host, port, username, password, verify_tls=False, use_tls=True,
group_id: int | None = None, new_group: str | None = None, note: str | None = None) -> Device:
validate_name(name)
note = _clean_note(note)
with session_scope() as s:
group_id = _resolve_group(s, group_id, new_group)
if s.query(Device).filter_by(name=name).first():
raise ValueError(f"Устройство с именем {name} уже существует")
d = Device(name=name, host=host, port=port, username=username,
password_enc=security.encrypt(password), verify_tls=verify_tls, use_tls=use_tls,
group_id=group_id, note=note)
s.add(d)
s.flush()
return d
def update_device(device_id: int, **fields) -> Device:
with session_scope() as s:
d = s.get(Device, device_id)
if d is None:
raise LookupError(f"Устройство {device_id} не найдено")
if fields.get("name") and fields["name"] != d.name:
validate_name(fields["name"])
d.name = fields["name"]
for k in ("host", "port", "username", "verify_tls", "use_tls"):
if fields.get(k) is not None:
setattr(d, k, fields[k])
if "group_id" in fields or fields.get("new_group"): # group_id=None — открепить от группы
d.group_id = _resolve_group(s, fields.get("group_id"), fields.get("new_group"))
if "note" in fields: # пустое примечание — очистить
d.note = _clean_note(fields["note"])
if fields.get("password"): # пустой пароль = не менять
d.password_enc = security.encrypt(fields["password"])
return d
def delete_device(device_id: int) -> None:
with session_scope() as s:
d = s.get(Device, device_id)
if d is None:
raise LookupError(f"Устройство {device_id} не найдено")
s.delete(d)
def get_conn(device_id: int) -> Conn:
d = get_device(device_id)
return Conn(d.id, d.name, d.host, d.port, d.username,
security.decrypt(d.password_enc), d.verify_tls, d.use_tls)
def open_client(conn: Conn) -> RosClient:
return RosClient(conn.host, conn.port, conn.username, conn.password,
conn.verify_tls, get_settings().ros_timeout, conn.use_tls,
connect_timeout=get_settings().ros_connect_timeout)
def has_ros_update(st: dict) -> bool:
return version_newer(st.get("ros_latest"), st.get("ros_installed"))
def has_fw_update(st: dict) -> bool:
return bool(st.get("fw_current") and st.get("fw_upgrade") and st["fw_upgrade"] != st["fw_current"])
def filter_devices(devs: list[Device], group: str = "", q: str = "", status: str = "",
updates: str = "", channel: str = "") -> list[Device]:
"""Общий фильтр списка устройств для UI и API (по кэшу статуса).
group: "" — все, "none" — без группы, иначе id группы. status: online/offline.
updates: ros / fw / any (есть обновление) / none (обновлений нет).
"""
q = q.strip().lower()
out = []
for d in devs:
st = d.status
if group == "none" and d.group_id is not None:
continue
if group not in ("", "none") and str(d.group_id) != group:
continue
if q and q not in " ".join((d.name, d.host, st.get("model") or "")).lower():
continue
if status == "online" and d.online is not True:
continue
if status == "offline" and d.online is True:
continue
ros_new, fw_new = has_ros_update(st), has_fw_update(st)
if (updates == "ros" and not ros_new) or (updates == "fw" and not fw_new):
continue
if (updates == "any" and not (ros_new or fw_new)) or (updates == "none" and (ros_new or fw_new)):
continue
if channel and st.get("channel") != channel:
continue
out.append(d)
return out