фикс категорий задач

This commit is contained in:
dv 2026-04-10 12:14:01 +03:00
parent 6bbd4b6dab
commit af1a14a6a5
3 changed files with 79 additions and 4 deletions

View file

@ -50,3 +50,39 @@ class BaseAction(ABC):
def is_compatible(self, category: str) -> bool: def is_compatible(self, category: str) -> bool:
return not self.compatible_categories or category in self.compatible_categories return not self.compatible_categories or category in self.compatible_categories
class CompositeAction(BaseAction):
"""Dispatches to different sub-actions based on device category.
Define routing as {category: action_instance}. The composite action's
compatible_categories is derived automatically from the routing keys.
Devices with a category not in routing are skipped with an error result.
"""
def __init__(
self,
name: str,
display_name: str,
routing: dict[str, "BaseAction"],
params_schema: list[dict] | None = None,
) -> None:
self.name = name
self.display_name = display_name
self._routing = routing
self.compatible_categories = list(routing.keys())
self.params_schema = params_schema or []
async def execute(
self,
device: Device,
obj: Object,
params: dict,
emit: Emitter,
) -> ActionResult:
action = self._routing.get(device.category)
if action is None:
msg = f"[{device.ip}] Нет обработчика для категории '{device.category}'"
await emit(msg, "error")
return ActionResult(success=False, error=msg)
return await action.execute(device, obj, params, emit)

View file

@ -18,7 +18,7 @@ from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from app.models.plugin import Plugin from app.models.plugin import Plugin
from app.plugins.base import BaseAction from app.plugins.base import BaseAction, CompositeAction
from app.plugins.builtin.camera.get_status import CameraGetStatusAction from app.plugins.builtin.camera.get_status import CameraGetStatusAction
from app.plugins.builtin.camera.set_ntp import CameraSetNTPAction from app.plugins.builtin.camera.set_ntp import CameraSetNTPAction
from app.plugins.builtin.common.ping import PingAction from app.plugins.builtin.common.ping import PingAction
@ -32,6 +32,27 @@ from app.plugins.builtin.ssh.ssh_command import SSHCommandAction
from app.plugins.registry import action_registry from app.plugins.registry import action_registry
from app.services.database import AsyncSessionLocal from app.services.database import AsyncSessionLocal
# Shared sub-action instances reused inside composite actions
_timedatectl = GetTimedatectlAction()
_mikrotik_get_ntp = MikrotikGetNTPAction()
_camera_get_status = CameraGetStatusAction()
# Composite: Get NTP Info — routes by device category
_get_ntp_info = CompositeAction(
name="get_ntp_info",
display_name="Get NTP Info",
routing={
"main_server": _timedatectl,
"vm": _timedatectl,
"server": _timedatectl,
"raspberry": _timedatectl,
"embedded": _timedatectl,
"router": _mikrotik_get_ntp,
"mikrotik": _mikrotik_get_ntp,
"camera": _camera_get_status,
},
)
# Public dict — used by Admin router for hot enable/disable # Public dict — used by Admin router for hot enable/disable
BUILTIN_ACTIONS: dict[str, BaseAction] = { BUILTIN_ACTIONS: dict[str, BaseAction] = {
a.name: a a.name: a
@ -39,13 +60,14 @@ BUILTIN_ACTIONS: dict[str, BaseAction] = {
PingAction(), PingAction(),
SSHCommandAction(), SSHCommandAction(),
DebianSystemInfoAction(), DebianSystemInfoAction(),
GetTimedatectlAction(), _timedatectl,
GetFileAction(), GetFileAction(),
MikrotikGetConfigAction(), MikrotikGetConfigAction(),
MikrotikGetNTPAction(), _mikrotik_get_ntp,
MikrotikSetNTPAction(), MikrotikSetNTPAction(),
CameraGetStatusAction(), _camera_get_status,
CameraSetNTPAction(), CameraSetNTPAction(),
_get_ntp_info,
] ]
} }

View file

@ -233,6 +233,23 @@ async def _run_task(task_id: str) -> None:
return return
targets = await _resolve_scope(scope) targets = await _resolve_scope(scope)
# Filter devices by action compatibility (compatible_categories)
if action.compatible_categories:
compatible = [(d, o) for d, o in targets if action.is_compatible(d.category)]
skipped = [(d, o) for d, o in targets if not action.is_compatible(d.category)]
if skipped:
skipped_info = ", ".join(
f"{d.ip} ({d.category})" for d, _ in skipped[:10]
)
suffix = f" и ещё {len(skipped) - 10}" if len(skipped) > 10 else ""
await _emit(
task_id,
f"Пропущено {len(skipped)} несовместимых устройств: {skipped_info}{suffix}",
"warn",
)
targets = compatible
if not targets: if not targets:
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
async with db.begin(): async with db.begin():