BE+FE: change log

This commit is contained in:
jokob-sk
2026-07-05 22:38:29 +10:00
parent 35313feff4
commit e0449bed8e
44 changed files with 1346 additions and 542 deletions

View File

@@ -777,12 +777,12 @@ def api_devices_totals(payload=None):
@validate_request(
operation_id="get_device_history_filters",
summary="Get Device History Filter Values",
description="Return distinct changedBy and changedColumn values available in DevicesHistory. Optionally scope to a single device with ?devGuid=<guid>.",
description="Return distinct changedBy and changedColumn values available in DevicesHistory. Optionally scope to a single device with ?devGUID=<guid>.",
tags=["devices"],
auth_callable=is_authorized
)
def api_devices_history_filters(payload=None):
dev_guid = request.args.get("devGuid") or None
dev_guid = request.args.get("devGUID") or None
filters = DevicesHistoryInstance().get_available_filter_values(devGUID=dev_guid)
return jsonify({"success": True, "data": filters})

View File

@@ -33,6 +33,7 @@ from .graphql_helpers import ( # noqa: E402 [flake8 lint suppression]
apply_common_pagination,
apply_plugin_filters,
apply_events_filters,
extract_paging
)
from models.device_history_instance import DevicesHistoryInstance # noqa: E402
@@ -65,72 +66,98 @@ def _to_graphql_history(groups):
class Query(ObjectType):
# --- DEVICES ---
devices = Field(DeviceResult, options=PageQueryOptionsInput())
# --- DEVICE HISTORY ---
deviceHistoryGrouped = Field(
DeviceHistoryResult,
devGuid=String(required=True, description="Device GUID to fetch history for"),
devGUID=String(required=False, description="Device GUID to fetch history for"),
changedColumn=String(description="Filter to groups containing this field change"),
changedBy=String(description="Filter to a specific attribution source"),
limit=graphene.Int(description="Max grouped events to return (default 50)"),
offset=graphene.Int(description="Grouped-event offset for pagination (default 0)"),
options=PageQueryOptionsInput(description="Paging, sorting, filtering options"),
)
allDeviceHistoryGrouped = Field(
DeviceHistoryResult,
changedColumn=String(description="Filter to groups containing this field change"),
changedBy=String(description="Filter to a specific attribution source"),
limit=graphene.Int(description="Max grouped events to return (default 100)"),
offset=graphene.Int(description="Grouped-event offset for pagination (default 0)"),
)
def resolve_deviceHistoryGrouped(
self,
info,
devGUID=None,
changedColumn=None,
changedBy=None,
options=None):
"""
Return grouped device history.
def resolve_deviceHistoryGrouped(self, info, devGuid, changedColumn=None,
changedBy=None, limit=50, offset=0):
If `devGUID` is provided, results are restricted to a single device.
If omitted, history across all devices is returned.
This resolver supports:
- Optional filtering by device GUID, change column, and attribution source
- Pagination via `options` (page/limit/offset)
- Sorting at the grouped event level
- Full-text search across history fields (if enabled in backend)
Internally:
- Raw history rows are fetched from the database
- Rows are grouped in Python by (timestamp, changedBy, devGUID)
- Sorting is applied to grouped events
- Pagination is applied after grouping
Args:
info: GraphQL execution context
devGUID (str | None): Optional device GUID filter
changedColumn (str | None): Filter to only events containing this column
changedBy (str | None): Filter by attribution source
options (PageQueryOptionsInput | None): Paging, sorting, and search options
Returns:
DeviceHistoryResult: Grouped history events and total count
"""
try:
mylog("none", f"[HISTORY] unified resolver devGUID={devGUID} changedColumn={changedColumn} changedBy={changedBy}")
h = DevicesHistoryInstance()
groups = h.get_grouped_history(
devGUID=devGuid,
changedColumn=changedColumn,
changedBy=changedBy,
limit=limit,
offset=offset,
)
total = h.get_total_group_count(
devGUID=devGuid,
changedColumn=changedColumn,
changedBy=changedBy,
)
paging = extract_paging(options)
if devGUID:
groups = h.get_grouped_history(
devGUID=devGUID,
changedColumn=changedColumn,
changedBy=changedBy,
limit=paging["limit"],
offset=paging["offset"],
sort=paging["sort"],
search=paging["search"],
)
total = h.get_total_group_count(
devGUID=devGUID,
changedColumn=changedColumn,
changedBy=changedBy,
)
else:
groups = h.get_all_grouped_history(
changedColumn=changedColumn,
changedBy=changedBy,
limit=paging["limit"],
offset=paging["offset"],
sort=paging["sort"],
search=paging["search"],
)
total = h.get_total_group_count(
changedColumn=changedColumn,
changedBy=changedBy,
)
return DeviceHistoryResult(
history=_to_graphql_history(groups),
count=total,
)
except Exception as e:
mylog("none", f"[graphql] resolve_deviceHistoryGrouped error: {e}")
mylog("none", f"[graphql] unified resolver error: {e}")
return DeviceHistoryResult(history=[], count=0)
def resolve_allDeviceHistoryGrouped(self, info, changedColumn=None,
changedBy=None, limit=100, offset=0):
try:
h = DevicesHistoryInstance()
groups = h.get_all_grouped_history(
changedColumn=changedColumn,
changedBy=changedBy,
limit=limit,
offset=offset,
)
total = h.get_total_group_count(
changedColumn=changedColumn,
changedBy=changedBy,
)
return DeviceHistoryResult(
history=_to_graphql_history(groups),
count=total,
)
except Exception as e:
mylog("none", f"[graphql] resolve_allDeviceHistoryGrouped error: {e}")
return DeviceHistoryResult(history=[], count=0)
# --- DEVICES ---
devices = Field(DeviceResult, options=PageQueryOptionsInput())
def resolve_devices(self, info, options=None):
# mylog('none', f'[graphql_schema] resolve_devices: {self}')

