From 7221b4ba96b90c96a7817e9c76c4f7421b4231cc Mon Sep 17 00:00:00 2001
From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com>
Date: Sun, 15 Mar 2026 01:19:34 +0000
Subject: [PATCH] Keep all local changes while resolving conflicts
---
docs/NOTIFICATION_TEMPLATES.md | 100 ++++++
.../notification_processing/config.json | 147 +++++++++
server/messaging/notification_sections.py | 133 ++++++++
server/messaging/reporting.py | 51 +--
server/models/notification_instance.py | 56 +---
test/backend/test_notification_templates.py | 299 ++++++++++++++++++
test/integration/integration_test.py | 14 +-
7 files changed, 723 insertions(+), 77 deletions(-)
create mode 100644 docs/NOTIFICATION_TEMPLATES.md
create mode 100644 server/messaging/notification_sections.py
create mode 100644 test/backend/test_notification_templates.py
diff --git a/docs/NOTIFICATION_TEMPLATES.md b/docs/NOTIFICATION_TEMPLATES.md
new file mode 100644
index 00000000..2956bd32
--- /dev/null
+++ b/docs/NOTIFICATION_TEMPLATES.md
@@ -0,0 +1,100 @@
+# Notification Text Templates
+
+> Customize how devices and events appear in **text** notifications (email previews, push notifications, Apprise messages).
+
+By default, NetAlertX formats each device as a vertical list of `Header: Value` pairs. Text templates let you define a **single-line format per device** using `{FieldName}` placeholders — ideal for mobile notification previews and high-volume alerts.
+
+HTML email tables are **not affected** by these templates.
+
+## Quick Start
+
+1. Go to **Settings → Notification Processing**.
+2. Set a template string for the section you want to customize, e.g.:
+ - **Text Template: New Devices** → `{devName} ({eve_MAC}) - {eve_IP}`
+3. Save. The next notification will use your format.
+
+**Before (default):**
+```
+🆕 New devices
+---------
+devName: MyPhone
+eve_MAC: aa:bb:cc:dd:ee:ff
+devVendor: Apple
+eve_IP: 192.168.1.42
+eve_DateTime: 2025-01-15 10:30:00
+eve_EventType: New Device
+devComments:
+```
+
+**After (with template `{devName} ({eve_MAC}) - {eve_IP}`):**
+```
+🆕 New devices
+---------
+MyPhone (aa:bb:cc:dd:ee:ff) - 192.168.1.42
+```
+
+## Settings Reference
+
+| Setting | Type | Default | Description |
+|---------|------|---------|-------------|
+| `NTFPRCS_TEXT_SECTION_HEADERS` | Boolean | `true` | Show/hide section titles (e.g. `🆕 New devices \n---------`). |
+| `NTFPRCS_TEXT_TEMPLATE_new_devices` | String | *(empty)* | Template for new device rows. |
+| `NTFPRCS_TEXT_TEMPLATE_down_devices` | String | *(empty)* | Template for down device rows. |
+| `NTFPRCS_TEXT_TEMPLATE_down_reconnected` | String | *(empty)* | Template for reconnected device rows. |
+| `NTFPRCS_TEXT_TEMPLATE_events` | String | *(empty)* | Template for event rows. |
+| `NTFPRCS_TEXT_TEMPLATE_plugins` | String | *(empty)* | Template for plugin event rows. |
+
+When a template is **empty**, the section uses the original vertical `Header: Value` format (full backward compatibility).
+
+## Template Syntax
+
+Use `{FieldName}` to insert a value from the notification data. Field names are **case-sensitive** and must match the column names exactly.
+
+```
+{devName} ({eve_MAC}) connected at {eve_DateTime}
+```
+
+- No loops, conditionals, or nesting — just simple string replacement.
+- If a `{FieldName}` does not exist in the data, it is left as-is in the output (safe failure). For example, `{NonExistent}` renders literally as `{NonExistent}`.
+
+## Variable Availability by Section
+
+All four device sections (`new_devices`, `down_devices`, `down_reconnected`, `events`) share the same unified field names.
+
+### `new_devices`, `down_devices`, `down_reconnected`, and `events`
+
+| Variable | Description |
+|----------|-------------|
+| `{devName}` | Device display name |
+| `{eve_MAC}` | Device MAC address |
+| `{devVendor}` | Device vendor/manufacturer |
+| `{eve_IP}` | Device IP address |
+| `{eve_DateTime}` | Event timestamp |
+| `{eve_EventType}` | Type of event (e.g. `New Device`, `Connected`, `Device Down`) |
+| `{devComments}` | Device comments |
+
+**Example (new_devices/events):** `{devName} ({eve_MAC}) - {eve_IP} [{eve_EventType}]`
+
+**Example (down_devices):** `{devName} ({eve_MAC}) {devVendor} - went down at {eve_DateTime}`
+
+**Example (down_reconnected):** `{devName} ({eve_MAC}) reconnected at {eve_DateTime}`
+
+### `plugins`
+
+| Variable | Description |
+|----------|-------------|
+| `{Plugin}` | Plugin code name |
+| `{Object_PrimaryId}` | Primary identifier of the object |
+| `{Object_SecondaryId}` | Secondary identifier |
+| `{DateTimeChanged}` | Timestamp of change |
+| `{Watched_Value1}` | First watched value |
+| `{Watched_Value2}` | Second watched value |
+| `{Watched_Value3}` | Third watched value |
+| `{Watched_Value4}` | Fourth watched value |
+| `{Status}` | Plugin event status |
+
+**Example:** `{Plugin}: {Object_PrimaryId} - {Status}`
+
+## Section Headers Toggle
+
+Set **Text Section Headers** (`NTFPRCS_TEXT_SECTION_HEADERS`) to `false` to remove the section title separators from text notifications. This is useful when you want compact output without the `🆕 New devices \n---------` banners.
diff --git a/front/plugins/notification_processing/config.json b/front/plugins/notification_processing/config.json
index acb4fbbf..bf2c9e2a 100755
--- a/front/plugins/notification_processing/config.json
+++ b/front/plugins/notification_processing/config.json
@@ -152,6 +152,153 @@
"string": "You can specify a SQL where condition to filter out Events from notifications. For example AND devLastIP NOT LIKE '192.168.3.%' will always exclude any Event notifications for all devices with the IP starting with 192.168.3.%."
}
]
+<<<<<<< Updated upstream
+=======
+ },
+ {
+ "function": "TEXT_SECTION_HEADERS",
+ "type": {
+ "dataType": "boolean",
+ "elements": [
+ { "elementType": "input", "elementOptions": [{ "type": "checkbox" }], "transformers": [] }
+ ]
+ },
+ "default_value": true,
+ "options": [],
+ "localized": ["name", "description"],
+ "name": [
+ {
+ "language_code": "en_us",
+ "string": "Text Section Headers"
+ }
+ ],
+ "description": [
+ {
+ "language_code": "en_us",
+ "string": "Enable or disable section titles (e.g. 🆕 New devices \\n---------) in text notifications. Enabled by default for backward compatibility."
+ }
+ ]
+ },
+ {
+ "function": "TEXT_TEMPLATE_new_devices",
+ "type": {
+ "dataType": "string",
+ "elements": [
+ { "elementType": "input", "elementOptions": [], "transformers": [] }
+ ]
+ },
+ "default_value": "",
+ "options": [],
+ "localized": ["name", "description"],
+ "name": [
+ {
+ "language_code": "en_us",
+ "string": "Text Template: New Devices"
+ }
+ ],
+ "description": [
+ {
+ "language_code": "en_us",
+ "string": "Custom text template for new device notifications. Use {FieldName} placeholders, e.g. {devName} ({eve_MAC}) - {eve_IP}. Leave empty for default formatting. Available fields: {devName}, {eve_MAC}, {devVendor}, {eve_IP}, {eve_DateTime}, {eve_EventType}, {devComments}."
+ }
+ ]
+ },
+ {
+ "function": "TEXT_TEMPLATE_down_devices",
+ "type": {
+ "dataType": "string",
+ "elements": [
+ { "elementType": "input", "elementOptions": [], "transformers": [] }
+ ]
+ },
+ "default_value": "",
+ "options": [],
+ "localized": ["name", "description"],
+ "name": [
+ {
+ "language_code": "en_us",
+ "string": "Text Template: Down Devices"
+ }
+ ],
+ "description": [
+ {
+ "language_code": "en_us",
+ "string": "Custom text template for down device notifications. Use {FieldName} placeholders, e.g. {devName} ({eve_MAC}) - {eve_IP}. Leave empty for default formatting. Available fields: {devName}, {eve_MAC}, {devVendor}, {eve_IP}, {eve_DateTime}, {eve_EventType}, {devComments}."
+ }
+ ]
+ },
+ {
+ "function": "TEXT_TEMPLATE_down_reconnected",
+ "type": {
+ "dataType": "string",
+ "elements": [
+ { "elementType": "input", "elementOptions": [], "transformers": [] }
+ ]
+ },
+ "default_value": "",
+ "options": [],
+ "localized": ["name", "description"],
+ "name": [
+ {
+ "language_code": "en_us",
+ "string": "Text Template: Reconnected"
+ }
+ ],
+ "description": [
+ {
+ "language_code": "en_us",
+ "string": "Custom text template for reconnected device notifications. Use {FieldName} placeholders, e.g. {devName} ({eve_MAC}) reconnected at {eve_DateTime}. Leave empty for default formatting. Available fields: {devName}, {eve_MAC}, {devVendor}, {eve_IP}, {eve_DateTime}, {eve_EventType}, {devComments}."
+ }
+ ]
+ },
+ {
+ "function": "TEXT_TEMPLATE_events",
+ "type": {
+ "dataType": "string",
+ "elements": [
+ { "elementType": "input", "elementOptions": [], "transformers": [] }
+ ]
+ },
+ "default_value": "",
+ "options": [],
+ "localized": ["name", "description"],
+ "name": [
+ {
+ "language_code": "en_us",
+ "string": "Text Template: Events"
+ }
+ ],
+ "description": [
+ {
+ "language_code": "en_us",
+ "string": "Custom text template for event notifications. Use {FieldName} placeholders, e.g. {devName} ({eve_MAC}) {eve_EventType} at {eve_DateTime}. Leave empty for default formatting. Available fields: {devName}, {eve_MAC}, {devVendor}, {eve_IP}, {eve_DateTime}, {eve_EventType}, {devComments}."
+ }
+ ]
+ },
+ {
+ "function": "TEXT_TEMPLATE_plugins",
+ "type": {
+ "dataType": "string",
+ "elements": [
+ { "elementType": "input", "elementOptions": [], "transformers": [] }
+ ]
+ },
+ "default_value": "",
+ "options": [],
+ "localized": ["name", "description"],
+ "name": [
+ {
+ "language_code": "en_us",
+ "string": "Text Template: Plugins"
+ }
+ ],
+ "description": [
+ {
+ "language_code": "en_us",
+ "string": "Custom text template for plugin event notifications. Use {FieldName} placeholders, e.g. {Plugin}: {Object_PrimaryId} - {Status}. Leave empty for default formatting. Available fields: {Plugin}, {Object_PrimaryId}, {Object_SecondaryId}, {DateTimeChanged}, {Watched_Value1}, {Watched_Value2}, {Watched_Value3}, {Watched_Value4}, {Status}."
+ }
+ ]
+>>>>>>> Stashed changes
}
],
diff --git a/server/messaging/notification_sections.py b/server/messaging/notification_sections.py
new file mode 100644
index 00000000..a31fb204
--- /dev/null
+++ b/server/messaging/notification_sections.py
@@ -0,0 +1,133 @@
+# -------------------------------------------------------------------------------
+# notification_sections.py — Single source of truth for notification section
+# metadata: titles, SQL templates, datetime fields, and section ordering.
+#
+# Both reporting.py and notification_instance.py import from here.
+# -------------------------------------------------------------------------------
+
+# Canonical processing order
+SECTION_ORDER = [
+ "new_devices",
+ "down_devices",
+ "down_reconnected",
+ "events",
+ "plugins",
+]
+
+# Section display titles (used in text + HTML notifications)
+SECTION_TITLES = {
+ "new_devices": "🆕 New devices",
+ "down_devices": "🔴 Down devices",
+ "down_reconnected": "🔁 Reconnected down devices",
+ "events": "⚡ Events",
+ "plugins": "🔌 Plugins",
+}
+
+# Which column(s) contain datetime values per section (for timezone conversion)
+DATETIME_FIELDS = {
+ "new_devices": ["eve_DateTime"],
+ "down_devices": ["eve_DateTime"],
+ "down_reconnected": ["eve_DateTime"],
+ "events": ["eve_DateTime"],
+ "plugins": ["DateTimeChanged"],
+}
+
+# ---------------------------------------------------------------------------
+# SQL templates
+#
+# All device sections use unified DB column names so the JSON output
+# has consistent field names across new_devices, down_devices,
+# down_reconnected, and events.
+#
+# Placeholders:
+# {condition} — optional WHERE clause appended by condition builder
+# {alert_down_minutes} — runtime value, only used by down_devices
+# ---------------------------------------------------------------------------
+SQL_TEMPLATES = {
+ "new_devices": """
+ SELECT
+ devName,
+ eve_MAC,
+ devVendor,
+ devLastIP as eve_IP,
+ eve_DateTime,
+ eve_EventType,
+ devComments
+ FROM Events_Devices
+ WHERE eve_PendingAlertEmail = 1
+ AND eve_EventType = 'New Device' {condition}
+ ORDER BY eve_DateTime
+ """,
+ "down_devices": """
+ SELECT
+ devName,
+ eve_MAC,
+ devVendor,
+ eve_IP,
+ eve_DateTime,
+ eve_EventType,
+ devComments
+ FROM Events_Devices AS down_events
+ WHERE eve_PendingAlertEmail = 1
+ AND down_events.eve_EventType = 'Device Down'
+ AND eve_DateTime < datetime('now', '-{alert_down_minutes} minutes')
+ AND NOT EXISTS (
+ SELECT 1
+ FROM Events AS connected_events
+ WHERE connected_events.eve_MAC = down_events.eve_MAC
+ AND connected_events.eve_EventType = 'Connected'
+ AND connected_events.eve_DateTime > down_events.eve_DateTime
+ )
+ ORDER BY down_events.eve_DateTime
+ """,
+ "down_reconnected": """
+ SELECT
+ devName,
+ eve_MAC,
+ devVendor,
+ eve_IP,
+ eve_DateTime,
+ eve_EventType,
+ devComments
+ FROM Events_Devices AS reconnected_devices
+ WHERE reconnected_devices.eve_EventType = 'Down Reconnected'
+ AND reconnected_devices.eve_PendingAlertEmail = 1
+ ORDER BY reconnected_devices.eve_DateTime
+ """,
+ "events": """
+ SELECT
+ devName,
+ eve_MAC,
+ devVendor,
+ devLastIP as eve_IP,
+ eve_DateTime,
+ eve_EventType,
+ devComments
+ FROM Events_Devices
+ WHERE eve_PendingAlertEmail = 1
+ AND eve_EventType IN ('Connected', 'Down Reconnected', 'Disconnected','IP Changed') {condition}
+ ORDER BY eve_DateTime
+ """,
+ "plugins": """
+ SELECT
+ Plugin,
+ Object_PrimaryId,
+ Object_SecondaryId,
+ DateTimeChanged,
+ Watched_Value1,
+ Watched_Value2,
+ Watched_Value3,
+ Watched_Value4,
+ Status
+ FROM Plugins_Events
+ """,
+}
+
+# Sections that support user-defined condition filters
+SECTIONS_WITH_CONDITIONS = {"new_devices", "events"}
+
+# Legacy setting key mapping for condition filters
+SECTION_CONDITION_MAP = {
+ "new_devices": "NTFPRCS_new_dev_condition",
+ "events": "NTFPRCS_event_condition",
+}
diff --git a/server/messaging/reporting.py b/server/messaging/reporting.py
index 21c0d19e..d222529d 100755
--- a/server/messaging/reporting.py
+++ b/server/messaging/reporting.py
@@ -25,20 +25,20 @@ from helper import ( # noqa: E402 [flake8 lint suppression]
from logger import mylog # noqa: E402 [flake8 lint suppression]
from db.sql_safe_builder import create_safe_condition_builder # noqa: E402 [flake8 lint suppression]
from utils.datetime_utils import format_date_iso # noqa: E402 [flake8 lint suppression]
+from messaging.notification_sections import ( # noqa: E402 [flake8 lint suppression]
+ SECTION_ORDER,
+ SECTION_TITLES,
+ DATETIME_FIELDS,
+ SQL_TEMPLATES,
+ SECTIONS_WITH_CONDITIONS,
+ SECTION_CONDITION_MAP,
+)
import conf # noqa: E402 [flake8 lint suppression]
# ===============================================================================
# Timezone conversion
# ===============================================================================
-DATETIME_FIELDS = {
- "new_devices": ["Datetime"],
- "down_devices": ["eve_DateTime"],
- "down_reconnected": ["eve_DateTime"],
- "events": ["Datetime"],
- "plugins": ["DateTimeChanged"],
-}
-
def get_datetime_fields_from_columns(column_names):
return [
@@ -155,6 +155,7 @@ def get_notifications(db):
return ""
+<<<<<<< Updated upstream
# -------------------------
# SQL templates
# -------------------------
@@ -245,13 +246,17 @@ def get_notifications(db):
# Sections that support dynamic conditions
sections_with_conditions = {"new_devices", "events"}
+=======
+ # SQL templates with placeholders for runtime values
+ # {condition} and {alert_down_minutes} are formatted at query time
+>>>>>>> Stashed changes
# Initialize final structure
final_json = {}
- for section in ["new_devices", "down_devices", "down_reconnected", "events", "plugins"]:
+ for section in SECTION_ORDER:
final_json[section] = []
final_json[f"{section}_meta"] = {
- "title": section_titles.get(section, section),
+ "title": SECTION_TITLES.get(section, section),
"columnNames": []
}
@@ -260,17 +265,8 @@ def get_notifications(db):
# -------------------------
# Main loop
# -------------------------
- condition_builder = create_safe_condition_builder()
-
- SECTION_CONDITION_MAP = {
- "new_devices": "NTFPRCS_new_dev_condition",
- "events": "NTFPRCS_event_condition",
- }
-
- sections_with_conditions = set(SECTION_CONDITION_MAP.keys())
-
for section in sections:
- template = sql_templates.get(section)
+ template = SQL_TEMPLATES.get(section)
if not template:
mylog("verbose", ["[Notification] Unknown section: ", section])
@@ -280,7 +276,7 @@ def get_notifications(db):
parameters = {}
try:
- if section in sections_with_conditions:
+ if section in SECTIONS_WITH_CONDITIONS:
condition_key = SECTION_CONDITION_MAP.get(section)
condition_setting = get_setting_value(condition_key)
@@ -289,11 +285,18 @@ def get_notifications(db):
condition_setting
)
- sqlQuery = template.format(condition=safe_condition)
+ # Format template with runtime placeholders
+ format_vars = {"condition": safe_condition}
+ if section == "down_devices":
+ format_vars["alert_down_minutes"] = alert_down_minutes
+ sqlQuery = template.format(**format_vars)
except Exception as e:
mylog("verbose", [f"[Notification] Error building condition for {section}: ", e])
- sqlQuery = template.format(condition="")
+ fallback_vars = {"condition": ""}
+ if section == "down_devices":
+ fallback_vars["alert_down_minutes"] = alert_down_minutes
+ sqlQuery = template.format(**fallback_vars)
parameters = {}
mylog("debug", [f"[Notification] {section} SQL query: ", sqlQuery])
@@ -307,7 +310,7 @@ def get_notifications(db):
final_json[section] = json_obj.json.get("data", [])
final_json[f"{section}_meta"] = {
- "title": section_titles.get(section, section),
+ "title": SECTION_TITLES.get(section, section),
"columnNames": getattr(json_obj, "columnNames", [])
}
diff --git a/server/models/notification_instance.py b/server/models/notification_instance.py
index e45f97d3..686685d2 100755
--- a/server/models/notification_instance.py
+++ b/server/models/notification_instance.py
@@ -16,6 +16,7 @@ from helper import (
getBuildTimeStampAndVersion,
)
from messaging.in_app import write_notification
+from messaging.notification_sections import SECTION_ORDER
from utils.datetime_utils import timeNowUTC, timeNowTZ, get_timezone_offset
@@ -60,12 +61,7 @@ class NotificationInstance:
write_file(logPath + "/report_output.json", json.dumps(JSON))
# Check if nothing to report, end
- if (
- JSON["new_devices"] == [] and JSON["down_devices"] == [] and JSON["events"] == [] and JSON["plugins"] == [] and JSON["down_reconnected"] == []
- ):
- self.HasNotifications = False
- else:
- self.HasNotifications = True
+ self.HasNotifications = any(JSON.get(s, []) for s in SECTION_ORDER)
self.GUID = str(uuid.uuid4())
self.DateTimeCreated = timeNowUTC()
@@ -129,47 +125,13 @@ class NotificationInstance:
mail_text = mail_text.replace("REPORT_DASHBOARD_URL", self.serverUrl)
mail_html = mail_html.replace("REPORT_DASHBOARD_URL", self.serverUrl)
- # Start generating the TEXT & HTML notification messages
- # new_devices
- # ---
- html, text = construct_notifications(self.JSON, "new_devices")
-
- mail_text = mail_text.replace("NEW_DEVICES_TABLE", text + "\n")
- mail_html = mail_html.replace("NEW_DEVICES_TABLE", html)
- mylog("verbose", ["[Notification] New Devices sections done."])
-
- # down_devices
- # ---
- html, text = construct_notifications(self.JSON, "down_devices")
-
- mail_text = mail_text.replace("DOWN_DEVICES_TABLE", text + "\n")
- mail_html = mail_html.replace("DOWN_DEVICES_TABLE", html)
- mylog("verbose", ["[Notification] Down Devices sections done."])
-
- # down_reconnected
- # ---
- html, text = construct_notifications(self.JSON, "down_reconnected")
-
- mail_text = mail_text.replace("DOWN_RECONNECTED_TABLE", text + "\n")
- mail_html = mail_html.replace("DOWN_RECONNECTED_TABLE", html)
- mylog("verbose", ["[Notification] Reconnected Down Devices sections done."])
-
- # events
- # ---
- html, text = construct_notifications(self.JSON, "events")
-
- mail_text = mail_text.replace("EVENTS_TABLE", text + "\n")
- mail_html = mail_html.replace("EVENTS_TABLE", html)
- mylog("verbose", ["[Notification] Events sections done."])
-
- # plugins
- # ---
- html, text = construct_notifications(self.JSON, "plugins")
-
- mail_text = mail_text.replace("PLUGINS_TABLE", text + "\n")
- mail_html = mail_html.replace("PLUGINS_TABLE", html)
-
- mylog("verbose", ["[Notification] Plugins sections done."])
+ # Generate TEXT & HTML for each notification section
+ for section in SECTION_ORDER:
+ html, text = construct_notifications(self.JSON, section)
+ placeholder = f"{section.upper()}_TABLE"
+ mail_text = mail_text.replace(placeholder, text + "\n")
+ mail_html = mail_html.replace(placeholder, html)
+ mylog("verbose", [f"[Notification] {section} section done."])
final_text = removeDuplicateNewLines(mail_text)
diff --git a/test/backend/test_notification_templates.py b/test/backend/test_notification_templates.py
new file mode 100644
index 00000000..0653493d
--- /dev/null
+++ b/test/backend/test_notification_templates.py
@@ -0,0 +1,299 @@
+"""
+NetAlertX Notification Text Template Tests
+
+Tests the template substitution and section header toggle in
+construct_notifications(). All tests mock get_setting_value to avoid
+database/config dependencies.
+
+License: GNU GPLv3
+"""
+
+import sys
+import os
+import unittest
+from unittest.mock import patch
+
+# Add the server directory to the path for imports
+INSTALL_PATH = os.getenv("NETALERTX_APP", "/app")
+sys.path.extend([f"{INSTALL_PATH}/server"])
+
+
+def _make_json(section, devices, column_names, title="Test Section"):
+ """Helper to build the JSON structure expected by construct_notifications."""
+ return {
+ section: devices,
+ f"{section}_meta": {
+ "title": title,
+ "columnNames": column_names,
+ },
+ }
+
+
+SAMPLE_NEW_DEVICES = [
+ {
+ "devName": "MyPhone",
+ "eve_MAC": "aa:bb:cc:dd:ee:ff",
+ "devVendor": "",
+ "eve_IP": "192.168.1.42",
+ "eve_DateTime": "2025-01-15 10:30:00",
+ "eve_EventType": "New Device",
+ "devComments": "",
+ },
+ {
+ "devName": "Laptop",
+ "eve_MAC": "11:22:33:44:55:66",
+ "devVendor": "Dell",
+ "eve_IP": "192.168.1.99",
+ "eve_DateTime": "2025-01-15 11:00:00",
+ "eve_EventType": "New Device",
+ "devComments": "Office",
+ },
+]
+
+NEW_DEVICE_COLUMNS = ["devName", "eve_MAC", "devVendor", "eve_IP", "eve_DateTime", "eve_EventType", "devComments"]
+
+
+class TestConstructNotificationsTemplates(unittest.TestCase):
+ """Tests for template substitution in construct_notifications."""
+
+ def _setting_factory(self, overrides=None):
+ """Return a mock get_setting_value that resolves from overrides dict."""
+ settings = overrides or {}
+
+ def mock_get(key):
+ return settings.get(key, "")
+
+ return mock_get
+
+ # -----------------------------------------------------------------
+ # Empty section should always return ("", "") regardless of settings
+ # -----------------------------------------------------------------
+ @patch("models.notification_instance.get_setting_value")
+ def test_empty_section_returns_empty(self, mock_setting):
+ from models.notification_instance import construct_notifications
+
+ mock_setting.return_value = ""
+ json_data = _make_json("new_devices", [], [])
+ html, text = construct_notifications(json_data, "new_devices")
+ self.assertEqual(html, "")
+ self.assertEqual(text, "")
+
+ # -----------------------------------------------------------------
+ # Legacy fallback: no template → vertical Header: Value per device
+ # -----------------------------------------------------------------
+ @patch("models.notification_instance.get_setting_value")
+ def test_legacy_fallback_no_template(self, mock_setting):
+ from models.notification_instance import construct_notifications
+
+ mock_setting.side_effect = self._setting_factory({
+ "NTFPRCS_TEXT_SECTION_HEADERS": True,
+ "NTFPRCS_TEXT_TEMPLATE_new_devices": "",
+ })
+
+ json_data = _make_json(
+ "new_devices", SAMPLE_NEW_DEVICES, NEW_DEVICE_COLUMNS, "🆕 New devices"
+ )
+ html, text = construct_notifications(json_data, "new_devices")
+
+ # Section header must be present
+ self.assertIn("🆕 New devices", text)
+ self.assertIn("---------", text)
+
+ # Legacy format: each header appears as "Header: \tValue"
+ self.assertIn("eve_MAC:", text)
+ self.assertIn("aa:bb:cc:dd:ee:ff", text)
+ self.assertIn("devName:", text)
+ self.assertIn("MyPhone", text)
+
+ # HTML must still be generated
+ self.assertNotEqual(html, "")
+
+ # -----------------------------------------------------------------
+ # Template substitution: single-line format per device
+ # -----------------------------------------------------------------
+ @patch("models.notification_instance.get_setting_value")
+ def test_template_substitution(self, mock_setting):
+ from models.notification_instance import construct_notifications
+
+ mock_setting.side_effect = self._setting_factory({
+ "NTFPRCS_TEXT_SECTION_HEADERS": True,
+ "NTFPRCS_TEXT_TEMPLATE_new_devices": "{devName} ({eve_MAC}) - {eve_IP}",
+ })
+
+ json_data = _make_json(
+ "new_devices", SAMPLE_NEW_DEVICES, NEW_DEVICE_COLUMNS, "🆕 New devices"
+ )
+ _, text = construct_notifications(json_data, "new_devices")
+
+ self.assertIn("MyPhone (aa:bb:cc:dd:ee:ff) - 192.168.1.42", text)
+ self.assertIn("Laptop (11:22:33:44:55:66) - 192.168.1.99", text)
+
+ # -----------------------------------------------------------------
+ # Missing field: {NonExistent} left as-is (safe failure)
+ # -----------------------------------------------------------------
+ @patch("models.notification_instance.get_setting_value")
+ def test_missing_field_safe_failure(self, mock_setting):
+ from models.notification_instance import construct_notifications
+
+ mock_setting.side_effect = self._setting_factory({
+ "NTFPRCS_TEXT_SECTION_HEADERS": True,
+ "NTFPRCS_TEXT_TEMPLATE_new_devices": "{devName} - {NonExistent}",
+ })
+
+ json_data = _make_json(
+ "new_devices", SAMPLE_NEW_DEVICES, NEW_DEVICE_COLUMNS, "🆕 New devices"
+ )
+ _, text = construct_notifications(json_data, "new_devices")
+
+ self.assertIn("MyPhone - {NonExistent}", text)
+ self.assertIn("Laptop - {NonExistent}", text)
+
+ # -----------------------------------------------------------------
+ # Section headers disabled: no title/separator in text output
+ # -----------------------------------------------------------------
+ @patch("models.notification_instance.get_setting_value")
+ def test_section_headers_disabled(self, mock_setting):
+ from models.notification_instance import construct_notifications
+
+ mock_setting.side_effect = self._setting_factory({
+ "NTFPRCS_TEXT_SECTION_HEADERS": False,
+ "NTFPRCS_TEXT_TEMPLATE_new_devices": "{devName} ({eve_MAC})",
+ })
+
+ json_data = _make_json(
+ "new_devices", SAMPLE_NEW_DEVICES, NEW_DEVICE_COLUMNS, "🆕 New devices"
+ )
+ _, text = construct_notifications(json_data, "new_devices")
+
+ self.assertNotIn("🆕 New devices", text)
+ self.assertNotIn("---------", text)
+ # Template output still present
+ self.assertIn("MyPhone (aa:bb:cc:dd:ee:ff)", text)
+
+ # -----------------------------------------------------------------
+ # Section headers enabled (default when setting absent/empty)
+ # -----------------------------------------------------------------
+ @patch("models.notification_instance.get_setting_value")
+ def test_section_headers_default_enabled(self, mock_setting):
+ from models.notification_instance import construct_notifications
+
+ # Simulate setting not configured (returns empty string)
+ mock_setting.side_effect = self._setting_factory({})
+
+ json_data = _make_json(
+ "new_devices", SAMPLE_NEW_DEVICES, NEW_DEVICE_COLUMNS, "🆕 New devices"
+ )
+ _, text = construct_notifications(json_data, "new_devices")
+
+ # Headers should be shown by default
+ self.assertIn("🆕 New devices", text)
+ self.assertIn("---------", text)
+
+ # -----------------------------------------------------------------
+ # Mixed valid and invalid fields in same template
+ # -----------------------------------------------------------------
+ @patch("models.notification_instance.get_setting_value")
+ def test_mixed_valid_and_invalid_fields(self, mock_setting):
+ from models.notification_instance import construct_notifications
+
+ mock_setting.side_effect = self._setting_factory({
+ "NTFPRCS_TEXT_SECTION_HEADERS": True,
+ "NTFPRCS_TEXT_TEMPLATE_new_devices": "{devName} ({BadField}) - {eve_IP}",
+ })
+
+ json_data = _make_json(
+ "new_devices", SAMPLE_NEW_DEVICES, NEW_DEVICE_COLUMNS, "🆕 New devices"
+ )
+ _, text = construct_notifications(json_data, "new_devices")
+
+ self.assertIn("MyPhone ({BadField}) - 192.168.1.42", text)
+
+ # -----------------------------------------------------------------
+ # Down devices section uses same column names as all other sections
+ # -----------------------------------------------------------------
+ @patch("models.notification_instance.get_setting_value")
+ def test_down_devices_template(self, mock_setting):
+ from models.notification_instance import construct_notifications
+
+ mock_setting.side_effect = self._setting_factory({
+ "NTFPRCS_TEXT_SECTION_HEADERS": True,
+ "NTFPRCS_TEXT_TEMPLATE_down_devices": "{devName} ({eve_MAC}) down since {eve_DateTime}",
+ })
+
+ down_devices = [
+ {
+ "devName": "Router",
+ "eve_MAC": "ff:ee:dd:cc:bb:aa",
+ "devVendor": "Cisco",
+ "eve_IP": "10.0.0.1",
+ "eve_DateTime": "2025-01-15 08:00:00",
+ "eve_EventType": "Device Down",
+ "devComments": "",
+ }
+ ]
+ columns = ["devName", "eve_MAC", "devVendor", "eve_IP", "eve_DateTime", "eve_EventType", "devComments"]
+
+ json_data = _make_json("down_devices", down_devices, columns, "🔴 Down devices")
+ _, text = construct_notifications(json_data, "down_devices")
+
+ self.assertIn("Router (ff:ee:dd:cc:bb:aa) down since 2025-01-15 08:00:00", text)
+
+ # -----------------------------------------------------------------
+ # Down reconnected section uses same unified column names
+ # -----------------------------------------------------------------
+ @patch("models.notification_instance.get_setting_value")
+ def test_down_reconnected_template(self, mock_setting):
+ from models.notification_instance import construct_notifications
+
+ mock_setting.side_effect = self._setting_factory({
+ "NTFPRCS_TEXT_SECTION_HEADERS": True,
+ "NTFPRCS_TEXT_TEMPLATE_down_reconnected": "{devName} ({eve_MAC}) reconnected at {eve_DateTime}",
+ })
+
+ reconnected = [
+ {
+ "devName": "Switch",
+ "eve_MAC": "aa:11:bb:22:cc:33",
+ "devVendor": "Netgear",
+ "eve_IP": "10.0.0.2",
+ "eve_DateTime": "2025-01-15 09:30:00",
+ "eve_EventType": "Down Reconnected",
+ "devComments": "",
+ }
+ ]
+ columns = ["devName", "eve_MAC", "devVendor", "eve_IP", "eve_DateTime", "eve_EventType", "devComments"]
+
+ json_data = _make_json("down_reconnected", reconnected, columns, "🔁 Reconnected down devices")
+ _, text = construct_notifications(json_data, "down_reconnected")
+
+ self.assertIn("Switch (aa:11:bb:22:cc:33) reconnected at 2025-01-15 09:30:00", text)
+
+ # -----------------------------------------------------------------
+ # HTML output is unchanged regardless of template config
+ # -----------------------------------------------------------------
+ @patch("models.notification_instance.get_setting_value")
+ def test_html_unchanged_with_template(self, mock_setting):
+ from models.notification_instance import construct_notifications
+
+ # Get HTML without template
+ mock_setting.side_effect = self._setting_factory({
+ "NTFPRCS_TEXT_SECTION_HEADERS": True,
+ "NTFPRCS_TEXT_TEMPLATE_new_devices": "",
+ })
+ json_data = _make_json(
+ "new_devices", SAMPLE_NEW_DEVICES, NEW_DEVICE_COLUMNS, "🆕 New devices"
+ )
+ html_without, _ = construct_notifications(json_data, "new_devices")
+
+ # Get HTML with template
+ mock_setting.side_effect = self._setting_factory({
+ "NTFPRCS_TEXT_SECTION_HEADERS": True,
+ "NTFPRCS_TEXT_TEMPLATE_new_devices": "{devName} ({eve_MAC})",
+ })
+ html_with, _ = construct_notifications(json_data, "new_devices")
+
+ self.assertEqual(html_without, html_with)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/integration/integration_test.py b/test/integration/integration_test.py
index 200e0f49..b4e28f06 100755
--- a/test/integration/integration_test.py
+++ b/test/integration/integration_test.py
@@ -42,8 +42,10 @@ def test_db(test_db_path):
eve_MAC TEXT,
eve_DateTime TEXT,
devLastIP TEXT,
+ eve_IP TEXT,
eve_EventType TEXT,
devName TEXT,
+ devVendor TEXT,
devComments TEXT,
eve_PendingAlertEmail INTEGER
)
@@ -84,13 +86,13 @@ def test_db(test_db_path):
# Insert test data
test_data = [
- ('aa:bb:cc:dd:ee:ff', '2024-01-01 12:00:00', '192.168.1.100', 'New Device', 'Test Device', 'Test Comment', 1),
- ('11:22:33:44:55:66', '2024-01-01 12:01:00', '192.168.1.101', 'Connected', 'Test Device 2', 'Another Comment', 1),
- ('77:88:99:aa:bb:cc', '2024-01-01 12:02:00', '192.168.1.102', 'Disconnected', 'Test Device 3', 'Third Comment', 1),
+ ('aa:bb:cc:dd:ee:ff', '2024-01-01 12:00:00', '192.168.1.100', '192.168.1.100', 'New Device', 'Test Device', 'Apple', 'Test Comment', 1),
+ ('11:22:33:44:55:66', '2024-01-01 12:01:00', '192.168.1.101', '192.168.1.101', 'Connected', 'Test Device 2', 'Dell', 'Another Comment', 1),
+ ('77:88:99:aa:bb:cc', '2024-01-01 12:02:00', '192.168.1.102', '192.168.1.102', 'Disconnected', 'Test Device 3', 'Cisco', 'Third Comment', 1),
]
cur.executemany('''
- INSERT INTO Events_Devices (eve_MAC, eve_DateTime, devLastIP, eve_EventType, devName, devComments, eve_PendingAlertEmail)
- VALUES (?, ?, ?, ?, ?, ?, ?)
+ INSERT INTO Events_Devices (eve_MAC, eve_DateTime, devLastIP, eve_IP, eve_EventType, devName, devVendor, devComments, eve_PendingAlertEmail)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''', test_data)
conn.commit()
@@ -115,7 +117,7 @@ def test_fresh_install_compatibility(builder):
def test_existing_db_compatibility():
mock_db = Mock()
mock_result = Mock()
- mock_result.columnNames = ['MAC', 'Datetime', 'IP', 'Event Type', 'Device name', 'Comments']
+ mock_result.columnNames = ['devName', 'eve_MAC', 'devVendor', 'eve_IP', 'eve_DateTime', 'eve_EventType', 'devComments']
mock_result.json = {'data': []}
mock_db.get_table_as_json.return_value = mock_result