Files
NetAlertX/server/models/device_history_instance.py
Jokob @NetAlertX 18ec0ce96c feat: Implement DevicesHistory feature with triggers and history tracking
- Added db_history.py to manage DevicesHistory table and triggers for INSERT and UPDATE operations.
- Created device_history_instance.py for querying and grouping DevicesHistory records.
- Developed change_history.php for displaying device change history with filtering and pagination.
- Introduced skel_device_details_tab_history.php for skeleton loading state in device details tab.
- Added unit tests in test_device_history.py to validate trigger functionality and history management.
- Implemented filter population and pagination in the change history UI.
2026-07-04 23:34:52 +00:00

217 lines
7.6 KiB
Python

"""
device_history_instance.py — Query, group, and prune DevicesHistory records.
Writes are handled entirely by SQLite triggers (trg_devhist_update /
trg_devhist_insert) created in db/db_history.py. This class is read/prune only.
"""
from database import get_temp_db_connection
from logger import mylog
class DevicesHistoryInstance:
# -------------------------------------------------------------------------
# DB helpers (mirrors DeviceInstance pattern)
# -------------------------------------------------------------------------
def _fetchall(self, query, params=()):
conn = get_temp_db_connection()
rows = conn.execute(query, params).fetchall()
conn.close()
return [dict(r) for r in rows]
def _execute(self, query, params=()):
conn = get_temp_db_connection()
cur = conn.cursor()
cur.execute(query, params)
affected = cur.rowcount
conn.commit()
conn.close()
return affected
# -------------------------------------------------------------------------
# Public query API
# -------------------------------------------------------------------------
def get_grouped_history(self, devGUID, changedColumn=None, changedBy=None,
limit=50, offset=0):
"""
Return grouped change history for a single device.
Rows are grouped by (timestamp, changedBy, devGUID). Pagination is
applied at the group level so that `limit` controls how many distinct
change events are returned, not how many individual field rows.
Args:
devGUID: Device GUID to filter on (required).
changedColumn: Optional field-name filter — only groups that contain
a change to this column are included.
changedBy: Optional source filter (e.g. 'USER', 'ARPSCAN').
limit: Max number of grouped events to return.
offset: Number of grouped events to skip (for pagination).
Returns:
list[dict] each with keys: devGUID, timestamp, changedBy, changes
"""
return self._query_grouped(
devGUID=devGUID,
changedColumn=changedColumn,
changedBy=changedBy,
limit=limit,
offset=offset,
)
def get_all_grouped_history(self, changedColumn=None, changedBy=None,
limit=100, offset=0):
"""
Return grouped change history across all devices (global view).
Same behaviour as get_grouped_history but without a devGUID filter.
"""
return self._query_grouped(
devGUID=None,
changedColumn=changedColumn,
changedBy=changedBy,
limit=limit,
offset=offset,
)
def get_available_filter_values(self, devGUID=None):
"""
Return distinct changedBy and changedColumn values present in the table.
Used by the frontend to dynamically populate filter dropdowns.
Args:
devGUID: If provided, scope to a single device.
Returns:
dict with keys 'changedBy' and 'changedColumn', each a sorted list.
"""
where = "WHERE devGUID = ?" if devGUID else ""
params = (devGUID,) if devGUID else ()
by_rows = self._fetchall(
f"SELECT DISTINCT changedBy FROM DevicesHistory {where} ORDER BY changedBy",
params,
)
col_rows = self._fetchall(
f"SELECT DISTINCT changedColumn FROM DevicesHistory {where} ORDER BY changedColumn",
params,
)
return {
"changedBy": [r["changedBy"] for r in by_rows],
"changedColumn": [r["changedColumn"] for r in col_rows],
}
def get_total_group_count(self, devGUID=None, changedColumn=None, changedBy=None):
"""
Return the total number of distinct change-event groups matching the
given filters. Used by the frontend to calculate total pages.
"""
clauses, params = self._build_clauses(devGUID, changedColumn, changedBy)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
rows = self._fetchall(
f"""
SELECT COUNT(*) AS cnt FROM (
SELECT DISTINCT timestamp, changedBy, devGUID
FROM DevicesHistory {where}
)
""",
tuple(params),
)
return rows[0]["cnt"] if rows else 0
# -------------------------------------------------------------------------
# Retention management
# -------------------------------------------------------------------------
def prune_history(self, days):
"""
Delete DevicesHistory rows older than `days` days.
Called by the DBCLNP plugin during scheduled cleanup.
Args:
days: Retention window. Rows with timestamp older than this are
deleted.
Returns:
int: Number of rows deleted.
"""
if days <= 0:
mylog("verbose", ["[DevicesHistory] prune skipped — DEV_HIST_DAYS is 0"])
return 0
affected = self._execute(
"DELETE FROM DevicesHistory WHERE timestamp < datetime('now', ?)",
(f"-{days} days",),
)
mylog("verbose", [f"[DevicesHistory] pruned {affected} rows older than {days} days"])
return affected
# -------------------------------------------------------------------------
# Internal helpers
# -------------------------------------------------------------------------
@staticmethod
def _build_clauses(devGUID, changedColumn, changedBy):
clauses = []
params = []
if devGUID:
clauses.append("devGUID = ?")
params.append(devGUID)
if changedColumn:
clauses.append("changedColumn = ?")
params.append(changedColumn)
if changedBy:
clauses.append("changedBy = ?")
params.append(changedBy)
return clauses, params
def _query_grouped(self, devGUID, changedColumn, changedBy, limit, offset):
"""
Core fetch-and-group logic shared by all public query methods.
1. Fetch matching rows ordered by timestamp DESC.
2. Group by (timestamp, changedBy, devGUID) in Python.
3. Apply limit/offset at the group level.
"""
clauses, params = self._build_clauses(devGUID, changedColumn, changedBy)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
rows = self._fetchall(
f"""
SELECT devGUID, timestamp, changedBy, changedColumn, oldValue, newValue
FROM DevicesHistory
{where}
ORDER BY timestamp DESC, changedBy, changedColumn
""",
tuple(params),
)
# Group by (timestamp, changedBy, devGUID) — preserving DESC order
groups = {}
order = []
for row in rows:
key = (row["timestamp"], row["changedBy"], row["devGUID"])
if key not in groups:
groups[key] = {
"devGUID": row["devGUID"],
"timestamp": row["timestamp"],
"changedBy": row["changedBy"],
"changes": [],
}
order.append(key)
groups[key]["changes"].append({
"changedColumn": row["changedColumn"],
"oldValue": row["oldValue"],
"newValue": row["newValue"],
})
grouped_list = [groups[k] for k in order]
return grouped_list[offset: offset + limit]