View File

@@ -139,3 +139,29 @@ def apply_events_filters(data, options):
]
return data
def extract_paging(options):
if not options:
return {
"page": 1,
"limit": 50,
"offset": 0,
"sort": [],
"search": None
}
page = getattr(options, "page", 1) or 1
limit = getattr(options, "limit", 50) or 50
search = getattr(options, "search", None)
sort = getattr(options, "sort", []) or []
offset = (page - 1) * limit
return {
"page": page,
"limit": limit,
"offset": offset,
"sort": sort,
"search": search
}

View File

@@ -10,7 +10,7 @@ from graphene import (
class SortOptionsInput(InputObjectType):
field = String()
order = String()
order = String() # direction asc, desc
class FilterOptionsInput(InputObjectType):
@@ -20,8 +20,8 @@ class FilterOptionsInput(InputObjectType):
class PageQueryOptionsInput(InputObjectType):
page = Int()
limit = Int()
sort = List(SortOptionsInput)
limit = Int() # pageSize
sort = List(SortOptionsInput) # sorting
search = String()
status = String()
filters = List(FilterOptionsInput)

View File

@@ -51,12 +51,6 @@ def _load_language_display_names():
# ===============================================================================
# Initialise user defined values
# ===============================================================================
# -------------------------------------------------------------------------------
# Import user values
# Check config dictionary
# -------------------------------------------------------------------------------
# managing application settings, ensuring SQL safety for user input, and updating internal configuration lists
def ccd(
@@ -75,6 +69,43 @@ def ccd(
overriddenByEnv=0,
all_plugins=[],
):
"""
Create or update a configuration setting entry and synchronise it across
in-memory config, plugin settings, and SQL-safe storage structures.
This function is responsible for:
- Resolving a setting value from defaults or existing config
- Sanitising text inputs for SQL safety
- Propagating updates into plugin configuration structures when
overridden by environment variables
- Maintaining both raw and SQL-safe representations of settings
- Storing optional JSON metadata alongside settings entries
Parameters:
key (str): Unique identifier for the setting.
default (any): Default value used if no existing config value exists.
config_dir (dict): Dictionary holding current runtime configuration.
name (str): Human-readable name of the setting.
inputtype (str): Type of input (e.g. "text", "number", etc.).
options (list|str): Allowed options or configuration constraints.
group (str): Logical grouping/category of the setting.
events (list, optional): Events triggered by changes to this setting.
desc (str, optional): Human-readable description of the setting.
setJsonMetadata (dict, optional): Additional metadata stored as JSON.
overrideTemplate (dict, optional): Template overrides (currently unused).
forceDefault (bool, optional): If True, ignores existing config value.
overriddenByEnv (int, optional): Flag indicating environment override.
all_plugins (list, optional): Plugin list for propagating updates.
Returns:
any: The resolved configuration value after processing.
Side Effects:
- Updates config_dir in-place
- Updates conf.mySettings and conf.mySettingsSQLsafe
- Updates plugin setting values if overridden by environment
- Adds/updates metadata entries for non-metadata keys
"""
if events is None:
events = []
if setJsonMetadata is None:
@@ -330,7 +361,7 @@ def importConfigs(pm, db, all_plugins):
)
conf.DEV_HIST_DAYS = ccd(
"DEV_HIST_DAYS",
14,
1,
c_d,
"Device history retention (days)",
'{"dataType":"integer", "elements": [{"elementType" : "input", "elementOptions" : [{"type": "number"}] ,"transformers": []}]}',
@@ -338,25 +369,13 @@ def importConfigs(pm, db, all_plugins):
"General",
desc="Number of days to retain device change history. Set to 0 to completely disable the auditing engine.",
)
_DEV_HIST_TRACKED_DEFAULT = [
'devMac', 'devName', 'devOwner', 'devType', 'devVendor', 'devFavorite',
'devGroup', 'devComments', 'devLastIP', 'devFQDN', 'devPrimaryIPv4',
'devPrimaryIPv6', 'devVlan', 'devForceStatus', 'devStaticIP', 'devScan',
'devAlertDown', 'devCanSleep', 'devSkipRepeated', 'devLocation',
'devIsArchived', 'devParentMAC', 'devParentPort', 'devParentRelType',
'devReqNicsOnline', 'devIcon', 'devSite', 'devSSID', 'devSyncHubNode',
'devSourcePlugin', 'devMacSource', 'devNameSource', 'devFQDNSource',
'devLastIPSource', 'devVendorSource', 'devSSIDSource',
'devParentMACSource', 'devParentPortSource', 'devParentRelTypeSource',
'devVlanSource', 'devCustomProps',
]
conf.DEV_HIST_TRACKED = ccd(
"DEV_HIST_TRACKED",
_DEV_HIST_TRACKED_DEFAULT,
['devMac', 'devName', 'devOwner', 'devType', 'devVendor', 'devFavorite', 'devGroup', 'devComments', 'devLastIP', 'devFQDN', 'devPrimaryIPv4', 'devPrimaryIPv6', 'devVlan', 'devForceStatus', 'devStaticIP', 'devScan', 'devAlertDown', 'devCanSleep', 'devSkipRepeated', 'devLocation', 'devIsArchived', 'devParentMAC', 'devParentPort', 'devParentRelType', 'devReqNicsOnline', 'devIcon', 'devSite', 'devSSID', 'devSyncHubNode'], # noqa: E501
c_d,
"Device history tracked fields",
'{"dataType":"array","elements":[{"elementType":"select","elementHasInputValue":1,"elementOptions":[{"multiple":"true","orderable":"true"}],"transformers":[]},{"elementType":"button","elementOptions":[{"sourceSuffixes":[]},{"separator":""},{"cssClasses":"col-xs-12"},{"onClick":"selectChange(this)"},{"getStringKey":"Gen_Change"}],"transformers":[]}]}', # noqa: E501
list_to_csv(_DEV_HIST_TRACKED_DEFAULT),
list_to_csv(['devMac', 'devName', 'devOwner', 'devType', 'devVendor', 'devFavorite', 'devGroup', 'devComments', 'devLastIP', 'devFQDN', 'devPrimaryIPv4', 'devPrimaryIPv6', 'devVlan', 'devForceStatus', 'devStaticIP', 'devScan', 'devAlertDown', 'devCanSleep', 'devSkipRepeated', 'devLocation', 'devIsArchived', 'devParentMAC', 'devParentPort', 'devParentRelType', 'devReqNicsOnline', 'devIcon', 'devSite', 'devSSID', 'devSyncHubNode', 'devSourcePlugin', 'devMacSource', 'devNameSource', 'devFQDNSource', 'devLastIPSource', 'devVendorSource', 'devSSIDSource', 'devParentMACSource', 'devParentPortSource', 'devParentRelTypeSource', 'devVlanSource', 'devCustomProps']), # noqa: E501
"General",
desc="List of Devices column names to monitor for changes. Add or remove field names to control what gets audited.",
)

View File

@@ -35,7 +35,7 @@ class DevicesHistoryInstance:
# -------------------------------------------------------------------------
def get_grouped_history(self, devGUID, changedColumn=None, changedBy=None,
limit=50, offset=0):
limit=50, offset=0, sort=None, search=None):
"""
Return grouped change history for a single device.
@@ -60,10 +60,18 @@ class DevicesHistoryInstance:
changedBy=changedBy,
limit=limit,
offset=offset,
sort=sort,
search=search
)
def get_all_grouped_history(self, changedColumn=None, changedBy=None,
limit=100, offset=0):
def get_all_grouped_history(self,
changedColumn=None,
changedBy=None,
limit=50,
offset=0,
sort=None,
search=None
):
"""
Return grouped change history across all devices (global view).
@@ -75,6 +83,8 @@ class DevicesHistoryInstance:
changedBy=changedBy,
limit=limit,
offset=offset,
sort=sort,
search=search
)
def get_available_filter_values(self, devGUID=None):
@@ -157,7 +167,7 @@ class DevicesHistoryInstance:
# -------------------------------------------------------------------------
@staticmethod
def _build_clauses(devGUID, changedColumn, changedBy):
def _build_clauses(devGUID, changedColumn, changedBy, search=None):
clauses = []
params = []
if devGUID:
@@ -169,19 +179,39 @@ class DevicesHistoryInstance:
if changedBy:
clauses.append("changedBy = ?")
params.append(changedBy)
if search:
clauses.append("""
(
devGUID LIKE ?
OR changedBy LIKE ?
OR changedColumn LIKE ?
OR oldValue LIKE ?
OR newValue LIKE ?
)
""")
like = f"%{search}%"
params.extend([like] * 5)
return clauses, params
def _query_grouped(self, devGUID, changedColumn, changedBy, limit, offset):
def _query_grouped(self, devGUID, changedColumn, changedBy, limit, offset, sort, search):
"""
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.
3. Apply sorting at group level.
4. Apply pagination at the end.
"""
clauses, params = self._build_clauses(devGUID, changedColumn, changedBy)
clauses, params = self._build_clauses(devGUID, changedColumn, changedBy, search)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
mylog("none", f"[HISTORY] SQL {where} params={params}")
mylog("none", f"[HISTORY] sort={sort}")
mylog("none", f"[HISTORY] sort type={type(sort)}")
mylog("none", f"[HISTORY] search {search} ")
mylog("none", f"[HISTORY] FINAL CALL limit={limit} offset={offset} sort={sort}")
rows = self._fetchall(
f"""
SELECT devGUID, timestamp, changedBy, changedColumn, oldValue, newValue
@@ -192,12 +222,15 @@ class DevicesHistoryInstance:
tuple(params),
)
# Group by (timestamp, changedBy, devGUID) — preserving DESC order
# ---------------------------
# 1. GROUP
# ---------------------------
groups = {}
order = []
for row in rows:
key = (row["timestamp"], row["changedBy"], row["devGUID"])
if key not in groups:
groups[key] = {
"devGUID": row["devGUID"],
@@ -206,6 +239,7 @@ class DevicesHistoryInstance:
"changes": [],
}
order.append(key)
groups[key]["changes"].append({
"changedColumn": row["changedColumn"],
"oldValue": row["oldValue"],
@@ -213,4 +247,39 @@ class DevicesHistoryInstance:
})
grouped_list = [groups[k] for k in order]
return grouped_list[offset: offset + limit]
# ---------------------------
# 2. SORT (group-level, stable)
# ---------------------------
if sort and len(sort) > 0:
s = sort[0]
field = s.get("field")
direction = s.get("order", "desc")
reverse = direction.lower() == "desc"
allowed_fields = {"timestamp", "changedBy", "devGUID"}
if field in allowed_fields:
def sort_key(x):
val = x.get(field)
# make timestamp safe for sorting
if field == "timestamp":
return val or ""
return val
grouped_list = sorted(
grouped_list,
key=sort_key,
reverse=reverse
)
# ---------------------------
# 3. PAGINATION (FINAL STEP ONLY)
# ---------------------------
start = max(offset or 0, 0)
end = start + (limit or 50)
return grouped_list[start:end]