diff --git a/aaa/models/user.py b/aaa/models/user.py index 2cb4466235..b31ecb55c0 100644 --- a/aaa/models/user.py +++ b/aaa/models/user.py @@ -36,7 +36,7 @@ ("sa.UserAccess", "user"), ("kb.KBUserBookmark", "user"), ("main.Checkpoint", "user"), - ("main.NotificationGroupUser", "user"), + ("main.NotificationGroupUserSubscription", "user"), ("main.ReportSubscription", "run_as"), ("ip.PrefixBookmark", "user"), ("kb.KBEntryHistory", "user"), diff --git a/commands/wipe.py b/commands/wipe.py index 673460d713..eef65556ce 100644 --- a/commands/wipe.py +++ b/commands/wipe.py @@ -211,7 +211,7 @@ def wipe_user(self, o): :type o: User :return: None """ - from noc.main.models.notificationgroup import NotificationGroupUser + from noc.main.models.notificationgroup import NotificationGroupUserSubscription from noc.main.models.audittrail import AuditTrail from noc.aaa.models.usercontact import UserContact from noc.aaa.models.permission import Permission @@ -233,7 +233,7 @@ def wipe_user(self, o): AuditTrail.objects.filter(user=o.username).delete() # Clean NotificationGroupUser with self.log("Cleaning notification groups"): - NotificationGroupUser.objects.filter(user=o).delete() + NotificationGroupUserSubscription.objects.filter(user=o).delete() # Clean User contact with self.log("Cleaning user contact"): UserContact.objects.filter(user=o).delete() diff --git a/core/matcher.py b/core/matcher.py index 8a1788cdd3..0ea54d48cd 100644 --- a/core/matcher.py +++ b/core/matcher.py @@ -27,7 +27,7 @@ def get_matcher(op: str, field: str, value: Any) -> Callable: case "$regex": value = re.compile(value) case "$in" | "$all" | "$any": - value = frozenset(str(v) for v in value) + value = frozenset(v for v in value) case "$eq": value = value case _: @@ -84,7 +84,7 @@ def match_regex(rx: re.Pattern, field: str, ctx: Dict[str, Any]) -> bool: def match_in(c_iter: FrozenSet, field: str, ctx: Dict[str, Any]) -> bool: - return str(ctx[field]) in c_iter + return ctx[field] in c_iter def match_all(c_iter: FrozenSet, field: str, ctx: Dict[str, Any]) -> bool: diff --git a/core/mx.py b/core/mx.py index 6de52fa5e1..930d309b3e 100644 --- a/core/mx.py +++ b/core/mx.py @@ -16,6 +16,7 @@ from noc.core.service.loader import get_service from noc.core.comp import DEFAULT_ENCODING from noc.core.ioloop.util import run_sync +from noc.models import get_model_id @dataclass @@ -43,6 +44,7 @@ class Message(object): MX_PROFILE_ID = "Profile-Id" MX_LABELS = "Labels" MX_RESOURCE_GROUPS = "Resource-Group-Ids" +MX_WATCH_FOR_ID = "Watch-For-Id" # Notification headers MX_TO = "To" MX_NOTIFICATION = b"notification" @@ -76,6 +78,40 @@ class MessageType(enum.Enum): OTHER = "other" +@dataclass(frozen=True) +class MetaConfig(object): + header: str + is_list: bool = False + + +CONFIGS = { + "watch_for": MetaConfig(MX_WATCH_FOR_ID), + "profile": MetaConfig(MX_PROFILE_ID), + "groups": MetaConfig(MX_RESOURCE_GROUPS, is_list=True), + "administrative_domain": MetaConfig(MX_ADMINISTRATIVE_DOMAIN_ID), + "from": MetaConfig(MX_DATA_ID), + "labels": MetaConfig(MX_LABELS, is_list=True), +} + + +class MessageMeta(enum.Enum): + @property + def config(self) -> MetaConfig: + return CONFIGS[self.value] + + WATCH_FOR = "watch_for" + PROFILE = "profile" + GROUPS = "groups" + ADM_DOMAIN = "administrative_domain" + FROM = "from" + LABELS = "labels" + + def clean_header_value(self, value: Any) -> bytes: + if self.config.is_list: + return MX_H_VALUE_SPLITTER.join(value).encode(DEFAULT_ENCODING) + return str(value).encode(DEFAULT_ENCODING) + + MESSAGE_HEADERS = { MX_SHARDING_KEY, MX_CHANGE_ID, @@ -102,11 +138,11 @@ def send_message( """ Build message and schedule to send to mx service - :param data: Data for transmit - :param message_type: Message type - :param headers: additional message headers - :param sharding_key: Key for sharding over MX services - :return: + Attrs: + data: Data for transmit + message_type: Message type + headers: additional message headers + sharding_key: Key for sharding over MX services """ msg_headers = { MX_MESSAGE_TYPE: message_type.value, @@ -134,11 +170,11 @@ def send_notification( ): """ Send notification to notification_group ot address - :param subject: Notification Title - :param body: Notification body - :param to: notification address - :param notification_method: Notification method (for to param) - :return: + Attrs: + subject: Notification Title + body: Notification body + to: notification address + notification_method: Notification method (for to param) """ if notification_method not in NOTIFICATION_METHODS: raise ValueError("Unknown notification method: %s" % notification_method) @@ -155,11 +191,12 @@ def send_notification( def get_mx_partitions() -> int: - """ - Get number of MX stream partitions - :return: - """ + """Get number of MX stream partitions""" from noc.core.msgstream.config import get_stream cfg = get_stream(MX_STREAM) return cfg.get_partitions() + + +def get_subscription_id(o) -> str: + return f"m:{get_model_id(o)}:{o.id}" diff --git a/core/router/action.py b/core/router/action.py index d08e4bd410..9fbb557e59 100644 --- a/core/router/action.py +++ b/core/router/action.py @@ -21,6 +21,8 @@ MX_NOTIFICATION_METHOD, MX_NOTIFICATION_DELAY, MX_NOTIFICATION_GROUP_ID, + MX_WATCH_FOR_ID, + MessageMeta, ) from noc.config import config @@ -173,7 +175,10 @@ def iter_action( ng = self.get_notification_group(msg.headers.get(MX_NOTIFICATION_GROUP_ID)) if not ng: return - for method, headers, render_template in ng.iter_actions(): + for method, headers, render_template in ng.iter_actions( + message_type.decode(), + {MessageMeta.WATCH_FOR: msg.headers[MX_WATCH_FOR_ID].decode()}, + ): yield NOTIFICATION_METHODS[method].decode(), headers, body diff --git a/core/router/base.py b/core/router/base.py index 7ca28cc0a7..a4c4bc1331 100644 --- a/core/router/base.py +++ b/core/router/base.py @@ -44,25 +44,23 @@ def __init__(self): # self.out_queue: Optional[QBuffer] = None def load(self): - """ - Load up all the rules and populate the chains - :return: - """ + """Load up all the rules and populate the chains""" from noc.main.models.messageroute import MessageRoute num = 0 for num, route in enumerate( MessageRoute.objects.filter(is_active=True).order_by("order"), start=1 ): - self.chains[route.type] += [Route.from_data(route.get_route_config())] + cfg = route.get_route_config() + cfg["type"] = cfg["type"].value + self.chains[route.type] += [Route.from_data(cfg)] logger.info("Loading %s route", num) self.rebuild_chains() def has_route(self, route_id: str) -> bool: """ Check Route already exists in chains - :param route_id: - :return: + route_id: Router identifier """ return route_id in self.routes @@ -73,28 +71,28 @@ def change_route(self, data): * change type = delete + insert * change order = reorder * change data = update - :param data: - :return: + Attrs: + data: """ r = Route.from_data(data) route_id = data["id"] to_rebuild = set() if not self.has_route(route_id): self.routes[data["id"]] = r - to_rebuild.add(r.type) + to_rebuild |= r.m_types logger.info("[%s|%s] Insert route", route_id, data["name"]) self.rebuild_chains(to_rebuild) return - if self.routes[route_id].type != r.type: + if self.routes[route_id].m_types != r.m_types: # rebuild logger.info( "[%s|%s] Change route chain: %s -> %s", route_id, data["name"], - self.routes[route_id].type, - r.type, + b";".join(sorted(self.routes[route_id].m_types)), + b";".join(sorted(r.m_types)), ) - to_rebuild.add([r.type, self.routes[route_id].type]) + to_rebuild |= r.m_types.symmetric_difference(self.routes[route_id].m_types) self.routes[route_id].set_type(r.type) if self.routes[route_id].order != r.order: logger.info( @@ -105,7 +103,7 @@ def change_route(self, data): r.order, ) self.routes[route_id].set_order(r.order) - to_rebuild.add(r.type) + to_rebuild |= r.m_types if self.routes[route_id].is_differ(data): logger.info("[%s|%s] Update route", route_id, data["name"]) self.routes[route_id].update(data) @@ -115,37 +113,43 @@ def change_route(self, data): def delete_route(self, route_id: str): """ Delete Route from Chains - :param route_id: - :return: + Attrs: + route_id: Router Identifiers """ r_type = None if route_id in self.routes: logger.info("[%s|%s] Delete route", route_id, self.routes[route_id].name) - r_type = self.routes[route_id].type + r_type = self.routes[route_id].m_types del self.routes[route_id] if r_type: - self.rebuild_chains([r_type], deleted=True) + self.rebuild_chains(r_type, deleted=True) - def rebuild_chains(self, r_types: Optional[Iterable[str]] = None, deleted: bool = False): + def rebuild_chains(self, r_types: Optional[Iterable[bytes]] = None, deleted: bool = False): """ Rebuild Router Chains Need lock ? - :param r_types: List types for rebuild chains - :param deleted: Route was deleted - :return: + Attrs: + r_types: List types for rebuild chains + deleted: Route was deleted flag """ chains = defaultdict(list) + r_types = frozenset(r_types) if r_types else None for rid, r in self.routes.items(): - if r_types and r.type not in r_types and rid != self.DEFAULT_CHAIN: + if r_types and not r.m_types.intersection(r_types) and rid != self.DEFAULT_CHAIN: continue - chains[r.type].append(r) + elif r_types: + updated_types = r.m_types.intersection(r_types) + else: + updated_types = r.m_types + for tt in updated_types: + chains[tt].append(r) if deleted: # Remove last route for rt in set(r_types) - set(chains): chains[rt] = [] for chain in chains: logger.info("[%s] Rebuild chain", chain) - self.chains[chain.encode(encoding=DEFAULT_ENCODING)] = list( + self.chains[chain] = list( sorted( [r for r in chains[chain]], key=operator.attrgetter("order"), @@ -174,8 +178,7 @@ async def publish( def route_sync(self, msg: Message): """ Synchronize method - :param msg: - :return: + msg: Route Message """ run_sync(partial(self.route_message, msg)) @@ -189,13 +192,12 @@ def get_message( ) -> Message: """ Build message - - :param data: Data for transmit - :param message_type: Message type - :param headers: additional message headers - :param sharding_key: Key for sharding - :param raw_value: - :return: + Attrs: + data: Data for transmit + message_type: Message type + headers: additional message headers + sharding_key: Key for sharding + raw_value: """ msg_headers = { MX_MESSAGE_TYPE: message_type.encode(DEFAULT_ENCODING), @@ -215,9 +217,9 @@ def get_message( async def route_message(self, msg: Message, msg_id: Optional[str] = None): """ Route message by rule - :param msg: - :param msg_id: - :return: + Attrs: + msg: Received Message + msg_id: Message sequence number """ mt = msg.headers.get(MX_MESSAGE_TYPE) if not mt: diff --git a/core/router/route.py b/core/router/route.py index 260a855084..c23c7d5859 100644 --- a/core/router/route.py +++ b/core/router/route.py @@ -6,9 +6,21 @@ # ---------------------------------------------------------------------- # Python modules -from typing import Tuple, Dict, List, Iterator, Callable, Union, Any, Optional, DefaultDict, Literal +import re +from typing import ( + Tuple, + Dict, + List, + Iterator, + Iterable, + Callable, + Union, + Any, + Optional, + Literal, + FrozenSet, +) from dataclasses import dataclass -from collections import defaultdict # Third-party modules from jinja2 import Template as JTemplate @@ -16,16 +28,17 @@ # NOC modules from noc.core.msgstream.message import Message +from noc.core.matcher import build_matcher from noc.core.comp import DEFAULT_ENCODING from noc.core.mx import ( - MX_LABELS, MX_H_VALUE_SPLITTER, - MX_ADMINISTRATIVE_DOMAIN_ID, - MX_RESOURCE_GROUPS, MX_NOTIFICATION_CHANNEL, MX_NOTIFICATION, MX_NOTIFICATION_GROUP_ID, + MX_LABELS, + MX_RESOURCE_GROUPS, MessageType, + MessageMeta, ) from .action import Action, NotificationAction, ActionCfg @@ -80,7 +93,7 @@ def is_re(self) -> bool: class MatchItem(object): labels: Optional[List[str]] = None exclude_labels: Optional[List[str]] = None - administrative_domain: Optional[int] = None + administrative_domain: Optional[List[int]] = None resource_groups: Optional[List[str]] = None profile: Optional[str] = None headers: Optional[List[HeaderMatchItem]] = None @@ -104,6 +117,31 @@ def from_data(cls, data: List[Dict[str, Any]]) -> List["MatchItem"]: ] return r + def get_match_expr(self): + r = {} + if self.labels: + r[MessageMeta.LABELS] = {"$all": frozenset(ll.encode() for ll in self.labels)} + if self.exclude_labels: + r[MessageMeta.LABELS] = {"$nin": frozenset(ll.encode() for ll in self.exclude_labels)} + if self.resource_groups: + r[MessageMeta.GROUPS] = {"$all": frozenset(x.encode() for x in self.resource_groups)} + if self.administrative_domain: + r[MessageMeta.ADM_DOMAIN.config.header] = { + "$in": frozenset(str(ad).encode() for ad in self.administrative_domain), + } + if self.profile: + r[MessageMeta.PROFILE.config.header] = str(self.profile).encode() + if not self.headers: + return r + for h in self.headers: + if h.op == "regex": + r[h.header] = {"$regex": re.compile(h.value.encode(DEFAULT_ENCODING))} + elif h.op == "!=": + continue + else: + r[h.header.encode()] = h.value.encode(DEFAULT_ENCODING) + return r + class Route(object): """ @@ -115,37 +153,52 @@ class Route(object): def __init__(self, name: str, r_type: str, order: int, telemetry_sample: Optional[int] = None): self.name = name - self.type = r_type + self.type: FrozenSet[bytes] = ( + frozenset([r_type.encode()]) + if isinstance(r_type, str) + else frozenset(x.encode() for x in r_type) + ) self.order = order self.telemetry_sample = telemetry_sample or 0 - self.match_co: str = "" # Code object for matcher + self.match_co: Optional[Callable] = None # Code object for matcher self.actions: List[Action] = [] self.transmute_handler: Optional[Callable[[Dict[str, bytes], T_BODY], T_BODY]] = None self.transmute_template: Optional[TransmuteTemplate] = None - def is_match(self, msg: Message, message_type: bytes) -> bool: - """ - Check if the route is applicable for messages + @property + def m_types(self) -> FrozenSet[bytes]: + return self.type - :param msg: - :param message_type: - :return: - """ - ctx = {"headers": msg.headers, "labels": set(), "resource_groups": set()} + def get_match_ctx(self, msg: Message) -> Dict[MessageMeta, Any]: + ctx = {} if MX_LABELS in msg.headers and msg.headers[MX_LABELS]: - ctx["labels"] = set(msg.headers[MX_LABELS].split(self.MX_H_VALUE_SPLITTER)) + ctx[MessageMeta.LABELS] = frozenset( + msg.headers[MX_LABELS].split(self.MX_H_VALUE_SPLITTER) + ) if MX_RESOURCE_GROUPS in msg.headers and msg.headers[MX_RESOURCE_GROUPS]: - ctx["resource_groups"] = set( + ctx[MessageMeta.GROUPS] = frozenset( msg.headers[MX_RESOURCE_GROUPS].split(self.MX_H_VALUE_SPLITTER) ) - return eval(self.match_co, ctx) + ctx.update(msg.headers) + return ctx + + def is_match(self, msg: Message, message_type: bytes) -> bool: + """ + Check if the route is applicable for messages + Attrs: + msg: message for processed + message_type: Message Type + """ + if not self.match_co: + return True + return self.match_co(self.get_match_ctx(msg)) def transmute(self, headers: Dict[str, bytes], data: bytes) -> Union[bytes, Dict[str, Any]]: """ Transmute message body and apply template - :param headers: - :param data: - :return: + Attrs: + headers: Message Headers + data: Message Body """ if self.transmute_handler: data = self.transmute_handler(headers, data) @@ -167,8 +220,11 @@ def iter_action( for a in self.actions: yield from a.iter_action(msg, message_type) - def set_type(self, r_type: str): - self.type = r_type.encode(encoding=DEFAULT_ENCODING) + def set_type(self, r_type: Union[str, FrozenSet]): + if isinstance(r_type, Iterable): + self.type = frozenset(x.encode() for x in r_type) + else: + self.type = frozenset([r_type.encode(encoding=DEFAULT_ENCODING)]) def set_order(self, order: int): self.order = order @@ -181,83 +237,40 @@ def is_differ(self, data) -> bool: """ return True + @classmethod + def get_matcher(cls, match) -> Optional[Callable]: + """""" + expr = [] + for r in MatchItem.from_data(match): + expr.append(r.get_match_expr()) + if not expr: + return None + if len(expr) == 1: + return build_matcher(expr[0]) + return build_matcher({"$or": expr}) + def update(self, data): from noc.main.models.template import Template from noc.main.models.handler import Handler - self.match_co = self.compile_match(MatchItem.from_data(data["match"])) + self.match_co = self.get_matcher(data["match"]) # Compile transmute part # r.transmutations = [Transmutation.from_transmute(t) for t in route.transmute] if "transmute_handler" in data: h = Handler.get_by_id(data["transmute_handler"]) self.transmute_handler = h.get_handler() if h else None if "transmute_template" in data: - template = Template.objects.get(id=data["transmute_template"]) + template = Template.get_by_id(data["transmute_template"]) self.transmute_template = TransmuteTemplate(JTemplate(template.body)) # Compile action part self.actions = [Action.from_data(data)] - @classmethod - def compile_match(cls, matches: List[MatchItem]): - expr = [] - # Compile match section - match_eq: DefaultDict[str, List[bytes]] = defaultdict(list) - match_re: DefaultDict[str, List[bytes]] = defaultdict(list) - match_ne: List[Tuple[str, bytes]] = [] - for match in matches: - if match.labels: - expr += [ - f"{set(ll.encode(encoding=DEFAULT_ENCODING) for ll in match.labels)!r}.intersection(labels)" - ] - if match.exclude_labels: - expr += [ - f"not {set(ll.encode(encoding=DEFAULT_ENCODING) for ll in match.exclude_labels)!r}.intersection(labels)" - ] - if match.administrative_domain: - expr += [ - f"int(headers[{MX_ADMINISTRATIVE_DOMAIN_ID!r}]) in {set(match.administrative_domain)}" - ] - if match.resource_groups: - expr += [ - f"{set(rg.encode(encoding=DEFAULT_ENCODING) for rg in match.resource_groups)!r}.intersection(resource_groups)" - ] - for h_match in match.headers: - if h_match.is_eq: - match_eq[h_match.header] += [h_match.value.encode(encoding=DEFAULT_ENCODING)] - elif h_match.is_ne: - match_ne += [(h_match.header, h_match.value.encode(encoding=DEFAULT_ENCODING))] - elif h_match.is_re: - match_re[h_match.header] += [h_match.value.encode(encoding=DEFAULT_ENCODING)] - # Expression for == - for header in match_eq: - if len(match_eq[header]) == 1: - # == - expr += [ - f"{header!r} in headers and headers[{header!r}] == {match_eq[header][0]!r}" - ] - else: - # in - expr += [ - f'{header!r} in headers and headers[{header!r}] in ({", ".join("%r" % x for x in match_eq[header])!r})' - ] - # Expression for != - for header, value in match_ne: - expr += [f"{header!r} in headers and headers[{header!r}] != {value!r}"] - # Expression for regex - # @todo - # Compile matching code - if expr: - cond_code = " and ".join(expr) - else: - cond_code = "True" - return compile(cond_code, "", "eval") - @classmethod def from_data(cls, data) -> "Route": """ Build Route from data config - :param data: - :return: + Attrs: + data: Datastream record """ r = Route(data["name"], data["type"], data["order"], data.get("telemetry_sample")) r.update(data) diff --git a/docs/message-notification/index.md b/docs/message-notification/index.md new file mode 100644 index 0000000000..4f54a848d6 --- /dev/null +++ b/docs/message-notification/index.md @@ -0,0 +1 @@ +# Message Notification diff --git a/docs/message-notification/index.ru.md b/docs/message-notification/index.ru.md new file mode 100644 index 0000000000..a81333a551 --- /dev/null +++ b/docs/message-notification/index.ru.md @@ -0,0 +1,178 @@ +# Уведомления по событиям системы + +Для оперативного информирования пользователя (*User*) (или внешней системы) по системным событиям в НОКе используется механизм **Уведомлений** (*Notification*). В состав механизма работы с уведомлениями входят следующие компоненты: + +* **Сообщение** (*Message*) - набор сведений о произошедшем событии +* **Группы уведомлений** (*Notification Group*) - списки контактов для получения уведомления. Находятся в меню *Основные (Main) -> Группы уведомлений (Notification Group)* +* **Маршруты доставки** (*Message Route*) - настройки получателей событий системы. Находятся в меню *Основные (Main) -> Маршруты сообщений (Message Route)* +* **Настройки пользователя** (*Notification User Settings*) - настройки доставки уведомлений пользователю. Включают в себя: + * Список контактов для доставки в *Профиле пользователя* (*User Profile*) + * Указание времени в рамках которого допустима отправка уведомлений + * Настройки групп уведомлений (*Notification Group*) +* **Контакты** (*Contacts*) - идентификатор получателя в канале доставки (*Notification Channel*) +* **Подписка на уведомления** (*Notification Subscription*) - список объектов по которым пользователь получает уведомления +* *Маршрутизатор сообщений* (*Message Route*) - компонент системы, отвечающий за доставку сообщений адресату. Находится внутри любого процесса, отправляющего сообщений пользователю. +* **MX** - сервис, отвечающий за доставку сообщений внешних отправителей (*Remote Sender*) + +За доставку уведомлений система использует сервисы-отправители (*Sender*), на текущий момент доступно 2 отправителя: + +* *mailsender* - доставка по почте +* *tgsender* - доставка сообщения в телеграмм + +### Процедура отправки уведомления + +При наступлении события в системе, оно порождает сообщение, заполняя его контекстом (набором данных), в котором событие произошло. Само *сообщение* (*Message*) состоит из 3 частей: + +* **тип сообщения** (*Message Type*) - описание содержимого сообщения +* **метаданные отправителя** (*MetaData*) - дополнительная информация по отправителю. Передаются в заголовке +* **тело сообщения** (*Body*) - структура `JSON` содержащая контекст события +* *вложения* (*Attachments*) - вложения + +После создания сообщения (*Message*) передаётся попадает в специальный компонент - *маршрутизатор события* (*Message Router*), которых на базе набора правил выполняет одно или несколько действий: + +* **Notification** - зарегистрировать событие в *Группе уведомления* (*Notification Group*) +* **Stream** - передать событие в соответствующий *топик* (*topic*) внутренней шины. Обычно это *kafkasender* для передачи сообщения внешним системам через сервис *Kafka* +* **Dump** - распечатать событие в логе сервиса отправителя +* **Drop** - остановить обработку события + +Правила для маршрутизации представляют собой список, отсортированный в порядке возрастания по полю *order*. Поэтому на сообщение может быть выполнено несколько действий. Основной критерий - это тип события **Message Type**, дополнительные это условия по объекту (*Managed Object*) по которому создано уведомление. В системе представлены следующие типы событий: + +| Тип сообщения | Сервис | ПРичина | Настройка (раздел message) | Описание | +| --------------------------- |----------------------------------------------------------------------------------| ------------------------------------------- |-------------------------------------|--------------------------------------------------------------------------------| +| **alarm** | Correlator | Открытие, изменение и закрытие аварии | enable_alarm | [alarm](../mx-messages-reference/alarm.md) | +| **managedobject** | - | | enable_managedobject | [managedobject](../mx-messages-reference/managedobject.md) | +| **reboot** | [Uptime Discovery](../discovery-reference/periodic/uptime.md) | Переход аптайма устройства через 0 | enable_reboot | [reboot](../mx-messages-reference/reboot.md) | +| **snmptrap** | SNMP Trap Collector | Получение сообщения SNMP с устройства | enable_snmptrap | [snmptrap](../mx-messages-reference/snmptrap.md) | +| **syslog** | Syslog Collector | Получение Syslog сообщения с устройства | enable_syslog | [syslog](../mx-messages-reference/syslog.md) | +| **event** | Classifier | Получение нового события из коллектора | enable_event | [event](../mx-messages-reference/event.md) | +| **interface_status_change** | [Interface Status Discovery](../discovery-reference/periodic/interfacestatus.md) | Изменение оперативного состояние интерфейса | - | [interface_status_change](../mx-messages-reference/interface_status_change.md) | +| **config_changed** | [Config Discovery](../discovery-reference/box/config.md) | Изменение текстовой конфигурации устройства | - | [config_changed](../mx-messages-reference/config_changed.md) | +| **object_new** | - | Добавление устройства (Managed object) | - | [object_new](../mx-messages-reference/object_new.md) | +| **object_deleted** | - | Удаление устройства (Managed Object) | - | [object_deleted](../mx-messages-reference/object_deleted.md) | +| **version_changed** | [Version Discovery](../discovery-reference/box/version.md) | Изменение версии ПО устройства | - | [version_changed](../mx-messages-reference/version_changed.md) | +| **config_policy_violation** | [Config Discovery](../discovery-reference/box/config.md) | Обнаружение проблем в конфигурации | - | [config_policy_violation](../mx-messages-reference/config_policy_violation.md) | +| **diagnostic_change** | - | | - | | +| **notification** | - | Отправка текстового уведомления | - | | +| **metrics** | Metrics Service | Поступление значений метрик в системе | enable_metrics,enable_metric_scopes | [metrics](../mx-messages-reference/metrics.md) | + +!!! note + + На тип сообщения **metrics**, запрещено настраивать уведомления (*Notification*) + +Параметры в колонке **Настройка** указываются в секции **message** глобальной конфигурации: + +```shell +./noc config dump message +message: + ds_limit: 1000 + embedded_router: true + enable_alarm: false + enable_diagnostic_change: false + enable_event: false + enable_managedobject: false + enable_metric_scopes: [] + enable_metrics: false + enable_reboot: false + enable_snmptrap: false + enable_syslog: false +``` + +## Настройка уведомлений + +Уведомление формируется на базе *сообщения* (*Message*) путём применения **шаблона** (*Template*). Для настройки их доставки адресатам отвечают **Группы уведомлений** (*Notification Group*). Она объединяет контакты и пользователей, которым необходимо доставить уведомление. Сами сообщения могут попасть в группу тремя способами (регулируется настройкой): + +* Указание в настройках источника. Пример: подписка на отчёты, профиль сервиса. В этом случае сообщения регистрируются в группе и отправляются зарегистрированным контактам. +* Любые сообщения системы. В этом случае в группу попадают все сообщения, регистрируемые в системе +* Указанных типов. В группе попадают сообщения, перечисленные в разделе типов + +![](notification_groups_form_config_changed_en.png) + +После попадания в группу, сообщение проверяется на соответствие **критериев** (*Condition*), если таковые указаны. И после этого передаётся для отправки адресатам. Адресатом в группе может быть *пользователь* (*User*) или контакт (*Contact*), также доступны следующие настройки: + +* **Имя** (*Name*) +* **Описание** (*Description*) +* **Message Register Policy** - политика регистрации сообщений + * *Disable* - отключить автоматическую регистрацию сообщений + * *Any* - любые сообщения + * *By Types* - только для сообщений, описанных в типах +* **Message Types** - настройки для типов сообщений + * *Message Type* - тип сообщения + * *Template* - ссылка на шаблон +* **Адресаты** (*Static Members*) - список адресатов для отправки зарегистрированных сообщений + * *Notification Method* - канал отправки: почта, tg - telegram + * *Contact* - адресат + * *Language* - язык для отправки + * *Time Pattern* - ссылка на маску времени +* **Настройки подписки** (*Subscription Settings*) - настройки подписки на группу + * *Пользователь* (*User*) - ссылка на пользователя + * *Группа* (*Group*) - ссылка на группу пользователей + * *Разрешить подписку* (*Allow Subscribe*) - разрешить пользователю подписываться на группу + * *Auto Subscription* - автоматически включить пользователя в рассылку + * *Notify If Subscribed* - уведомлять при изменениях подписки. Например, если пользователь включается или исключается из группы +* Критерии (*Conditions*) - условия для совпадения сообщений + * *Labels* - набор меток + * *Resource Groups* - набор групп + * *Administrative Domain* - зона ответственности + +!!! note + + Если в группе не указаны критерии (*Conditions*) то считается что подходят любые сообщения указанного типа + +## Настройка маршрутизации сообщений + +Расширенные настройки обработки сообщения доступны в меню **Маршруты Сообщения** (*Main -> Message Routes*). Они предоставляют следующие возможности: + +* Модифицировать сообщение +* Зарегистрировать уведомление в группе. При регистрации игнорируются настройки *Message Register Policy* группы +* Переслать сообщение во внешнюю систему (через шину) + +![](message_route_form_interface_status_change_en.png) + +Поведение устанавливается + +* **Имя** (*Name*) - наименование правила +* **Активно** (*Is Active*) - правило включено в работу +* **Описание** (*Description*) +* **Порядок** (*Order*) - порядок обработки +* **Тип сообщения** (*Type*) +* **Критерии** (*Match*) - критерии совпадения сообщения с правилом. Если не указаны - выполнится для любого + * *Метки* (*Labels*) + * *Группы* (*Resource Groups*) + * *Зоны Ответственности* (*Administrative Domain*) + * *Headers Match* - совпадение заголовков сообщения +* *Telemetry Sample* - отправка телеметрии по обработке сообщения +* *Преобразователь сообщения* (*Transmute Handler*) - обработчик для сообщения, позволяет изменить заголовки и тело (*body*) передаваемого сообщения. Необходимо регистрация в *Handlers* +* *Шаблон преобразования* (*Transmute Template*) - шаблон (*template*) для тела сообщения +* **Действие** (*Action*) + * *Notification* - отправить уведомление на группу, указанную в настройке (*Notification Group*) + * *Stream* - переслать сообщение в топик, указанный в настройке *Stream* + * *Dump* - распечатать сообщение в логе сервиса + * *Drop* - остановить обработку сообщения +* *Stream* - название потока во внутренней шине +* *Группа уведомления* (*Notification Group*) - группа, на которую отправить уведомление. Работает при действии *Notification* +* Шаблон уведомления (*Render Template*) - шаблон уведомления. Если не задан будет использоваться по умолчанию для соответствующего типа (*Message Type*) +* Заголовки (*Headers*) - установить заголовки для сообщения + +### Изменение шаблона сообщения + +Для создания уведомления необходимо к сообщению применить *Шаблон* (*Template*). Таковые настраиваются в меню *Main -> Setup -> Templates*. Для работы с шаблонами используется библиотека [Jinja2](https://jinja.palletsprojects.com), набор доступных переменных можно найти в таблице типов сообщений (колонка *Описание*). + +![](templates_form_config_changed_en.png) + +!!! warning + + Поле Тип сообщения **MessageType** задаёт шаблон по умолчанию для типа сообщения. Менять его не желательно + +## Проверка работы + +В том случае если после выполненных настроек уведомление не приходит необходимо удостовериться в правильности настроек всех компонентов. + +Для проверки **Группы уведомления** (*Notification Group*) на панели инструментов списка есть кнопка **Group Action** а в ней пункт **Test Selected Group**. На контакты из выбранных групп должно придти тестовое сообщение. В случае отсутствия такого необходимо проверить правильность указанных контактов и запущены ли соответствующие сервисы: *mailsender* и *tgsender*, выполнена ли их настройка. + +Если *тестовое сообщение* **дошло** до указанных контактов (*Contact*). То возможная причина в том, что сервисы не подхватили настройки. Обновить их можно командой `./noc datastream rebuild --datastream cfgmxroute` и после этого перезапустить НОК. + +## Подписка на уведомления + +Настройка уведомлений для пользователя + +Подписка на уведомления по объекту diff --git a/docs/message-notification/message_route_form_interface_status_change_en.png b/docs/message-notification/message_route_form_interface_status_change_en.png new file mode 100644 index 0000000000..c5cba28257 Binary files /dev/null and b/docs/message-notification/message_route_form_interface_status_change_en.png differ diff --git a/docs/message-notification/notification_groups_form_config_changed_en.png b/docs/message-notification/notification_groups_form_config_changed_en.png new file mode 100644 index 0000000000..47ef5edf1f Binary files /dev/null and b/docs/message-notification/notification_groups_form_config_changed_en.png differ diff --git a/docs/message-notification/templates_form_config_changed_en.png b/docs/message-notification/templates_form_config_changed_en.png new file mode 100644 index 0000000000..d4cc064cd1 Binary files /dev/null and b/docs/message-notification/templates_form_config_changed_en.png differ diff --git a/main/migrations/0076_notification_group_add_subscription.py b/main/migrations/0076_notification_group_add_subscription.py new file mode 100644 index 0000000000..1c04868e17 --- /dev/null +++ b/main/migrations/0076_notification_group_add_subscription.py @@ -0,0 +1,149 @@ +# ---------------------------------------------------------------------- +# Add message_type to Template models +# ---------------------------------------------------------------------- +# Copyright (C) 2007-2024 The NOC Project +# See LICENSE for details +# ---------------------------------------------------------------------- + +# Python modules +import uuid +from collections import defaultdict + +# Third-party modules +import orjson +from django.db import models +from django.contrib.postgres.fields import ArrayField + +# NOC modules +from noc.core.model.fields import DocumentReferenceField +from noc.core.migration.base import BaseMigration + + +class Migration(BaseMigration): + + def create_subscription(self): + # Mock Models + NotificationGroup = self.db.mock_model( + model_name="NotificationGroup", db_table="main_notificationgroup" + ) + TimePattern = self.db.mock_model(model_name="TimePattern", db_table="main_timepattern") + User = self.db.mock_model(model_name="User", db_table="auth_user") + + # Model 'NotificationGroupUser' + self.db.create_table( + "main_notificationgroupusersubscription", + ( + ("id", models.AutoField(verbose_name="ID", primary_key=True, auto_created=True)), + ( + "notification_group", + models.ForeignKey( + NotificationGroup, + verbose_name="Notification Group", + on_delete=models.CASCADE, + ), + ), + ( + "time_pattern", + models.ForeignKey( + TimePattern, + verbose_name="Time Pattern", + on_delete=models.CASCADE, + null=True, + blank=True, + ), + ), + ("user", models.ForeignKey(User, verbose_name="User", on_delete=models.CASCADE)), + ( + "expired_at", + models.DateTimeField("Expired Subscription After", null=True, blank=True), + ), + ("suppress", models.BooleanField("Deactivate Subscription", default=False)), + ( + "watch", + models.CharField("Watch key", max_length=100, null=True, blank=True), + ), + ("remote_system", DocumentReferenceField("self", null=True, blank=True)), + ), + ) + self.db.create_index( + "main_notificationgroupusersubscription", + [ + "notification_group_id", + "user_id", + "watch", + ], + unique=True, + ) + + def migrate(self): + self.create_subscription() + self.db.add_column("main_notificationgroup", "uuid", models.UUIDField(null=True)) + for id in self.db.execute("SELECT id FROM main_notificationgroup"): + u = str(uuid.uuid4()) + self.db.execute( + "UPDATE main_notificationgroup SET uuid=%s WHERE id =%s and uuid IS NULL", [u, id] + ) + self.db.add_column( + "main_notificationgroup", + "message_register_policy", + models.CharField("Name", max_length=1, default="d"), + ) + self.db.add_column( + "main_notificationgroup", + "message_types", + models.JSONField("Notification Contacts", null=True, blank=True, default=lambda: "[]"), + ) + self.db.add_column( + "main_notificationgroup", + "static_members", + models.JSONField("Notification Contacts", null=True, blank=True, default=lambda: "[]"), + ) + self.db.add_column( + "main_notificationgroup", + "subscription_settings", + models.JSONField( + "Notification Subscriptions", null=True, blank=True, default=lambda: "[]" + ), + ) + self.db.add_column( + "main_notificationgroup", + "subscription_to", + ArrayField( + models.CharField(max_length=100), null=True, blank=True, default=lambda: "{}" + ), + ) + self.db.add_column( + "main_notificationgroup", + "conditions", + models.JSONField( + "Condition for match Notification Group", + null=True, + blank=True, + default=lambda: "[]", + ), + ) + r = defaultdict(list) + for ng, tp, method, contact in self.db.execute( + "SELECT notification_group_id, time_pattern_id, notification_method, params FROM main_notificationgroupother" + ): + r[ng].append( + { + "contact": contact, + "notification_method": method, + "time_patter": tp or None, + } + ) + for ng, members in r.items(): + self.db.execute( + """UPDATE main_notificationgroup SET static_members = %s::jsonb WHERE id = %s""", + [orjson.dumps(members).decode(), ng], + ) + for ng, tp, user in self.db.execute( + "SELECT notification_group_id, time_pattern_id, user_id FROM main_notificationgroupuser" + ): + self.db.execute( + "INSERT INTO main_notificationgroupusersubscription(notification_group_id, user_id) VALUES(%s,%s)", + [ng, user], + ) + self.db.delete_table("main_notificationgroupuser") + self.db.delete_table("main_notificationgroupother") diff --git a/main/models/messageroute.py b/main/models/messageroute.py index b93d7f5b71..fd111d70ea 100644 --- a/main/models/messageroute.py +++ b/main/models/messageroute.py @@ -133,10 +133,7 @@ def clean(self): super().clean() def get_route_config(self): - """ - Return data for configured Router - :return: - """ + """Return data for configured Router""" r = { "name": self.name, "type": self.type, diff --git a/main/models/notificationgroup.py b/main/models/notificationgroup.py index 73f05818f2..b25ea49ef0 100644 --- a/main/models/notificationgroup.py +++ b/main/models/notificationgroup.py @@ -1,7 +1,7 @@ # --------------------------------------------------------------------- # NotificationGroup model # --------------------------------------------------------------------- -# Copyright (C) 2007-2022 The NOC Project +# Copyright (C) 2007-2024 The NOC Project # See LICENSE for details # --------------------------------------------------------------------- @@ -9,24 +9,48 @@ import datetime import logging import operator +from dataclasses import dataclass from threading import Lock -from typing import Tuple, Dict, Iterator, Optional, Any, Set, List +from typing import Tuple, Dict, Iterable, Optional, Any, Set, List # Third-party modules -from django.db import models +from django.db.models import ( + TextField, + CharField, + ForeignKey, + BooleanField, + DateTimeField, + UUIDField, + CASCADE, +) +from django.contrib.postgres.fields import ArrayField +from pydantic import BaseModel, RootModel, model_validator import cachetools # NOC modules from noc.core.model.base import NOCModel +from noc.core.model.fields import PydanticField, DocumentReferenceField +from noc.core.timepattern import TimePatternList +from noc.core.mx import ( + send_notification, + NOTIFICATION_METHODS, + MX_TO, + MessageType, + MessageMeta, + get_subscription_id, +) +from noc.core.model.decorator import on_delete_check, on_save +from noc.core.change.decorator import change +from noc.core.comp import DEFAULT_ENCODING +from noc.core.prettyjson import to_json +from noc.core.text import quote_safe_path from noc.aaa.models.user import User -from noc.settings import LANGUAGE_CODE from noc.main.models.systemtemplate import SystemTemplate from noc.main.models.template import Template -from noc.core.timepattern import TimePatternList -from noc.core.mx import send_notification, NOTIFICATION_METHODS, MX_TO -from noc.core.model.decorator import on_delete_check -from noc.core.comp import DEFAULT_ENCODING -from .timepattern import TimePattern +from noc.main.models.remotesystem import RemoteSystem +from noc.main.models.timepattern import TimePattern +from noc.config import config +from noc.settings import LANGUAGE_CODE id_lock = Lock() logger = logging.getLogger(__name__) @@ -45,6 +69,92 @@ USER_NOTIFICATION_METHOD_CHOICES = NOTIFICATION_METHOD_CHOICES +@dataclass(frozen=True) +class NotificationContact: + contact: str + language: str = LANGUAGE_CODE + method: str = "mail" + watch: Optional[str] = None + time_pattern: Optional[TimePatternList] = None + + def match(self, ts: datetime.datetime, watch: Optional[str] = None): + if not self.time_pattern and not self.watch: + return True + if self.time_pattern and not self.time_pattern.match(ts): + return False + if self.watch and self.watch != watch: + return False + return True + + +class StaticMember(BaseModel): + """ + Contact Members for notification Set + """ + + notification_method: str + contact: str + language: Optional[str] = None + time_pattern: Optional[int] = None + + +StaticMembers = RootModel[List[StaticMember]] + + +class SubscriptionConditionItem(BaseModel): + """ + Rules for Match message + Attributes: + labels: Match All labels in list + resource_groups: Match Any resource group in List + administrative_domain: Have Administrative domain in paths + """ + + labels: Optional[List[str]] = None + resource_groups: Optional[List[str]] = None + administrative_domain: Optional[int] = None + # profile, group, container, administrative_domain + + +class SubscriptionSettingItem(BaseModel): + """ + Attributes: + group: User group for apply settings + allow_subscribe: Allow subscribe to group + auto_subscription: Create subscription record + notify_if_subscribed: Send notification if Subscription Changed + """ + + user: Optional[int] = None + group: Optional[int] = None + allow_subscribe: bool = False + auto_subscription: bool = False + notify_if_subscribed: bool = False + + @model_validator(mode="after") + def check_passwords_match(self): + if not self.user and not self.group: + raise ValueError("User or Group must be set") + return self + + +SubscriptionSettings = RootModel[List[SubscriptionSettingItem]] + + +SubscriptionConditions = RootModel[List[SubscriptionConditionItem]] + + +class MessageTypeItem(BaseModel): + message_type: MessageType + template: Optional[int] = None + deny: bool = False + + +MessageTypes = RootModel[List[MessageTypeItem]] + + +@on_save +@change @on_delete_check( check=[ ("cm.ObjectNotify", "notification_group"), @@ -55,8 +165,7 @@ ("fm.AlarmRule", "actions.notification_group"), ("inv.InterfaceProfile", "default_notification_group"), ("main.ReportSubscription", "notification_group"), - ("main.NotificationGroupOther", "notification_group"), - ("main.NotificationGroupUser", "notification_group"), + ("main.NotificationGroupUserSubscription", "notification_group"), ("main.SystemNotification", "notification_group"), ("main.MessageRoute", "notification_group"), ("sa.ObjectNotification", "notification_group"), @@ -75,8 +184,56 @@ class Meta(object): db_table = "main_notificationgroup" ordering = ["name"] - name = models.CharField("Name", max_length=64, unique=True) - description = models.TextField("Description", null=True, blank=True) + _json_collection = { + "collection": "templates", + "json_collection": "main.notificationgroups", + "json_unique_fields": ["name"], + } + + name: str = CharField("Name", max_length=64, unique=True) + uuid = UUIDField() + description = TextField("Description", null=True, blank=True) + message_register_policy = CharField( + max_length=1, + choices=[ + ("d", "Disable"), # Direct + ("a", "Any"), + ("t", "By Types"), + ], + default="d", + null=False, + blank=False, + ) + message_types: List[MessageTypeItem] = PydanticField( + "Message Type Settings", + schema=MessageTypes, + blank=True, + null=True, + default=list, + ) + static_members: Optional[List[StaticMember]] = PydanticField( + "Notification Contacts", + schema=StaticMembers, + blank=True, + null=True, + default=list, + ) + subscription_settings: Optional[List[SubscriptionSettingItem]] = PydanticField( + "Subscription Settings", + schema=SubscriptionSettings, + blank=True, + null=True, + default=list, + ) + # subscribed = ArrayField(CharField(max_length=100)) + subscription_to: List[str] = ArrayField(CharField(max_length=100), blank=True, null=True) + conditions: Optional[List[SubscriptionConditionItem]] = PydanticField( + "Condition for match Notification Group", + schema=SubscriptionConditions, + blank=True, + null=True, + default=list, + ) _id_cache = cachetools.TTLCache(maxsize=100, ttl=60) _name_cache = cachetools.TTLCache(maxsize=100, ttl=60) @@ -87,42 +244,128 @@ def __str__(self): @classmethod @cachetools.cachedmethod(operator.attrgetter("_id_cache"), lock=lambda _: id_lock) def get_by_id(cls, id: int) -> Optional["NotificationGroup"]: - ng = NotificationGroup.objects.filter(id=id)[:1] - if ng: - return ng[0] - return None + return NotificationGroup.objects.filter(id=id).first() @classmethod @cachetools.cachedmethod(operator.attrgetter("_name_cache"), lock=lambda _: id_lock) - def get_by_name(cls, name): - ng = NotificationGroup.objects.filter(name=name)[:1] - if ng: - return ng[0] - return None + def get_by_name(cls, name: str) -> Optional["NotificationGroup"]: + return NotificationGroup.objects.filter(name=name).first() + + @classmethod + def get_groups_by_type(cls, message_type: MessageType) -> List["NotificationGroup"]: + return list( + NotificationGroup.objects.filter(message_types__message_type=message_type.value) + ) + + @classmethod + def get_groups_by_user(cls, user: User) -> List["NotificationGroup"]: + return list(NotificationGroup.objects.filter()) + + def get_subscription_by_user( + self, user: User, watch: Optional[Any] = None + ) -> Optional["NotificationGroupUserSubscription"]: + """Getting subscription by user""" + if watch: + watch = get_subscription_id(watch) + return NotificationGroupUserSubscription.objects.filter( + notification_group=self, + user=user, + watch=watch, + ).first() + return NotificationGroupUserSubscription.objects.filter( + notification_group=self, + user=user, + watch="", + ).first() @property - def members(self): + def is_active(self) -> bool: + """For cfgMX datastream add""" + return self.message_register_policy != "d" + + def iter_changed_datastream(self, changed_fields=None): + if config.datastream.enable_cfgmxroute: + yield "cfgmxroute", f"ng:{self.id}" + + def on_save(self): + self.ensure_subscriptions() + + def get_route_config(self): + """Return data for configured Router""" + tt = ["*"] + if self.message_types: + tt = [x["message_type"] for x in self.message_types] + r = { + "name": self.name, + "type": tt, + "order": 998, + "action": "notification", + "notification_group": str(self.id), + # r["render_template"] = str(self.render_template.id) + "telemetry_sample": 0, + "match": [], + } + if not self.conditions: + return r + for m in self.conditions: + c = {} + if m["resource_groups"]: + c[MessageMeta.GROUPS] = list(m["resource_groups"]) + if m["labels"]: + c[MessageMeta.LABELS] = list(m["labels"]) + if m["administrative_domain"]: + c[MessageMeta.ADM_DOMAIN] = m["administrative_domain"] + if c: + r["match"].append(c) + return r + + @property + def members(self) -> List[NotificationContact]: """ List of (time pattern, method, params, language) """ default_language = LANGUAGE_CODE m = [] # Collect user notifications - for ngu in self.notificationgroupuser_set.filter(user__is_active=True): + for ngu in self.notificationgroupusersubscription_set.filter(): + if ngu.suppress: + continue lang = ngu.user.preferred_language or default_language user_contacts = ngu.user.contacts if user_contacts: for tp, method, params in user_contacts: - m += [(TimePatternList([ngu.time_pattern, tp]), method, params, lang)] + if tp: + tp = [tp] + if ngu.time_pattern: + tp.insert(ngu.time_pattern, 0) + m.append( + NotificationContact( + contact=params, + method=method, + language=lang, + time_pattern=TimePatternList(tp), + watch=ngu.watch, + ) + ) else: - m += [(TimePatternList([]), "mail", ngu.user.email, lang)] + m += [ + NotificationContact( + contact=ngu.user.email, + language=lang, + watch=ngu.watch, + time_pattern=ngu.time_pattern or None, + ) + ] # Collect other notifications - for ngo in self.notificationgroupother_set.all(): - if ngo.notification_method == "mail" and "," in ngo.params: - for y in ngo.params.split(","): - m += [(ngo.time_pattern, ngo.notification_method, y.strip(), default_language)] - else: - m += [(ngo.time_pattern, ngo.notification_method, ngo.params, default_language)] + for ngo in self.static_members: + for c in ngo["contact"].split(","): + m.append( + NotificationContact( + contact=c, + method=ngo["notification_method"], + time_pattern=ngo.get("time_pattern") or None, + ) + ) return m @property @@ -131,19 +374,17 @@ def active_members(self) -> Set[Tuple[str, str, Optional[str]]]: List of currently active members: (method, param, language) """ now = datetime.datetime.now() - return set( - (method, param, lang) for tp, method, param, lang in self.members if tp.match(now) - ) + return set((c.method, c.contact, c.language) for c in self.members if c.match(now)) @property - def languages(self): + def languages(self) -> Set[str]: """ List of preferred languages for users """ - return set(x[3] for x in self.members) + return set(x.language for x in self.members) @classmethod - def get_effective_message(cls, messages, lang): + def get_effective_message(cls, messages, lang: str) -> str: for cl in (lang, LANGUAGE_CODE, "en"): if cl in messages: return messages[cl] @@ -160,12 +401,12 @@ def send_notification( ): """ Send notification message to MX service for processing - :param method: Method for sending message: mail, tg... - :param address: Address to message - :param subject: Notification Subject - :param body: Notification body - :param attachments: - :return: + Attrs: + method: Method for sending message: mail, tg... + address: Address to message + subject: Notification Subject + body: Notification body + attachments: """ if method not in NOTIFICATION_METHODS: logger.error("Unknown notification method: %s", method) @@ -179,13 +420,100 @@ def send_notification( attachments=attachments or [], ) + def subscribe( + self, + user: User, + expired_at: Optional[datetime.datetime] = None, + watch: Optional[Any] = None, + ): + """Subscribe User to Group""" + s = self.get_subscription_by_user(user, watch) + if not s: + s = NotificationGroupUserSubscription(notification_group=self, user=user) + if watch: + s.watch = get_subscription_id(watch) + if expired_at and s.expired_at != expired_at: + s.expired_at = expired_at + s.save() + + def unsubscribe( + self, + user: User, + watch: Optional[Any] = None, + ): + """Unsubscribe User""" + s = self.get_subscription_by_user(user, watch) + if s: + s.delete() + + def supress( + self, + user: User, + watch: Optional[Any] = None, + ): + """Supress Notification for subscription""" + s = self.get_subscription_by_user(user, watch) + if not s.suppress: + NotificationGroupUserSubscription.objects.filter(id=s.id).update(suppress=True) + + @property + def iter_subscription_settings(self) -> Iterable[SubscriptionSettingItem]: + for s in self.subscription_settings: + yield SubscriptionSettingItem(**s) + + def is_allowed_subscription(self, user: User) -> bool: + groups = frozenset(user.groups.values_list("id", flat=True)) + for s in self.iter_subscription_settings: + if s.user == user.id and s.allow_subscribe: + return True + if s.group in groups and s.allow_subscribe: + return True + return False + + def ensure_user_subscription(self, user: User): + """""" + ng = self.get_subscription_by_user(user) + if not ng: + self.subscribe(user) + + def ensure_subscriptions(self): + """Ensure Subscription with settings""" + print("Ensure Subscription") + for s in self.iter_subscription_settings: + if s.user: + u = User.get_by_id(s.user) + ng = self.get_subscription_by_user(u) + if s.auto_subscription and not ng: + self.subscribe(u) + elif not s.auto_subscription and ng: + self.unsubscribe(u) + + def register_message( + self, + message_type: str, + ctx: Dict[str, Any], + meta: Dict[str, Any], + template: Optional["Template"] = None, + attachments=None, + ): + """ + Register message on Group + Attrs: + message_type: Message Type + ctx: Message Context Vars + meta: Sender Metadata + template: Template for render body + attachments: Include attachments + """ + def notify(self, subject, body, link=None, attachments=None): """ Send message to active members - :param subject: Message subject - :param body: Message body - :param link: Optional link - :param attachments: List of attachments. Each one is a dict + Attrs: + subject: Message subject + body: Message body + link: Optional link + attachments: List of attachments. Each one is a dict with keys *filename* and *data*. *data* is the raw data """ logger.debug("Notify group %s: %s", self.name, subject) @@ -232,13 +560,20 @@ def group_notify(cls, groups, subject, body, link=None, delay=None, tag=None): cls.get_effective_message(body, lang[(method, params)]), ) - def iter_actions(self) -> Iterator[Tuple[str, Dict[str, bytes], Optional["Template"]]]: + def iter_actions( + self, + message_type: str, + meta: Dict[MessageMeta, Any], + ts: Optional[datetime.datetime] = None, + ) -> Iterable[Tuple[str, Dict[str, bytes], Optional["Template"]]]: """ mx-compatible actions. Yields tuples of `stream`, `headers` - :return: """ - for method, param, _ in self.active_members: - yield method, {MX_TO: param.encode(encoding=DEFAULT_ENCODING)}, None + now = ts or datetime.datetime.now() + for c in self.members: + if not c.match(now, meta.get(MessageMeta.WATCH_FOR)): + continue + yield c.method, {MX_TO: c.contact.encode(encoding=DEFAULT_ENCODING)}, None @classmethod def render_message( @@ -253,54 +588,63 @@ def render_message( return ctx return {"subject": template.render_subject(**ctx), "body": template.render_body(**ctx)} - -class NotificationGroupUser(NOCModel): - class Meta(object): - verbose_name = "Notification Group User" - verbose_name_plural = "Notification Group Users" - app_label = "main" - db_table = "main_notificationgroupuser" - unique_together = [("notification_group", "time_pattern", "user")] - - notification_group = models.ForeignKey( - NotificationGroup, verbose_name="Notification Group", on_delete=models.CASCADE - ) - time_pattern = models.ForeignKey( - TimePattern, verbose_name="Time Pattern", on_delete=models.CASCADE - ) - user = models.ForeignKey(User, verbose_name="User", on_delete=models.CASCADE) - - def __str__(self): - return "%s: %s: %s" % ( - self.notification_group.name, - self.time_pattern.name, - self.user.username, + @property + def json_data(self) -> Dict[str, Any]: + r = { + "name": self.name, + "$collection": self._json_collection["json_collection"], + "uuid": self.uuid, + "description": self.description, + "message_register_policy": self.message_register_policy, + "message_types": [t.model_dump() for t in self.message_types], + } + return r + + def to_json(self) -> str: + return to_json( + self.json_data, + order=[ + "name", + "$collection", + "uuid", + "message_register_policy", + "message_types", + ], ) + def get_json_path(self) -> str: + return quote_safe_path(self.name.strip("*")) + ".json" -class NotificationGroupOther(NOCModel): + +class NotificationGroupUserSubscription(NOCModel): class Meta(object): - verbose_name = "Notification Group Other" - verbose_name_plural = "Notification Group Others" + verbose_name = "Notification Group User Subscription" + verbose_name_plural = "Notification Group Users" app_label = "main" - db_table = "main_notificationgroupother" - unique_together = [("notification_group", "time_pattern", "notification_method", "params")] + db_table = "main_notificationgroupusersubscription" + unique_together = [("notification_group", "user", "watch")] - notification_group = models.ForeignKey( - NotificationGroup, verbose_name="Notification Group", on_delete=models.CASCADE + notification_group: NotificationGroup = ForeignKey( + NotificationGroup, verbose_name="Notification Group", on_delete=CASCADE ) - time_pattern = models.ForeignKey( - TimePattern, verbose_name="Time Pattern", on_delete=models.CASCADE + time_pattern: Optional[TimePattern] = ForeignKey( + TimePattern, verbose_name="Time Pattern", on_delete=CASCADE, null=True, blank=True ) - notification_method = models.CharField( - "Method", max_length=16, choices=NOTIFICATION_METHOD_CHOICES - ) - params = models.CharField("Params", max_length=256) + user: User = ForeignKey(User, verbose_name="User", on_delete=CASCADE) + expired_at = DateTimeField("Expired Subscription After", auto_now_add=False) + suppress = BooleanField("Deactivate Subscription", default=False) + watch = CharField("Watch key", max_length=100, null=True, blank=True) + remote_system = DocumentReferenceField(RemoteSystem, null=True, blank=True) def __str__(self): - return "%s: %s: %s: %s" % ( - self.notification_group.name, - self.time_pattern.name, - self.notification_method, - self.params, - ) + if not self.watch: + return f"{self.user.username}@{self.notification_group.name}: {self.time_pattern.name if self.time_pattern else ''}" + return f"{self.user.username}@{self.notification_group.name}: ({self.watch}) {self.time_pattern.name if self.time_pattern else ''}" + + def is_match(self, meta: Dict[MessageMeta, Any]): + # time_pattern + if not self.watch: + return True + if self.watch and MessageMeta.FROM not in meta: + return False + return self.watch == meta[MessageMeta.FROM] diff --git a/main/models/remotesystem.py b/main/models/remotesystem.py index f1c08601cc..3c61cc3ad6 100644 --- a/main/models/remotesystem.py +++ b/main/models/remotesystem.py @@ -73,6 +73,7 @@ def __str__(self): ("ip.PrefixProfile", "remote_system"), ("ip.Prefix", "remote_system"), ("main.Label", "remote_system"), + ("main.NotificationGroupUserSubscription", "remote_system"), ("sa.ManagedObject", "remote_system"), ("sa.AdministrativeDomain", "remote_system"), ("sa.ManagedObjectProfile", "remote_system"), diff --git a/main/models/reportsubscription.py b/main/models/reportsubscription.py index 019ab8de5c..2c81681e7e 100644 --- a/main/models/reportsubscription.py +++ b/main/models/reportsubscription.py @@ -42,7 +42,7 @@ class ReportSubscription(Document): run_as = ForeignKeyField(User) # Send result to notification group # If empty, only file will be written - notification_group = ForeignKeyField(NotificationGroup) + notification_group: "NotificationGroup" = ForeignKeyField(NotificationGroup) # Predefined report id # : report = StringField() @@ -124,7 +124,7 @@ def build_report(self): return path def send_report(self, path): - addresses = [r[2] for r in self.notification_group.members if r[1] == "mail"] + addresses = [r.contact for r in self.notification_group.members if r.method == "mail"] with open(path) as f: data = f.read() for a in addresses: diff --git a/main/models/timepattern.py b/main/models/timepattern.py index a724de61d1..91c9581211 100644 --- a/main/models/timepattern.py +++ b/main/models/timepattern.py @@ -29,8 +29,7 @@ ("fm.AlarmTrigger", "time_pattern"), ("fm.EventTrigger", "time_pattern"), ("main.TimePatternTerm", "time_pattern"), - ("main.NotificationGroupUser", "time_pattern"), - ("main.NotificationGroupOther", "time_pattern"), + ("main.NotificationGroupUserSubscription", "time_pattern"), ("maintenance.Maintenance", "time_pattern"), ("sa.ManagedObject", "time_pattern"), ] diff --git a/mkdocs.yml b/mkdocs.yml index cd494da525..42ca455ae5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -953,7 +953,8 @@ nav: - Performance Management: performance-management/index.md - Working with Report: reference-report-configuration/index.md - Peering Management: peering-management/index.md - - Monitoring by Service Operation Status: docs/service-status-monitoring/index.md + - Monitoring by Service Operation Status: service-status-monitoring/index.md + - Message Notification: message-notification/index.md - Custom: custom/index.md - Cards: card/index.md - ETL: etl/index.md diff --git a/models.py b/models.py index 3ed1858a39..f17a22205c 100644 --- a/models.py +++ b/models.py @@ -112,8 +112,7 @@ def iter_model_id(): "main.MetricStream": "noc.main.models.metricstream.MetricStream", "main.MIMEType": "noc.main.models.mimetype.MIMEType", "main.NotificationGroup": "noc.main.models.notificationgroup.NotificationGroup", - "main.NotificationGroupOther": "noc.main.models.notificationgroup.NotificationGroupOther", - "main.NotificationGroupUser": "noc.main.models.notificationgroup.NotificationGroupUser", + "main.NotificationGroupUserSubscription": "noc.main.models.notificationgroup.NotificationGroupUserSubscription", "main.Pool": "noc.main.models.pool.Pool", "main.PrefixTable": "noc.main.models.prefixtable.PrefixTable", "main.PyRule": "noc.main.models.pyrule.PyRule", diff --git a/sa/models/managedobject.py b/sa/models/managedobject.py index 55cfc0cb3d..624ccc37f3 100644 --- a/sa/models/managedobject.py +++ b/sa/models/managedobject.py @@ -58,12 +58,9 @@ from noc.core.mx import ( send_message, MessageType, - MX_LABELS, - MX_H_VALUE_SPLITTER, - MX_ADMINISTRATIVE_DOMAIN_ID, - MX_RESOURCE_GROUPS, - MX_PROFILE_ID, + get_subscription_id, MX_NOTIFICATION_DELAY, + MessageMeta, ) from noc.core.deprecations import RemovedInNOC2301Warning from noc.aaa.models.user import User @@ -2777,16 +2774,19 @@ def interactions(self) -> "InteractionHub": def get_mx_message_headers(self, labels: Optional[List[str]] = None) -> Dict[str, bytes]: return { - MX_LABELS: MX_H_VALUE_SPLITTER.join(self.effective_labels + (labels or [])).encode( - DEFAULT_ENCODING - ), - MX_ADMINISTRATIVE_DOMAIN_ID: str(self.administrative_domain.id).encode( - DEFAULT_ENCODING - ), - MX_PROFILE_ID: str(self.object_profile.id).encode(DEFAULT_ENCODING), - MX_RESOURCE_GROUPS: MX_H_VALUE_SPLITTER.join( - [str(sg) for sg in self.effective_service_groups] - ).encode(DEFAULT_ENCODING), + key.config.header: key.clean_header_value(value) + for key, value in self.message_meta.items() + } + + @property + def message_meta(self) -> Dict[MessageMeta, Any]: + """Message Meta for instance""" + return { + MessageMeta.WATCH_FOR: get_subscription_id(self), + MessageMeta.ADM_DOMAIN: str(self.administrative_domain.id), + MessageMeta.PROFILE: get_subscription_id(self.object_profile), + MessageMeta.GROUPS: list(self.effective_service_groups), + MessageMeta.LABELS: list(self.effective_labels), } def set_profile(self, profile: str) -> bool: diff --git a/services/datastream/streams/cfgmxroute.py b/services/datastream/streams/cfgmxroute.py index 60d903cd78..ed8e82b5bc 100644 --- a/services/datastream/streams/cfgmxroute.py +++ b/services/datastream/streams/cfgmxroute.py @@ -11,6 +11,7 @@ # NOC modules from noc.core.datastream.base import DataStream from noc.main.models.messageroute import MessageRoute +from noc.main.models.notificationgroup import NotificationGroup class CfgMetricsCollectorDataStream(DataStream): @@ -18,7 +19,10 @@ class CfgMetricsCollectorDataStream(DataStream): @classmethod def get_object(cls, oid: str) -> Dict[str, Any]: - route: "MessageRoute" = MessageRoute.get_by_id(oid) + if oid.startswith("ng:"): + route = NotificationGroup.get_by_id(int(oid[3:])) + else: + route = MessageRoute.get_by_id(oid) if not route or not route.is_active: raise KeyError() r = route.get_route_config() diff --git a/services/web/apps/main/notificationgroup/views.py b/services/web/apps/main/notificationgroup/views.py index b855783ed7..e81c89e0ed 100644 --- a/services/web/apps/main/notificationgroup/views.py +++ b/services/web/apps/main/notificationgroup/views.py @@ -1,18 +1,15 @@ # --------------------------------------------------------------------- # main.notificationgroup application # --------------------------------------------------------------------- -# Copyright (C) 2007-2019 The NOC Project +# Copyright (C) 2007-2024 The NOC Project # See LICENSE for details # --------------------------------------------------------------------- # NOC modules from noc.services.web.base.extmodelapplication import ExtModelApplication, view -from noc.main.models.notificationgroup import ( - NotificationGroup, - NotificationGroupUser, - NotificationGroupOther, -) -from noc.services.web.base.modelinline import ModelInline +from noc.aaa.models.user import User +from noc.aaa.models.group import Group +from noc.main.models.notificationgroup import NotificationGroup from noc.sa.interfaces.base import ListOfParameter, ModelParameter, UnicodeParameter from noc.core.translation import ugettext as _ @@ -23,13 +20,10 @@ class NotificationGroupApplication(ExtModelApplication): """ title = _("Notification Group") - menu = [_("Setup"), _("Notification Groups")] + menu = [_("Notification Groups")] model = NotificationGroup glyph = "envelope-o" - users = ModelInline(NotificationGroupUser) - other = ModelInline(NotificationGroupOther) - @view( url="^actions/test/$", method=["POST"], @@ -45,3 +39,19 @@ def api_action_test(self, request, ids, subject, body): for g in ids: g.notify(subject=subject, body=body) return "Notification message has been sent" + + def instance_to_dict(self, o, fields=None): + r = super().instance_to_dict(o, fields=fields) + r["subscription_settings"] = [] + for ss in o.iter_subscription_settings: + x = ss.model_dump() + if ss.user: + u = User.get_by_id(ss.user) + x["user__label"] = u.username + if ss.group: + g = Group.get_by_id(ss.group) + x["group__label"] = g.name + r["subscription_settings"].append(x) + for ss in r.get("message_types", []): + ss["message_type__label"] = ss["message_type"] + return r diff --git a/services/web/apps/main/notificationgroupusersubscription/__init__.py b/services/web/apps/main/notificationgroupusersubscription/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/services/web/apps/main/notificationgroupusersubscription/views.py b/services/web/apps/main/notificationgroupusersubscription/views.py new file mode 100644 index 0000000000..711eb474be --- /dev/null +++ b/services/web/apps/main/notificationgroupusersubscription/views.py @@ -0,0 +1,21 @@ +# --------------------------------------------------------------------- +# main.notificationgroupusersubscription application +# --------------------------------------------------------------------- +# Copyright (C) 2007-2024 The NOC Project +# See LICENSE for details +# --------------------------------------------------------------------- + +# NOC modules +from noc.services.web.base.extmodelapplication import ExtModelApplication +from noc.main.models.notificationgroup import NotificationGroupUserSubscription +from noc.core.translation import ugettext as _ + + +class NotificationGroupUserSubscriptionApplication(ExtModelApplication): + """ + NotificationGroupUserSubscription application + """ + + title = _("Notification User Subscriptions") + menu = [_("Notification User Subscriptions")] + model = NotificationGroupUserSubscription diff --git a/services/web/base/decorators/watch.py b/services/web/base/decorators/watch.py new file mode 100644 index 0000000000..1c8bcaafa2 --- /dev/null +++ b/services/web/base/decorators/watch.py @@ -0,0 +1,89 @@ +# ---------------------------------------------------------------------- +# NotifySubscription handler +# ---------------------------------------------------------------------- +# Copyright (C) 2007-2024 The NOC Project +# See LICENSE for details +# ---------------------------------------------------------------------- + +# NOC modules +from .base import BaseAppDecorator +from noc.sa.interfaces.base import BooleanParameter, DateTimeParameter +from noc.main.models.notificationgroup import NotificationGroup + + +class WatchHandlerDecorator(BaseAppDecorator): + def contribute_to_class(self): + self.add_view( + "api_avail_subscription", + self.api_avail_subscription, + method=["GET"], + url=r"^(?P[^/]+)/api_avail_subscription/$", + access="read", + api=True, + ) + + self.add_view( + "api_subscribe_group", + self.api_subscribe_group, + method=["POST"], + url=r"^(?P[^/]+)/watch/(?P[0-9a-f]{24})/$", + access="write", + api=True, + validate={ + "expired_at": DateTimeParameter(required=False), + "suppress": BooleanParameter(default=False), + }, + ) + + self.add_view( + "api_unsubscribe_group", + self.api_unsubscribe_group, + method=["POST"], + url=r"^(?P[^/]+)/unwatch/(?P[0-9a-f]{24})/$", + access="write", + api=True, + ) + + def api_avail_subscription(self, request, object_id): + # try: + # o = self.app.queryset(request).get(**{self.app.pk: object_id}) + # except self.app.model.DoesNotExist: + # return self.app.response_not_found() + r = [] + for g in NotificationGroup.get_groups_by_user(request.user): + r += [ + { + "id": str(g.id), + "label": str(g.label or ""), + "description": str(g.description or ""), + } + ] + return r + + def api_subscribe_group(self, request, object_id, group_id): + try: + o = self.app.queryset(request).get(**{self.app.pk: object_id}) + except self.app.model.DoesNotExist: + return self.app.response_not_found() + g = NotificationGroup.get_by_id(group_id) + if not g: + return self.app.response_not_found() + g.subscribe(request.user, watch=o) + return {"status": True} + + def api_unsubscribe_group(self, request, object_id, group_id): + try: + o = self.app.queryset(request).get(**{self.app.pk: object_id}) + except self.app.model.DoesNotExist: + return self.app.response_not_found() + g = NotificationGroup.get_by_id(group_id) + if not g: + return self.app.response_not_found() + g.unsubscribe(request.user, watch=o) + return {"status": True} + + +def watch_handler(cls): + WatchHandlerDecorator(cls) + cls.ng_watch = True + return cls diff --git a/services/web/base/modelinline.py b/services/web/base/modelinline.py index e0e870849b..4d2330af53 100644 --- a/services/web/base/modelinline.py +++ b/services/web/base/modelinline.py @@ -411,9 +411,9 @@ def api_create(self, request, parent): o = self.model(**attrs) try: o.save() - except IntegrityError: + except IntegrityError as e: return self.app.render_json( - {"status": False, "message": "Integrity error"}, status=self.CONFLICT + {"status": False, "message": f"Integrity error: {e}"}, status=self.CONFLICT ) format = request.GET.get(self.format_param) if format == "ext": @@ -453,9 +453,9 @@ def api_update(self, request, parent, id): setattr(o, k, v) try: o.save() - except IntegrityError: + except IntegrityError as e: return self.app.render_json( - {"status": False, "message": "Integrity error"}, status=self.CONFLICT + {"status": False, "message": f"Integrity error: {e}"}, status=self.CONFLICT ) return self.app.response(status=self.OK) diff --git a/ui/web/main/notificationgroup/Application.js b/ui/web/main/notificationgroup/Application.js index e202b0cf3b..426777da30 100644 --- a/ui/web/main/notificationgroup/Application.js +++ b/ui/web/main/notificationgroup/Application.js @@ -1,7 +1,7 @@ //--------------------------------------------------------------------- // main.notificationgroup application //--------------------------------------------------------------------- -// Copyright (C) 2007-2018 The NOC Project +// Copyright (C) 2007-2024 The NOC Project // See LICENSE for details //--------------------------------------------------------------------- console.debug("Defining NOC.main.notificationgroup.Application"); @@ -9,117 +9,242 @@ console.debug("Defining NOC.main.notificationgroup.Application"); Ext.define("NOC.main.notificationgroup.Application", { extend: "NOC.core.ModelApplication", requires: [ + "NOC.core.JSONPreview", + "NOC.core.ListFormField", + "NOC.core.tagfield.Tagfield", + "NOC.core.label.LabelField", "NOC.main.notificationgroup.Model", - "NOC.main.notificationgroup.UsersModel", - "NOC.main.notificationgroup.OtherModel", "NOC.main.timepattern.LookupField", - "NOC.aaa.user.LookupField" + "NOC.main.template.LookupField", + "NOC.main.ref.messagetype.LookupField", + "NOC.aaa.user.LookupField", + "NOC.aaa.group.LookupField", + "NOC.inv.resourcegroup.LookupField", + "NOC.sa.administrativedomain.LookupField" ], model: "NOC.main.notificationgroup.Model", search: true, helpId: "reference-notification-group", - columns: [ - { - text: __("Name"), - dataIndex: "name", - width: 150 - }, - { - text: __("Description"), - dataIndex: "description", - flex: 1 - } - ], - fields: [ - { - name: "name", - xtype: "textfield", - fieldLabel: __("Name"), - allowBlank: false - }, - { - name: "description", - xtype: "textarea", - fieldLabel: __("Description"), - allowBlank: true - } - ], - inlines: [ - { - title: __("Users"), - model: "NOC.main.notificationgroup.UsersModel", - columns: [ - { - text: __("Time Pattern"), - dataIndex: "time_pattern", - width: 150, - renderer: NOC.render.Lookup("time_pattern"), - editor: "main.timepattern.LookupField" - }, - { - text: __("User"), - dataIndex: "user", - flex: 1, - renderer: NOC.render.Lookup("user"), - editor: "aaa.user.LookupField" - } - ] - }, - { - title: __("Other"), - model: "NOC.main.notificationgroup.OtherModel", - columns: [ - { - text: __("Time Pattern"), - dataIndex: "time_pattern", - width: 150, - renderer: NOC.render.Lookup("time_pattern"), - editor: "main.timepattern.LookupField" - }, - { - text: __("Method"), - dataIndex: "notification_method", - width: 75, - editor: { - xtype: "combobox", - store: [ - ["mail", "Mail"], - ["tg", "Telegram"], - ["icq", "ICQ"], - ["file", "File"], - ["xmpp", "Jabber"] - ] - } - }, - { - text: __("Params"), - dataIndex: "params", - flex: 1, - editor: "textfield" - } - ] - } - ], - actions: [ - { - title: __("Test selected groups"), - action: "test", - form: [ - { - name: "subject", - xtype: "textfield", - fieldLabel: __("Subject"), - allowBlank: false, - width: 600 - }, - { - name: "body", - xtype: "textarea", - fieldLabel: __("Body"), - allowBlank: false, - width: 600 + initComponent: function(){ + var me = this; + + me.jsonPanel = Ext.create("NOC.core.JSONPreview", { + app: me, + restUrl: new Ext.XTemplate("/main/notificationgroup/{id}/json/"), + previewName: new Ext.XTemplate("Notification Group: {name}"), + }); + me.ITEM_JSON = me.registerItem(me.jsonPanel); + + Ext.apply(me, { + columns: [ + { + text: __("Name"), + dataIndex: "name", + width: 250 + }, + { + text: __("Blt"), + // tooltip: "Built-in", - broken in ExtJS 5.1 + dataIndex: "is_builtin", + width: 40, + renderer: NOC.render.Bool, + align: "center", + }, + { + text: __("Description"), + dataIndex: "description", + width: 350 + } + ], + fields: [ + { + name: "name", + xtype: "textfield", + fieldLabel: __("Name"), + allowBlank: false + }, + { + xtype: "displayfield", + name: "uuid", + fieldLabel: __("UUID"), + }, + { + name: "description", + xtype: "textarea", + fieldLabel: __("Description"), + allowBlank: true + }, + { + name: "message_register_policy", + xtype: "combobox", + fieldLabel: __("Message Registry Policy"), + allowBlank: true, + store: [ + ["d", __("Disabled")], + ["a", __("Any")], + ["t", __("By Types")] + ] + }, + { + name: "message_types", + fieldLabel: __("Message Types"), + xtype: "gridfield", + columns: [ + { + text: __("Message Type"), + dataIndex: "message_type", + width: 150, + renderer: NOC.render.Lookup("message_type"), + editor: "main.ref.messagetype.LookupField" + }, + { + text: __("Template"), + dataIndex: "template", + width: 350, + renderer: NOC.render.Lookup("template"), + editor: "main.template.LookupField" + } + ] + }, + { + name: "static_members", + fieldLabel: __("Static Members"), + xtype: "gridfield", + columns: [ + { + text: __("Time Pattern"), + dataIndex: "time_pattern", + width: 350, + renderer: NOC.render.Lookup("time_pattern"), + editor: "main.timepattern.LookupField" + }, + { + text: __("Method"), + dataIndex: "notification_method", + width: 75, + editor: { + xtype: "combobox", + store: [ + ["mail", "Mail"], + ["tg", "Telegram"], + ["icq", "ICQ"], + ["file", "File"], + ["xmpp", "Jabber"] + ] + } + }, + { + text: __("Contact"), + dataIndex: "contact", + width: 300, + editor: "textfield" + } + ] + }, + { + name: "subscription_settings", + xtype: "gridfield", + fieldLabel: __("Subscription Settings"), + columns: [ + { + text: __("User"), + dataIndex: "user", + editor: "aaa.user.LookupField", + renderer: NOC.render.Lookup("user"), + width: 250 + }, + { + text: __("Group"), + dataIndex: "group", + editor: "aaa.group.LookupField", + renderer: NOC.render.Lookup("group"), + width: 250 + }, + { + text: __("Allow Subscribe"), + dataIndex: "allow_subscribe", + editor: "checkboxfield", + width: 100, + renderer: NOC.render.Bool + }, + { + text: __("Add to Subscription"), + dataIndex: "auto_subscription", + editor: "checkboxfield", + width: 100, + renderer: NOC.render.Bool + }, + { + text: __("Notify Changed"), + dataIndex: "notify_if_subscribed", + editor: "checkboxfield", + width: 100, + renderer: NOC.render.Bool + } + ] + }, + { + name: "conditions", + xtype: "listform", + fieldLabel: __("Match Conditions"), + rows: 5, + items: [ + { + name: "labels", + xtype: "labelfield", + fieldLabel: __("Match Labels"), + allowBlank: true, + isTree: true, + pickerPosition: "down", + uiStyle: "extra", + query: { + "allow_matched": true + } + }, + { + xtype: "core.tagfield", + url: "/inv/resourcegroup/lookup/", + fieldLabel: __("Object Groups"), + name: "resource_groups" + }, + { + name: "administrative_domain", + xtype: "sa.administrativedomain.LookupField", + fieldLabel: __("Adm. Domain"), + allowBlank: true + }, + ] } - ] - } - ] + ], + actions: [ + { + title: __("Test selected groups"), + action: "test", + form: [ + { + name: "subject", + xtype: "textfield", + fieldLabel: __("Subject"), + allowBlank: false, + width: 600 + }, + { + name: "body", + xtype: "textarea", + fieldLabel: __("Body"), + allowBlank: false, + width: 600 + } + ] + } + ] + }); + me.callParent(); + }, + + onJSON: function(){ + var me = this; + me.showItem(me.ITEM_JSON); + me.jsonPanel.preview(me.currentRecord); + } }); diff --git a/ui/web/main/notificationgroup/Model.js b/ui/web/main/notificationgroup/Model.js index b07b4c2922..bde5aa9bdb 100644 --- a/ui/web/main/notificationgroup/Model.js +++ b/ui/web/main/notificationgroup/Model.js @@ -19,9 +19,35 @@ Ext.define("NOC.main.notificationgroup.Model", { name: "name", type: "string" }, + { + name: "uuid", + type: "string", + persist: false, + }, { name: "description", type: "string" + }, + { + name: "message_register_policy", + type: "string", + defaultValue: "d" + }, + { + name: "message_types", + type: "auto" + }, + { + name: "subscription_settings", + type: "auto" + }, + { + name: "static_members", + type: "auto" + }, + { + name: "conditions", + type: "auto" } ] }); diff --git a/ui/web/main/notificationgroup/OtherModel.js b/ui/web/main/notificationgroup/OtherModel.js deleted file mode 100644 index d33c90ae35..0000000000 --- a/ui/web/main/notificationgroup/OtherModel.js +++ /dev/null @@ -1,37 +0,0 @@ -//--------------------------------------------------------------------- -// main.notificationgroup Model -//--------------------------------------------------------------------- -// Copyright (C) 2007-2012 The NOC Project -// See LICENSE for details -//--------------------------------------------------------------------- -console.debug("Defining NOC.main.notificationgroup.OtherModel"); - -Ext.define("NOC.main.notificationgroup.OtherModel", { - extend: "Ext.data.Model", - rest_url: "/main/notificationgroup/{{parent}}/other/", - parentField: "notification_group_id", - - fields: [ - { - name: "id", - type: "string" - }, - { - name: "time_pattern", - type: "int" - }, - { - name: "time_pattern__label", - type: "string", - persist: false - }, - { - name: "notification_method", - type: "string" - }, - { - name: "params", - type: "string" - } - ] -}); diff --git a/ui/web/main/notificationgroupusersubscription/Application.js b/ui/web/main/notificationgroupusersubscription/Application.js new file mode 100644 index 0000000000..127523993b --- /dev/null +++ b/ui/web/main/notificationgroupusersubscription/Application.js @@ -0,0 +1,71 @@ +//--------------------------------------------------------------------- +// main.notificationgroupusersubscription application +//--------------------------------------------------------------------- +// Copyright (C) 2007-2024 The NOC Project +// See LICENSE for details +//--------------------------------------------------------------------- +console.debug("Defining NOC.main.notificationgroupusersubscription.Application"); + +Ext.define("NOC.main.notificationgroupusersubscription.Application", { + extend: "NOC.core.ModelApplication", + requires: [ + "NOC.main.notificationgroupusersubscription.Model", + "NOC.main.timepattern.LookupField", + "NOC.main.template.LookupField", + "NOC.main.remotesystem.LookupField", + "NOC.aaa.user.LookupField" + ], + model: "NOC.main.notificationgroupusersubscription.Model", + search: true, + initComponent: function(){ + var me = this; + + Ext.apply(me, { + columns: [ + { + text: __("User"), + dataIndex: "user", + width: 350, + renderer: NOC.render.Lookup("user") + }, + { + text: __("Expired At"), + dataIndex: "expired_at", + width: 150 + }, + { + text: __("Supress"), + dataIndex: "suppress", + width: 50, + renderer: NOC.render.Bool, + sortable: false + }, + { + text: __("Notification Group"), + dataIndex: "notification_group", + width: 350, + renderer: NOC.render.Lookup("notification_group") + }, + { + text: __("Time Pattern"), + dataIndex: "time_pattern", + width: 350, + renderer: NOC.render.Lookup("time_pattern") + }, + { + text: __("Remote System"), + dataIndex: "remote_system", + width: 350, + renderer: NOC.render.Lookup("remote_system") + }, + { + text: __("Watch"), + dataIndex: "watch", + width: 150 + } + ], + fields: [ ] + }); + me.callParent(); + } +}); diff --git a/ui/web/main/notificationgroupusersubscription/Model.js b/ui/web/main/notificationgroupusersubscription/Model.js new file mode 100644 index 0000000000..8f2db1def3 --- /dev/null +++ b/ui/web/main/notificationgroupusersubscription/Model.js @@ -0,0 +1,68 @@ +//--------------------------------------------------------------------- +// main.notificationgroupusersubscription Model +//--------------------------------------------------------------------- +// Copyright (C) 2007-2012 The NOC Project +// See LICENSE for details +//--------------------------------------------------------------------- +console.debug("Defining NOC.main.notificationgroupusersubscription.Model"); + +Ext.define("NOC.main.notificationgroupusersubscription.Model", { + extend: "Ext.data.Model", + rest_url: "/main/notificationgroupusersubscription/", + + fields: [ + { + name: "id", + type: "int" + }, + { + name: "notification_group", + type: "int" + }, + { + name: "notification_group__label", + type: "string", + persist: false + }, + { + name: "time_pattern", + type: "int" + }, + { + name: "time_pattern__label", + type: "string", + persist: false + }, + { + name: "user", + type: "int" + }, + { + name: "user__label", + type: "string", + persist: false + }, + { + name: "expired_at", + type: "string" + }, + { + name: "suppress", + type: "boolean" + }, + { + name: "remote_system", + type: "string" + }, + { + name: "watch", + type: "string", + persist: false + }, + { + name: "remote_system__label", + type: "string", + persist: false + } + ] +});