+
+
+
Toner Report
+
View printers with low or critical toner/supply levels
+
Printers
+
{{ report.name }}
{{ report.description }}
diff --git a/frontend/src/views/reports/TonerReport.vue b/frontend/src/views/reports/TonerReport.vue
new file mode 100644
index 0000000..a1eec44
--- /dev/null
+++ b/frontend/src/views/reports/TonerReport.vue
@@ -0,0 +1,251 @@
+
+
+
+
+
Loading supply data...
+
+
+
+
+
+
+
+
{{ summary.total_checked }}
+
Printers Checked
+
+
+
{{ summary.low }}
+
Low Supply
+
+
+
{{ summary.critical }}
+
Critical Supply
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Printer Name |
+ Asset # |
+ Location |
+ IP Address |
+ Supplies |
+
+
+
+
+ |
+
+ {{ printer.printername || 'Unknown' }}
+
+ |
+ {{ printer.assetnumber }} |
+ {{ printer.location || '-' }} |
+ {{ printer.ipaddress }} |
+
+
+ {{ supply.name }}
+
+
+ {{ supply.level }}%
+
+
+ |
+
+
+
+
No printers match the selected filter.
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/settings/AuditLogs.vue b/frontend/src/views/settings/AuditLogs.vue
new file mode 100644
index 0000000..54a87c6
--- /dev/null
+++ b/frontend/src/views/settings/AuditLogs.vue
@@ -0,0 +1,367 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Timestamp |
+ Action |
+ Entity |
+ Name/ID |
+ User |
+ IP Address |
+ Changes |
+
+
+
+
+ | Loading... |
+
+
+ | No audit logs found |
+
+
+ | {{ formatDate(log.timestamp) }} |
+
+
+ {{ log.action }}
+
+ |
+ {{ log.entitytype }} |
+
+
+ {{ log.entityname || `#${log.entityid}` }}
+
+ {{ log.entityname || `#${log.entityid}` }}
+ |
+ {{ log.username || '-' }} |
+ {{ log.ipaddress || '-' }} |
+
+
+ -
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Field |
+ Old Value |
+ New Value |
+
+
+
+
+ | {{ field }} |
+ {{ formatValue(change.old) }} |
+ {{ formatValue(change.new) }} |
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/settings/SettingsIndex.vue b/frontend/src/views/settings/SettingsIndex.vue
index 5b36a7c..5bb3241 100644
--- a/frontend/src/views/settings/SettingsIndex.vue
+++ b/frontend/src/views/settings/SettingsIndex.vue
@@ -62,12 +62,30 @@
Subnets
Manage IP subnets and DHCP
+
+
+
+ System Settings
+ Configure integrations and system options
+
+
+
+
+ Audit Logs
+ View system activity and change history
+
+
+
+
+ Users & Roles
+ Manage user accounts and permissions
+
diff --git a/frontend/src/views/settings/UsersList.vue b/frontend/src/views/settings/UsersList.vue
new file mode 100644
index 0000000..116ec31
--- /dev/null
+++ b/frontend/src/views/settings/UsersList.vue
@@ -0,0 +1,745 @@
+
+
+
+
+
+
+
+
+ | Username |
+ Email |
+ Name |
+ Roles |
+ Status |
+ Last Login |
+ Actions |
+
+
+
+
+ | Loading... |
+
+
+ | No users found |
+
+
+ | {{ user.username }} |
+ {{ user.email }} |
+ {{ [user.firstname, user.lastname].filter(Boolean).join(' ') || '-' }} |
+
+
+ {{ role.rolename }}
+
+ None
+ |
+
+ Locked
+ Inactive
+ Active
+ |
+ {{ formatDate(user.lastlogindate) }} |
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Role Name |
+ Description |
+ Permissions |
+ Users |
+ Actions |
+
+
+
+
+ |
+
+ {{ role.rolename }}
+
+ |
+ {{ role.description || '-' }} |
+
+ All permissions
+ {{ role.permissions.length }} permissions
+ None
+ |
+ {{ role.usercount }} |
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Are you sure you want to delete user {{ deletingUser.username }}?
+
This action cannot be undone.
+
+
+
+
+
+
+
+
+ {{ error }}
+
+
+
+
+
diff --git a/plugins/computers/api/routes.py b/plugins/computers/api/routes.py
index 1ac0042..b507c0d 100644
--- a/plugins/computers/api/routes.py
+++ b/plugins/computers/api/routes.py
@@ -4,7 +4,7 @@ from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
-from shopdb.core.models import Asset, AssetType, OperatingSystem, Application, AppVersion
+from shopdb.core.models import Asset, AssetType, OperatingSystem, Application, AppVersion, AuditLog
from shopdb.utils.responses import (
success_response,
error_response,
@@ -348,6 +348,12 @@ def create_computer():
)
db.session.add(comp)
+ db.session.flush()
+
+ # Audit log
+ AuditLog.log('created', 'Computer', entityid=comp.computerid,
+ entityname=data.get('hostname') or data['assetnumber'])
+
db.session.commit()
result = asset.to_dict()
@@ -394,11 +400,18 @@ def update_computer(computer_id: int):
http_code=409
)
+ # Track changes for audit log
+ changes = {}
+
# Update asset fields
asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid',
'locationid', 'businessunitid', 'mapleft', 'maptop', 'notes', 'isactive']
for key in asset_fields:
if key in data:
+ old_val = getattr(asset, key)
+ new_val = data[key]
+ if old_val != new_val:
+ changes[key] = {'old': old_val, 'new': new_val}
setattr(asset, key, data[key])
# Update computer fields
@@ -406,8 +419,17 @@ def update_computer(computer_id: int):
'lastreporteddate', 'lastboottime', 'isvnc', 'iswinrm', 'isshopfloor']
for key in computer_fields:
if key in data:
+ old_val = getattr(comp, key)
+ new_val = data[key]
+ if old_val != new_val:
+ changes[key] = {'old': old_val, 'new': new_val}
setattr(comp, key, data[key])
+ # Audit log if there were changes
+ if changes:
+ AuditLog.log('updated', 'Computer', entityid=comp.computerid,
+ entityname=comp.hostname or asset.assetnumber, changes=changes)
+
db.session.commit()
result = asset.to_dict()
@@ -431,6 +453,11 @@ def delete_computer(computer_id: int):
# Soft delete the asset
comp.asset.isactive = False
+
+ # Audit log
+ AuditLog.log('deleted', 'Computer', entityid=comp.computerid,
+ entityname=comp.hostname or comp.asset.assetnumber)
+
db.session.commit()
return success_response(message='Computer deleted')
diff --git a/plugins/equipment/api/routes.py b/plugins/equipment/api/routes.py
index ae7d7dd..570df02 100644
--- a/plugins/equipment/api/routes.py
+++ b/plugins/equipment/api/routes.py
@@ -4,7 +4,7 @@ from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
-from shopdb.core.models import Asset, AssetType, Vendor, Model
+from shopdb.core.models import Asset, AssetType, Vendor, Model, AuditLog
from shopdb.utils.responses import (
success_response,
error_response,
@@ -311,6 +311,12 @@ def create_equipment():
)
db.session.add(equip)
+ db.session.flush()
+
+ # Audit log
+ AuditLog.log('created', 'Equipment', entityid=equip.equipmentid,
+ entityname=data['assetnumber'])
+
db.session.commit()
result = asset.to_dict()
@@ -347,11 +353,18 @@ def update_equipment(equipment_id: int):
http_code=409
)
+ # Track changes for audit log
+ changes = {}
+
# Update asset fields
asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid',
'locationid', 'businessunitid', 'mapleft', 'maptop', 'notes', 'isactive']
for key in asset_fields:
if key in data:
+ old_val = getattr(asset, key)
+ new_val = data[key]
+ if old_val != new_val:
+ changes[key] = {'old': old_val, 'new': new_val}
setattr(asset, key, data[key])
# Update equipment fields
@@ -361,8 +374,17 @@ def update_equipment(equipment_id: int):
'controllervendorid', 'controllermodelid']
for key in equipment_fields:
if key in data:
+ old_val = getattr(equip, key)
+ new_val = data[key]
+ if old_val != new_val:
+ changes[key] = {'old': old_val, 'new': new_val}
setattr(equip, key, data[key])
+ # Audit log if there were changes
+ if changes:
+ AuditLog.log('updated', 'Equipment', entityid=equip.equipmentid,
+ entityname=asset.assetnumber, changes=changes)
+
db.session.commit()
result = asset.to_dict()
@@ -386,6 +408,11 @@ def delete_equipment(equipment_id: int):
# Soft delete the asset (equipment extension will stay linked)
equip.asset.isactive = False
+
+ # Audit log
+ AuditLog.log('deleted', 'Equipment', entityid=equip.equipmentid,
+ entityname=equip.asset.assetnumber)
+
db.session.commit()
return success_response(message='Equipment deleted')
diff --git a/plugins/network/api/routes.py b/plugins/network/api/routes.py
index 182e39b..4f5db4b 100644
--- a/plugins/network/api/routes.py
+++ b/plugins/network/api/routes.py
@@ -4,7 +4,7 @@ from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
-from shopdb.core.models import Asset, AssetType, Vendor
+from shopdb.core.models import Asset, AssetType, Vendor, AuditLog
from shopdb.utils.responses import (
success_response,
error_response,
@@ -350,6 +350,12 @@ def create_network_device():
)
db.session.add(netdev)
+ db.session.flush()
+
+ # Audit log
+ AuditLog.log('created', 'NetworkDevice', entityid=netdev.networkdeviceid,
+ entityname=data.get('hostname') or data['assetnumber'])
+
db.session.commit()
result = asset.to_dict()
@@ -396,11 +402,18 @@ def update_network_device(device_id: int):
http_code=409
)
+ # Track changes for audit log
+ changes = {}
+
# Update asset fields
asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid',
'locationid', 'businessunitid', 'mapleft', 'maptop', 'notes', 'isactive']
for key in asset_fields:
if key in data:
+ old_val = getattr(asset, key)
+ new_val = data[key]
+ if old_val != new_val:
+ changes[key] = {'old': old_val, 'new': new_val}
setattr(asset, key, data[key])
# Update network device fields
@@ -408,8 +421,17 @@ def update_network_device(device_id: int):
'firmwareversion', 'portcount', 'ispoe', 'ismanaged', 'rackunit']
for key in netdev_fields:
if key in data:
+ old_val = getattr(netdev, key)
+ new_val = data[key]
+ if old_val != new_val:
+ changes[key] = {'old': old_val, 'new': new_val}
setattr(netdev, key, data[key])
+ # Audit log if there were changes
+ if changes:
+ AuditLog.log('updated', 'NetworkDevice', entityid=netdev.networkdeviceid,
+ entityname=netdev.hostname or asset.assetnumber, changes=changes)
+
db.session.commit()
result = asset.to_dict()
@@ -433,6 +455,11 @@ def delete_network_device(device_id: int):
# Soft delete the asset
netdev.asset.isactive = False
+
+ # Audit log
+ AuditLog.log('deleted', 'NetworkDevice', entityid=netdev.networkdeviceid,
+ entityname=netdev.hostname or netdev.asset.assetnumber)
+
db.session.commit()
return success_response(message='Network device deleted')
@@ -573,6 +600,12 @@ def create_vlan():
)
db.session.add(vlan)
+ db.session.flush()
+
+ # Audit log
+ AuditLog.log('created', 'VLAN', entityid=vlan.vlanid,
+ entityname=f"VLAN {vlan.vlannumber} - {vlan.name}")
+
db.session.commit()
return success_response(vlan.to_dict(), message='VLAN created', http_code=201)
@@ -604,10 +637,21 @@ def update_vlan(vlan_id: int):
http_code=409
)
+ # Track changes for audit log
+ changes = {}
for key in ['vlannumber', 'name', 'description', 'vlantype', 'isactive']:
if key in data:
+ old_val = getattr(vlan, key)
+ new_val = data[key]
+ if old_val != new_val:
+ changes[key] = {'old': old_val, 'new': new_val}
setattr(vlan, key, data[key])
+ # Audit log if there were changes
+ if changes:
+ AuditLog.log('updated', 'VLAN', entityid=vlan.vlanid,
+ entityname=f"VLAN {vlan.vlannumber} - {vlan.name}", changes=changes)
+
db.session.commit()
return success_response(vlan.to_dict(), message='VLAN updated')
@@ -634,6 +678,11 @@ def delete_vlan(vlan_id: int):
)
vlan.isactive = False
+
+ # Audit log
+ AuditLog.log('deleted', 'VLAN', entityid=vlan.vlanid,
+ entityname=f"VLAN {vlan.vlannumber} - {vlan.name}")
+
db.session.commit()
return success_response(message='VLAN deleted')
@@ -754,6 +803,12 @@ def create_subnet():
)
db.session.add(subnet)
+ db.session.flush()
+
+ # Audit log
+ AuditLog.log('created', 'Subnet', entityid=subnet.subnetid,
+ entityname=f"{subnet.cidr} - {subnet.name}")
+
db.session.commit()
return success_response(subnet.to_dict(), message='Subnet created', http_code=201)
@@ -790,10 +845,21 @@ def update_subnet(subnet_id: int):
'locationid', 'dhcpenabled', 'dhcprangestart', 'dhcprangeend',
'dns1', 'dns2', 'isactive']
+ # Track changes for audit log
+ changes = {}
for key in allowed_fields:
if key in data:
+ old_val = getattr(subnet, key)
+ new_val = data[key]
+ if old_val != new_val:
+ changes[key] = {'old': old_val, 'new': new_val}
setattr(subnet, key, data[key])
+ # Audit log if there were changes
+ if changes:
+ AuditLog.log('updated', 'Subnet', entityid=subnet.subnetid,
+ entityname=f"{subnet.cidr} - {subnet.name}", changes=changes)
+
db.session.commit()
return success_response(subnet.to_dict(), message='Subnet updated')
@@ -812,6 +878,11 @@ def delete_subnet(subnet_id: int):
)
subnet.isactive = False
+
+ # Audit log
+ AuditLog.log('deleted', 'Subnet', entityid=subnet.subnetid,
+ entityname=f"{subnet.cidr} - {subnet.name}")
+
db.session.commit()
return success_response(message='Subnet deleted')
diff --git a/plugins/notifications/api/routes.py b/plugins/notifications/api/routes.py
index 33eb8c6..486b6a2 100644
--- a/plugins/notifications/api/routes.py
+++ b/plugins/notifications/api/routes.py
@@ -285,17 +285,20 @@ def get_active_notifications():
"""
now = datetime.utcnow()
+ from datetime import timedelta
+ lookahead = now + timedelta(days=10)
+
notifications = Notification.query.filter(
Notification.isactive == True,
db.or_(
Notification.starttime.is_(None),
- Notification.starttime <= now
+ Notification.starttime <= lookahead
),
db.or_(
Notification.endtime.is_(None),
Notification.endtime >= now
)
- ).order_by(Notification.starttime.desc()).all()
+ ).order_by(Notification.starttime.asc()).all()
data = [n.to_dict() for n in notifications]
diff --git a/plugins/printers/api/asset_routes.py b/plugins/printers/api/asset_routes.py
index 8b12831..22165e6 100644
--- a/plugins/printers/api/asset_routes.py
+++ b/plugins/printers/api/asset_routes.py
@@ -1,9 +1,11 @@
"""Printers API routes - new Asset-based architecture."""
+import logging
+
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
-from shopdb.extensions import db
+from shopdb.extensions import db, cache
from shopdb.core.models import Asset, AssetType, Vendor, Model, Communication, CommunicationType
from shopdb.utils.responses import (
success_response,
@@ -16,6 +18,8 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
from ..models import Printer, PrinterType
from ..services import ZabbixService
+logger = logging.getLogger(__name__)
+
printers_asset_bp = Blueprint('printers_asset', __name__)
@@ -421,8 +425,12 @@ def get_printer_supplies(printer_id: int):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Printer has no IP address')
service = ZabbixService()
- if not service.isconfigured:
- return error_response(ErrorCodes.BAD_REQUEST, 'Zabbix not configured')
+ if not service.isconfigured or not service.isreachable:
+ # Return empty supplies if Zabbix not available (fail gracefully)
+ return success_response({
+ 'ipaddress': comm.ipaddress,
+ 'supplies': []
+ })
supplies = service.getsuppliesbyip(comm.ipaddress)
@@ -432,6 +440,117 @@ def get_printer_supplies(printer_id: int):
})
+# =============================================================================
+# Low Supplies
+# =============================================================================
+
+def _get_low_supplies_data():
+ """Build low supplies data (cached for 10 minutes)."""
+ cached = cache.get('printers_low_supplies')
+ if cached is not None:
+ return cached
+
+ service = ZabbixService()
+ if not service.isconfigured or not service.isreachable:
+ return {'printers': [], 'summary': {'total_checked': 0, 'low': 0, 'critical': 0}}
+
+ # All active printers with an IP address
+ printers = (
+ db.session.query(Printer, Asset, Communication)
+ .join(Asset, Asset.assetid == Printer.assetid)
+ .join(Communication, Communication.assetid == Asset.assetid)
+ .filter(Asset.isactive == True)
+ .filter(Communication.ipaddress.isnot(None))
+ .filter(Communication.ipaddress != '')
+ .all()
+ )
+
+ # Dedupe by printer id (may have multiple comms)
+ seen = set()
+ unique_printers = []
+ for printer, asset, comm in printers:
+ if printer.printerid not in seen:
+ seen.add(printer.printerid)
+ unique_printers.append((printer, asset, comm))
+
+ results = []
+ total_checked = 0
+
+ for printer, asset, comm in unique_printers:
+ supplies = service.getsuppliesbyip_cached(comm.ipaddress)
+ if supplies is None:
+ continue
+
+ total_checked += 1
+
+ # Annotate each supply with status
+ annotated = []
+ has_low = False
+ for s in supplies:
+ level = s.get('level', 0)
+ if level <= 5:
+ status = 'critical'
+ has_low = True
+ elif level <= 10:
+ status = 'low'
+ has_low = True
+ else:
+ status = 'ok'
+ annotated.append({
+ 'name': s.get('name', 'Unknown'),
+ 'level': level,
+ 'status': status
+ })
+
+ if has_low:
+ # Get location name
+ location_name = None
+ if asset.locationid:
+ from shopdb.core.models import Location
+ loc = Location.query.get(asset.locationid)
+ if loc:
+ location_name = loc.location
+
+ results.append({
+ 'printerid': printer.printerid,
+ 'printername': asset.name or printer.hostname or '',
+ 'assetnumber': asset.assetnumber or '',
+ 'ipaddress': comm.ipaddress,
+ 'location': location_name,
+ 'supplies': annotated
+ })
+
+ low_count = 0
+ critical_count = 0
+ for p in results:
+ has_critical = any(s['status'] == 'critical' for s in p['supplies'])
+ has_low = any(s['status'] == 'low' for s in p['supplies'])
+ if has_critical:
+ critical_count += 1
+ elif has_low:
+ low_count += 1
+
+ data = {
+ 'printers': results,
+ 'summary': {
+ 'total_checked': total_checked,
+ 'low': low_count,
+ 'critical': critical_count
+ }
+ }
+
+ cache.set('printers_low_supplies', data, timeout=600)
+ return data
+
+
+@printers_asset_bp.route('/lowsupplies', methods=['GET'])
+@jwt_required(optional=True)
+def low_supplies():
+ """Get printers with low or critical supply levels."""
+ data = _get_low_supplies_data()
+ return success_response(data)
+
+
# =============================================================================
# Dashboard
# =============================================================================
@@ -465,12 +584,24 @@ def dashboard_summary():
).group_by(Vendor.vendor
).all()
+ # Get real low/critical supply counts (skip if Zabbix not reachable)
+ low_count = 0
+ critical_count = 0
+ service = ZabbixService()
+ if service.isconfigured and service.isreachable:
+ try:
+ supply_data = _get_low_supplies_data()
+ low_count = supply_data['summary']['low']
+ critical_count = supply_data['summary']['critical']
+ except Exception as e:
+ logger.warning(f"Could not fetch supply data for dashboard: {e}")
+
return success_response({
'total': total,
- 'totalprinters': total, # For dashboard compatibility
- 'online': total, # Placeholder - would need monitoring integration
- 'lowsupplies': 0, # Placeholder - would need Zabbix integration
- 'criticalsupplies': 0, # Placeholder - would need Zabbix integration
+ 'totalprinters': total,
+ 'online': total,
+ 'lowsupplies': low_count,
+ 'criticalsupplies': critical_count,
'bytype': [{'type': t, 'count': c} for t, c in by_type],
'byvendor': [{'vendor': v, 'count': c} for v, c in by_vendor],
})
diff --git a/plugins/printers/api/routes.py b/plugins/printers/api/routes.py
index 320a8e6..69c0261 100644
--- a/plugins/printers/api/routes.py
+++ b/plugins/printers/api/routes.py
@@ -8,6 +8,7 @@ from shopdb.utils.responses import success_response, error_response, paginated_r
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.core.models.machine import Machine, MachineType
from shopdb.core.models.communication import Communication, CommunicationType
+from shopdb.core.models import AuditLog
from ..models import PrinterData
from ..services import ZabbixService
@@ -132,10 +133,21 @@ def update_printer_data(machine_id: int):
pd = PrinterData(machineid=machine_id)
db.session.add(pd)
+ # Track changes for audit log
+ changes = {}
for key in ['windowsname', 'sharename', 'iscsf', 'installpath', 'pin']:
if key in data:
+ old_val = getattr(pd, key, None)
+ new_val = data[key]
+ if old_val != new_val:
+ changes[key] = {'old': old_val, 'new': new_val}
setattr(pd, key, data[key])
+ # Audit log if there were changes
+ if changes:
+ AuditLog.log('updated', 'Printer', entityid=machine_id,
+ entityname=machine.machinenumber or machine.hostname, changes=changes)
+
db.session.commit()
return success_response({
@@ -176,14 +188,28 @@ def update_printer_communication(machine_id: int):
comm = Communication(machineid=machine_id, comtypeid=ip_comtype.comtypeid)
db.session.add(comm)
+ # Track changes for audit log
+ changes = {}
+
# Update fields
if 'ipaddress' in data:
+ if comm.ipaddress != data['ipaddress']:
+ changes['ipaddress'] = {'old': comm.ipaddress, 'new': data['ipaddress']}
comm.ipaddress = data['ipaddress']
if 'isprimary' in data:
+ if comm.isprimary != data['isprimary']:
+ changes['isprimary'] = {'old': comm.isprimary, 'new': data['isprimary']}
comm.isprimary = data['isprimary']
if 'macaddress' in data:
+ if comm.macaddress != data['macaddress']:
+ changes['macaddress'] = {'old': comm.macaddress, 'new': data['macaddress']}
comm.macaddress = data['macaddress']
+ # Audit log if there were changes
+ if changes:
+ AuditLog.log('updated', 'Printer', entityid=machine_id,
+ entityname=machine.machinenumber or machine.hostname, changes=changes)
+
db.session.commit()
return success_response({
@@ -211,8 +237,12 @@ def get_printer_supplies(machine_id: int):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Printer has no IP address')
service = ZabbixService()
- if not service.isconfigured:
- return error_response(ErrorCodes.SERVICE_UNAVAILABLE, 'Zabbix not configured')
+ if not service.isconfigured or not service.isreachable:
+ # Return empty supplies if Zabbix not available (fail gracefully)
+ return success_response({
+ 'ipaddress': primary_comm.ipaddress,
+ 'supplies': []
+ })
supplies = service.getsuppliesbyip(primary_comm.ipaddress)
diff --git a/plugins/printers/services/zabbix_service.py b/plugins/printers/services/zabbix_service.py
index de321c6..a54563c 100644
--- a/plugins/printers/services/zabbix_service.py
+++ b/plugins/printers/services/zabbix_service.py
@@ -6,6 +6,8 @@ from typing import Dict, List, Optional
import requests
from flask import current_app
+from shopdb.extensions import cache
+
logger = logging.getLogger(__name__)
@@ -14,20 +16,70 @@ class ZabbixService:
Zabbix API service for real-time printer supply lookups.
Queries Zabbix by IP address to get current supply levels.
- No caching - always returns live data.
+ Use getsuppliesbyip_cached() for cached lookups or
+ getsuppliesbyip() for live data.
+
+ Configuration:
+ ZABBIX_ENABLED: Set to True to enable Zabbix integration (default: False)
+ ZABBIX_URL: Zabbix API URL (e.g., http://zabbix.example.com:8080)
+ ZABBIX_TOKEN: Zabbix API authentication token
"""
+ CACHE_TTL = 600 # 10 minutes
+ REACHABLE_CHECK_TTL = 60 # Check reachability every 60 seconds
+
def __init__(self):
self._url = None
self._token = None
+ self._enabled = None
+
+ @property
+ def isenabled(self) -> bool:
+ """Check if Zabbix integration is enabled."""
+ # Check database setting first, fall back to env var
+ from shopdb.core.models import Setting
+ db_enabled = Setting.get('zabbix_enabled')
+ if db_enabled is not None:
+ return bool(db_enabled)
+ # Fall back to env var for backwards compatibility
+ return current_app.config.get('ZABBIX_ENABLED', False)
@property
def isconfigured(self) -> bool:
- """Check if Zabbix is configured."""
- self._url = current_app.config.get('ZABBIX_URL')
- self._token = current_app.config.get('ZABBIX_TOKEN')
+ """Check if Zabbix is enabled and configured."""
+ if not self.isenabled:
+ return False
+ # Check database settings first, fall back to env vars
+ from shopdb.core.models import Setting
+ self._url = Setting.get('zabbix_url') or current_app.config.get('ZABBIX_URL')
+ self._token = Setting.get('zabbix_token') or current_app.config.get('ZABBIX_TOKEN')
return bool(self._url and self._token)
+ @property
+ def isreachable(self) -> bool:
+ """Check if Zabbix is reachable (cached for 60 seconds)."""
+ if not self.isenabled or not self.isconfigured:
+ return False
+
+ cache_key = 'zabbix_reachable'
+ cached = cache.get(cache_key)
+ if cached is not None:
+ return cached
+
+ # Quick connectivity check with 500ms timeout
+ try:
+ response = requests.get(
+ f"{self._url}/api_jsonrpc.php",
+ timeout=0.5
+ )
+ reachable = response.status_code in (200, 401, 403, 405)
+ except requests.RequestException:
+ reachable = False
+
+ cache.set(cache_key, reachable, timeout=self.REACHABLE_CHECK_TTL)
+ logger.debug(f"Zabbix reachability check: {reachable}")
+ return reachable
+
def _apicall(self, method: str, params: Dict) -> Optional[Dict]:
"""Make a Zabbix API call."""
if not self.isconfigured:
@@ -46,7 +98,7 @@ class ZabbixService:
f"{self._url}/api_jsonrpc.php",
json=payload,
headers={'Content-Type': 'application/json'},
- timeout=10
+ timeout=0.5 # 500ms timeout - fail fast if Zabbix is slow/unreachable
)
response.raise_for_status()
data = response.json()
@@ -131,3 +183,21 @@ class ZabbixService:
"""Get Zabbix host ID for an IP address."""
host = self.gethostbyip(ip)
return host['hostid'] if host else None
+
+ def getsuppliesbyip_cached(self, ip: str) -> Optional[List[Dict]]:
+ """Get printer supply levels with caching (10-minute TTL)."""
+ cache_key = f'zabbix_supplies_{ip}'
+ result = cache.get(cache_key)
+ if result is not None:
+ return result
+
+ result = self.getsuppliesbyip(ip)
+ if result is not None:
+ cache.set(cache_key, result, timeout=self.CACHE_TTL)
+ return result
+
+ def clearcache(self, ip: str = None):
+ """Clear cached supply data for one IP or all."""
+ if ip:
+ cache.delete(f'zabbix_supplies_{ip}')
+ cache.delete('printers_low_supplies')
diff --git a/plugins/usb/api/routes.py b/plugins/usb/api/routes.py
index f905b23..a990ca5 100644
--- a/plugins/usb/api/routes.py
+++ b/plugins/usb/api/routes.py
@@ -5,6 +5,7 @@ from flask_jwt_extended import jwt_required, get_jwt_identity
from datetime import datetime
from shopdb.extensions import db
+from shopdb.core.models import AuditLog
from shopdb.utils.responses import (
success_response,
error_response,
@@ -140,6 +141,11 @@ def create_usb_device():
)
db.session.add(device)
+ db.session.flush()
+
+ AuditLog.log('created', 'USBDevice', entityid=device.usbdeviceid,
+ entityname=device.label or device.serialnumber)
+
db.session.commit()
return success_response(device.to_dict(), message='Device created', http_code=201)
@@ -184,13 +190,22 @@ def update_usb_device(device_id: int):
data = request.get_json() or {}
- # Update allowed fields
+ # Track changes for audit log
+ changes = {}
for field in ['label', 'assetnumber', 'usbdevicetypeid', 'capacitygb',
'vendorid', 'productid', 'manufacturer', 'productname',
'storagelocation', 'pin', 'notes']:
if field in data:
+ old_val = getattr(device, field)
+ new_val = data[field]
+ if old_val != new_val:
+ changes[field] = {'old': old_val, 'new': new_val}
setattr(device, field, data[field])
+ if changes:
+ AuditLog.log('updated', 'USBDevice', entityid=device.usbdeviceid,
+ entityname=device.label or device.serialnumber, changes=changes)
+
device.modifieddate = datetime.utcnow()
db.session.commit()
@@ -219,6 +234,10 @@ def delete_usb_device(device_id: int):
device.isactive = False
device.modifieddate = datetime.utcnow()
+
+ AuditLog.log('deleted', 'USBDevice', entityid=device.usbdeviceid,
+ entityname=device.label or device.serialnumber)
+
db.session.commit()
return success_response(None, message='Device deleted')
@@ -272,6 +291,11 @@ def checkout_device(device_id: int):
device.modifieddate = datetime.utcnow()
db.session.add(checkout)
+
+ AuditLog.log('checked_out', 'USBDevice', entityid=device.usbdeviceid,
+ entityname=device.label or device.serialnumber,
+ changes={'checked_out_to': data['sso'], 'reason': data.get('checkoutreason')})
+
db.session.commit()
return success_response(checkout.to_dict(), message='Device checked out', http_code=201)
@@ -311,12 +335,17 @@ def checkin_device(device_id: int):
active_checkout.waswiped = data.get('waswiped', False)
# Update device status
+ previous_user = device.currentuserid
device.ischeckedout = False
device.currentuserid = None
device.currentusername = None
device.currentcheckoutdate = None
device.modifieddate = datetime.utcnow()
+ AuditLog.log('checked_in', 'USBDevice', entityid=device.usbdeviceid,
+ entityname=device.label or device.serialnumber,
+ changes={'returned_by': previous_user, 'wiped': data.get('waswiped', False)})
+
db.session.commit()
return success_response(
diff --git a/requirements.txt b/requirements.txt
index f464b31..87eb7d1 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,6 +4,7 @@ flask-sqlalchemy>=3.1
flask-migrate>=4.0
flask-jwt-extended>=4.6
flask-cors>=4.0
+flask-caching>=2.0
flask-marshmallow>=1.2
marshmallow-sqlalchemy>=0.29
diff --git a/shopdb/__init__.py b/shopdb/__init__.py
index 4ddb594..f6da5a0 100644
--- a/shopdb/__init__.py
+++ b/shopdb/__init__.py
@@ -87,6 +87,9 @@ def register_blueprints(app: Flask):
collector_bp,
employees_bp,
slides_bp,
+ settings_bp,
+ auditlogs_bp,
+ users_bp,
)
api_prefix = '/api'
@@ -110,6 +113,9 @@ def register_blueprints(app: Flask):
app.register_blueprint(collector_bp, url_prefix=f'{api_prefix}/collector')
app.register_blueprint(employees_bp, url_prefix=f'{api_prefix}/employees')
app.register_blueprint(slides_bp, url_prefix=f'{api_prefix}/slides')
+ app.register_blueprint(settings_bp, url_prefix=f'{api_prefix}/settings')
+ app.register_blueprint(auditlogs_bp, url_prefix=f'{api_prefix}/auditlogs')
+ app.register_blueprint(users_bp, url_prefix=f'{api_prefix}/users')
def register_cli_commands(app: Flask):
diff --git a/shopdb/cli/__init__.py b/shopdb/cli/__init__.py
index fb50d0b..4054f68 100644
--- a/shopdb/cli/__init__.py
+++ b/shopdb/cli/__init__.py
@@ -145,3 +145,180 @@ def seed_test_user():
click.echo(click.style("Test user created: admin / admin123", fg='green'))
else:
click.echo(click.style("Test user already exists", fg='yellow'))
+
+
+@seed_cli.command('permissions')
+@with_appcontext
+def seed_permissions():
+ """Seed predefined permissions."""
+ from shopdb.extensions import db
+ from shopdb.core.models import Permission
+
+ created = Permission.seed()
+ db.session.commit()
+ click.echo(click.style(f"{created} permissions created.", fg='green'))
+
+
+@seed_cli.command('settings')
+@with_appcontext
+def seed_settings():
+ """Seed default system settings."""
+ from shopdb.extensions import db
+ from shopdb.core.models import Setting
+
+ defaults = [
+ # Zabbix integration
+ {
+ 'key': 'zabbix_enabled',
+ 'value': 'false',
+ 'valuetype': 'boolean',
+ 'category': 'integrations',
+ 'description': 'Enable Zabbix integration for printer supply monitoring'
+ },
+ {
+ 'key': 'zabbix_url',
+ 'value': '',
+ 'valuetype': 'string',
+ 'category': 'integrations',
+ 'description': 'Zabbix API URL (e.g., http://zabbix.example.com:8080)'
+ },
+ {
+ 'key': 'zabbix_token',
+ 'value': '',
+ 'valuetype': 'string',
+ 'category': 'integrations',
+ 'description': 'Zabbix API authentication token'
+ },
+ # Email/SMTP settings
+ {
+ 'key': 'smtp_enabled',
+ 'value': 'false',
+ 'valuetype': 'boolean',
+ 'category': 'email',
+ 'description': 'Enable email notifications and alerts'
+ },
+ {
+ 'key': 'smtp_host',
+ 'value': '',
+ 'valuetype': 'string',
+ 'category': 'email',
+ 'description': 'SMTP server hostname'
+ },
+ {
+ 'key': 'smtp_port',
+ 'value': '587',
+ 'valuetype': 'integer',
+ 'category': 'email',
+ 'description': 'SMTP server port (usually 587 for TLS, 465 for SSL, 25 for unencrypted)'
+ },
+ {
+ 'key': 'smtp_username',
+ 'value': '',
+ 'valuetype': 'string',
+ 'category': 'email',
+ 'description': 'SMTP authentication username'
+ },
+ {
+ 'key': 'smtp_password',
+ 'value': '',
+ 'valuetype': 'string',
+ 'category': 'email',
+ 'description': 'SMTP authentication password'
+ },
+ {
+ 'key': 'smtp_use_tls',
+ 'value': 'true',
+ 'valuetype': 'boolean',
+ 'category': 'email',
+ 'description': 'Use TLS encryption for SMTP connection'
+ },
+ {
+ 'key': 'smtp_from_address',
+ 'value': '',
+ 'valuetype': 'string',
+ 'category': 'email',
+ 'description': 'From address for outgoing emails'
+ },
+ {
+ 'key': 'smtp_from_name',
+ 'value': 'ShopDB',
+ 'valuetype': 'string',
+ 'category': 'email',
+ 'description': 'From name for outgoing emails'
+ },
+ {
+ 'key': 'alert_recipients',
+ 'value': '',
+ 'valuetype': 'string',
+ 'category': 'email',
+ 'description': 'Default email recipients for alerts (comma-separated)'
+ },
+ # Audit log settings
+ {
+ 'key': 'audit_retention_days',
+ 'value': '90',
+ 'valuetype': 'integer',
+ 'category': 'audit',
+ 'description': 'Number of days to retain audit logs (0 = keep forever)'
+ },
+ # Authentication settings
+ {
+ 'key': 'saml_enabled',
+ 'value': 'false',
+ 'valuetype': 'boolean',
+ 'category': 'auth',
+ 'description': 'Enable SAML SSO authentication'
+ },
+ {
+ 'key': 'saml_idp_metadata_url',
+ 'value': '',
+ 'valuetype': 'string',
+ 'category': 'auth',
+ 'description': 'SAML Identity Provider metadata URL'
+ },
+ {
+ 'key': 'saml_entity_id',
+ 'value': '',
+ 'valuetype': 'string',
+ 'category': 'auth',
+ 'description': 'SAML Service Provider entity ID (e.g., https://shopdb.example.com)'
+ },
+ {
+ 'key': 'saml_acs_url',
+ 'value': '',
+ 'valuetype': 'string',
+ 'category': 'auth',
+ 'description': 'SAML Assertion Consumer Service URL'
+ },
+ {
+ 'key': 'saml_allow_local_login',
+ 'value': 'true',
+ 'valuetype': 'boolean',
+ 'category': 'auth',
+ 'description': 'Allow local username/password login when SAML is enabled'
+ },
+ {
+ 'key': 'saml_auto_create_users',
+ 'value': 'true',
+ 'valuetype': 'boolean',
+ 'category': 'auth',
+ 'description': 'Automatically create users on first SAML login'
+ },
+ {
+ 'key': 'saml_admin_group',
+ 'value': '',
+ 'valuetype': 'string',
+ 'category': 'auth',
+ 'description': 'SAML group name that grants admin role'
+ },
+ ]
+
+ created = 0
+ for d in defaults:
+ if not Setting.query.filter_by(key=d['key']).first():
+ setting = Setting(**d)
+ db.session.add(setting)
+ created += 1
+
+ db.session.commit()
+ click.echo(click.style(f"{created} default settings created.", fg='green'))
diff --git a/shopdb/config.py b/shopdb/config.py
index dde923f..3dd4e46 100644
--- a/shopdb/config.py
+++ b/shopdb/config.py
@@ -36,6 +36,14 @@ class Config:
# Logging
LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO')
+ # Zabbix
+ ZABBIX_URL = os.environ.get('ZABBIX_URL', '')
+ ZABBIX_TOKEN = os.environ.get('ZABBIX_TOKEN', '')
+
+ # Cache
+ CACHE_TYPE = 'SimpleCache'
+ CACHE_DEFAULT_TIMEOUT = 600 # 10 minutes
+
# Pagination
DEFAULT_PAGE_SIZE = 20
MAX_PAGE_SIZE = 100
diff --git a/shopdb/core/api/__init__.py b/shopdb/core/api/__init__.py
index 3fc1e59..b6da533 100644
--- a/shopdb/core/api/__init__.py
+++ b/shopdb/core/api/__init__.py
@@ -19,6 +19,9 @@ from .reports import reports_bp
from .collector import collector_bp
from .employees import employees_bp
from .slides import slides_bp
+from .settings import settings_bp
+from .auditlogs import auditlogs_bp
+from .users import users_bp
__all__ = [
'auth_bp',
@@ -40,4 +43,7 @@ __all__ = [
'collector_bp',
'employees_bp',
'slides_bp',
+ 'settings_bp',
+ 'auditlogs_bp',
+ 'users_bp',
]
diff --git a/shopdb/core/api/applications.py b/shopdb/core/api/applications.py
index d74c1cd..93bdaf2 100644
--- a/shopdb/core/api/applications.py
+++ b/shopdb/core/api/applications.py
@@ -5,7 +5,7 @@ from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import (
- Application, AppVersion, AppOwner, SupportTeam, InstalledApp, Machine
+ Application, AppVersion, AppOwner, SupportTeam, InstalledApp, Machine, AuditLog
)
from shopdb.utils.responses import (
success_response,
@@ -132,6 +132,10 @@ def create_application():
)
db.session.add(app)
+ db.session.flush()
+
+ AuditLog.log('created', 'Application', entityid=app.appid, entityname=app.appname)
+
db.session.commit()
return success_response(app.to_dict(), message='Application created', http_code=201)
@@ -163,10 +167,20 @@ def update_application(app_id: int):
'applicationnotes', 'installpath', 'applicationlink', 'documentationpath',
'ishidden', 'isprinter', 'islicenced', 'image', 'isactive'
]
+
+ changes = {}
for key in fields:
if key in data:
+ old_val = getattr(app, key)
+ new_val = data[key]
+ if old_val != new_val:
+ changes[key] = {'old': old_val, 'new': new_val}
setattr(app, key, data[key])
+ if changes:
+ AuditLog.log('updated', 'Application', entityid=app.appid,
+ entityname=app.appname, changes=changes)
+
db.session.commit()
return success_response(app.to_dict(), message='Application updated')
@@ -181,6 +195,9 @@ def delete_application(app_id: int):
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
app.isactive = False
+
+ AuditLog.log('deleted', 'Application', entityid=app.appid, entityname=app.appname)
+
db.session.commit()
return success_response(message='Application deleted')
diff --git a/shopdb/core/api/auditlogs.py b/shopdb/core/api/auditlogs.py
new file mode 100644
index 0000000..cac20b5
--- /dev/null
+++ b/shopdb/core/api/auditlogs.py
@@ -0,0 +1,146 @@
+"""Audit log API routes."""
+
+from flask import Blueprint, request
+from flask_jwt_extended import jwt_required
+
+from shopdb.core.models import AuditLog
+from shopdb.utils.responses import success_response, error_response, ErrorCodes
+
+auditlogs_bp = Blueprint('auditlogs', __name__)
+
+
+@auditlogs_bp.route('', methods=['GET'])
+@jwt_required()
+def list_auditlogs():
+ """
+ List audit logs with filtering and pagination.
+
+ Query params:
+ page: Page number (default 1)
+ perpage: Items per page (default 50, max 200)
+ action: Filter by action (created, updated, deleted)
+ entitytype: Filter by entity type
+ userid: Filter by user ID
+ search: Search in entityname or username
+ from_date: Filter from date (ISO format)
+ to_date: Filter to date (ISO format)
+ """
+ page = request.args.get('page', 1, type=int)
+ perpage = min(request.args.get('perpage', 50, type=int), 200)
+
+ query = AuditLog.query
+
+ # Filters
+ action = request.args.get('action')
+ if action:
+ query = query.filter(AuditLog.action == action)
+
+ entitytype = request.args.get('entitytype')
+ if entitytype:
+ query = query.filter(AuditLog.entitytype == entitytype)
+
+ userid = request.args.get('userid', type=int)
+ if userid:
+ query = query.filter(AuditLog.userid == userid)
+
+ search = request.args.get('search')
+ if search:
+ search_term = f'%{search}%'
+ query = query.filter(
+ (AuditLog.entityname.ilike(search_term)) |
+ (AuditLog.username.ilike(search_term))
+ )
+
+ from_date = request.args.get('from_date')
+ if from_date:
+ from datetime import datetime
+ try:
+ dt = datetime.fromisoformat(from_date.replace('Z', '+00:00'))
+ query = query.filter(AuditLog.timestamp >= dt)
+ except ValueError:
+ pass
+
+ to_date = request.args.get('to_date')
+ if to_date:
+ from datetime import datetime
+ try:
+ dt = datetime.fromisoformat(to_date.replace('Z', '+00:00'))
+ query = query.filter(AuditLog.timestamp <= dt)
+ except ValueError:
+ pass
+
+ # Order by most recent first
+ query = query.order_by(AuditLog.timestamp.desc())
+
+ # Paginate
+ pagination = query.paginate(page=page, per_page=perpage, error_out=False)
+
+ return success_response(
+ [log.to_dict() for log in pagination.items],
+ meta={
+ 'page': page,
+ 'perpage': perpage,
+ 'total': pagination.total,
+ 'pages': pagination.pages
+ }
+ )
+
+
+@auditlogs_bp.route('/entity/
/', methods=['GET'])
+@jwt_required()
+def get_entity_history(entitytype: str, entityid: int):
+ """Get audit history for a specific entity."""
+ logs = AuditLog.query.filter_by(
+ entitytype=entitytype,
+ entityid=entityid
+ ).order_by(AuditLog.timestamp.desc()).all()
+
+ return success_response([log.to_dict() for log in logs])
+
+
+@auditlogs_bp.route('/stats', methods=['GET'])
+@jwt_required()
+def get_stats():
+ """Get audit log statistics."""
+ from sqlalchemy import func
+ from datetime import datetime, timedelta
+
+ # Actions by type
+ actions = db_func_count_by(AuditLog.action)
+
+ # Entity types
+ entities = db_func_count_by(AuditLog.entitytype)
+
+ # Recent activity (last 7 days)
+ week_ago = datetime.utcnow() - timedelta(days=7)
+ recent_count = AuditLog.query.filter(AuditLog.timestamp >= week_ago).count()
+
+ # Most active users (last 7 days)
+ from shopdb.extensions import db
+ active_users = db.session.query(
+ AuditLog.username,
+ func.count(AuditLog.auditlogid).label('count')
+ ).filter(
+ AuditLog.timestamp >= week_ago,
+ AuditLog.username.isnot(None)
+ ).group_by(AuditLog.username).order_by(func.count(AuditLog.auditlogid).desc()).limit(5).all()
+
+ return success_response({
+ 'actions': actions,
+ 'entities': entities,
+ 'recentCount': recent_count,
+ 'activeUsers': [{'username': u[0], 'count': u[1]} for u in active_users]
+ })
+
+
+def db_func_count_by(column):
+ """Helper to count grouped by a column."""
+ from sqlalchemy import func
+ from shopdb.extensions import db
+
+ results = db.session.query(
+ column,
+ func.count().label('count')
+ ).group_by(column).all()
+
+ return {r[0]: r[1] for r in results}
diff --git a/shopdb/core/api/machines.py b/shopdb/core/api/machines.py
index 90ed7ff..4a9ed3d 100644
--- a/shopdb/core/api/machines.py
+++ b/shopdb/core/api/machines.py
@@ -16,7 +16,7 @@ from flask import Blueprint, request, g
from flask_jwt_extended import jwt_required, current_user
from shopdb.extensions import db
-from shopdb.core.models import Machine, MachineType
+from shopdb.core.models import Machine, MachineType, AuditLog
from shopdb.core.models.relationship import MachineRelationship, RelationshipType
from shopdb.utils.responses import (
success_response,
@@ -259,6 +259,12 @@ def create_machine():
machine.createdby = current_user.username
db.session.add(machine)
+ db.session.flush()
+
+ # Audit log
+ AuditLog.log('created', 'Machine', entityid=machine.machineid,
+ entityname=machine.machinenumber)
+
db.session.commit()
return success_response(
@@ -306,11 +312,22 @@ def update_machine(machine_id: int):
'requiresmanualconfig', 'notes', 'isactive'
]
+ # Track changes for audit log
+ changes = {}
for key, value in data.items():
if key in allowed_fields:
+ old_val = getattr(machine, key)
+ if old_val != value:
+ changes[key] = {'old': old_val, 'new': value}
setattr(machine, key, value)
machine.modifiedby = current_user.username
+
+ # Audit log if there were changes
+ if changes:
+ AuditLog.log('updated', 'Machine', entityid=machine.machineid,
+ entityname=machine.machinenumber, changes=changes)
+
db.session.commit()
return success_response(machine.to_dict(), message='Machine updated successfully')
@@ -331,6 +348,11 @@ def delete_machine(machine_id: int):
)
machine.soft_delete(deleted_by=current_user.username)
+
+ # Audit log
+ AuditLog.log('deleted', 'Machine', entityid=machine.machineid,
+ entityname=machine.machinenumber)
+
db.session.commit()
return success_response(message='Machine deleted successfully')
diff --git a/shopdb/core/api/settings.py b/shopdb/core/api/settings.py
new file mode 100644
index 0000000..8d41844
--- /dev/null
+++ b/shopdb/core/api/settings.py
@@ -0,0 +1,293 @@
+"""Settings API routes."""
+
+from flask import Blueprint, request
+from flask_jwt_extended import jwt_required
+
+from shopdb.extensions import db, cache
+from shopdb.core.models import Setting, AuditLog
+from shopdb.utils.responses import success_response, error_response, ErrorCodes
+
+settings_bp = Blueprint('settings', __name__)
+
+# Cache key for settings
+SETTINGS_CACHE_KEY = 'system_settings'
+SETTINGS_CACHE_TTL = 300 # 5 minutes
+
+
+def get_cached_settings():
+ """Get all settings from cache or database."""
+ cached = cache.get(SETTINGS_CACHE_KEY)
+ if cached is not None:
+ return cached
+
+ settings = Setting.query.all()
+ result = {s.key: s.get_typed_value() for s in settings}
+ cache.set(SETTINGS_CACHE_KEY, result, timeout=SETTINGS_CACHE_TTL)
+ return result
+
+
+def invalidate_settings_cache():
+ """Clear the settings cache."""
+ cache.delete(SETTINGS_CACHE_KEY)
+
+
+@settings_bp.route('', methods=['GET'])
+@jwt_required(optional=True)
+def list_settings():
+ """List all settings, optionally filtered by category."""
+ category = request.args.get('category')
+
+ query = Setting.query
+ if category:
+ query = query.filter_by(category=category)
+
+ settings = query.order_by(Setting.category, Setting.key).all()
+ return success_response([s.to_dict() for s in settings])
+
+
+@settings_bp.route('/', methods=['GET'])
+@jwt_required(optional=True)
+def get_setting(key: str):
+ """Get a single setting by key."""
+ setting = Setting.query.filter_by(key=key).first()
+
+ if not setting:
+ return error_response(ErrorCodes.NOT_FOUND, f'Setting {key} not found', http_code=404)
+
+ return success_response(setting.to_dict())
+
+
+@settings_bp.route('/', methods=['PUT'])
+@jwt_required()
+def update_setting(key: str):
+ """Update a setting value."""
+ data = request.get_json()
+
+ if data is None or 'value' not in data:
+ return error_response(ErrorCodes.VALIDATION_ERROR, 'value is required')
+
+ setting = Setting.query.filter_by(key=key).first()
+
+ if not setting:
+ return error_response(ErrorCodes.NOT_FOUND, f'Setting {key} not found', http_code=404)
+
+ # Track old value for audit
+ old_value = setting.value
+
+ # Convert value to string for storage
+ value = data['value']
+ if isinstance(value, bool):
+ setting.value = 'true' if value else 'false'
+ else:
+ setting.value = str(value) if value is not None else None
+
+ # Audit log (mask sensitive values)
+ is_sensitive = 'password' in key or 'token' in key or 'secret' in key
+ AuditLog.log('updated', 'Setting', entityname=key, changes={
+ 'value': {
+ 'old': '***' if is_sensitive else old_value,
+ 'new': '***' if is_sensitive else setting.value
+ }
+ })
+
+ db.session.commit()
+ invalidate_settings_cache()
+
+ return success_response(setting.to_dict(), message='Setting updated')
+
+
+@settings_bp.route('', methods=['POST'])
+@jwt_required()
+def create_setting():
+ """Create a new setting (admin only)."""
+ data = request.get_json()
+
+ if not data or not data.get('key'):
+ return error_response(ErrorCodes.VALIDATION_ERROR, 'key is required')
+
+ if Setting.query.filter_by(key=data['key']).first():
+ return error_response(ErrorCodes.CONFLICT, f"Setting '{data['key']}' already exists", http_code=409)
+
+ value = data.get('value')
+ if isinstance(value, bool):
+ value_str = 'true' if value else 'false'
+ else:
+ value_str = str(value) if value is not None else None
+
+ setting = Setting(
+ key=data['key'],
+ value=value_str,
+ valuetype=data.get('valuetype', 'string'),
+ category=data.get('category', 'general'),
+ description=data.get('description')
+ )
+
+ db.session.add(setting)
+ db.session.commit()
+ invalidate_settings_cache()
+
+ return success_response(setting.to_dict(), message='Setting created', http_code=201)
+
+
+@settings_bp.route('/seed', methods=['POST'])
+@jwt_required()
+def seed_default_settings():
+ """Seed default settings if they don't exist."""
+ defaults = [
+ # Zabbix integration
+ {
+ 'key': 'zabbix_enabled',
+ 'value': 'false',
+ 'valuetype': 'boolean',
+ 'category': 'integrations',
+ 'description': 'Enable Zabbix integration for printer supply monitoring'
+ },
+ {
+ 'key': 'zabbix_url',
+ 'value': '',
+ 'valuetype': 'string',
+ 'category': 'integrations',
+ 'description': 'Zabbix API URL (e.g., http://zabbix.example.com:8080)'
+ },
+ {
+ 'key': 'zabbix_token',
+ 'value': '',
+ 'valuetype': 'string',
+ 'category': 'integrations',
+ 'description': 'Zabbix API authentication token'
+ },
+ # Email/SMTP settings
+ {
+ 'key': 'smtp_enabled',
+ 'value': 'false',
+ 'valuetype': 'boolean',
+ 'category': 'email',
+ 'description': 'Enable email notifications and alerts'
+ },
+ {
+ 'key': 'smtp_host',
+ 'value': '',
+ 'valuetype': 'string',
+ 'category': 'email',
+ 'description': 'SMTP server hostname'
+ },
+ {
+ 'key': 'smtp_port',
+ 'value': '587',
+ 'valuetype': 'integer',
+ 'category': 'email',
+ 'description': 'SMTP server port (usually 587 for TLS, 465 for SSL, 25 for unencrypted)'
+ },
+ {
+ 'key': 'smtp_username',
+ 'value': '',
+ 'valuetype': 'string',
+ 'category': 'email',
+ 'description': 'SMTP authentication username'
+ },
+ {
+ 'key': 'smtp_password',
+ 'value': '',
+ 'valuetype': 'string',
+ 'category': 'email',
+ 'description': 'SMTP authentication password'
+ },
+ {
+ 'key': 'smtp_use_tls',
+ 'value': 'true',
+ 'valuetype': 'boolean',
+ 'category': 'email',
+ 'description': 'Use TLS encryption for SMTP connection'
+ },
+ {
+ 'key': 'smtp_from_address',
+ 'value': '',
+ 'valuetype': 'string',
+ 'category': 'email',
+ 'description': 'From address for outgoing emails'
+ },
+ {
+ 'key': 'smtp_from_name',
+ 'value': 'ShopDB',
+ 'valuetype': 'string',
+ 'category': 'email',
+ 'description': 'From name for outgoing emails'
+ },
+ {
+ 'key': 'alert_recipients',
+ 'value': '',
+ 'valuetype': 'string',
+ 'category': 'email',
+ 'description': 'Default email recipients for alerts (comma-separated)'
+ },
+ # Audit log settings
+ {
+ 'key': 'audit_retention_days',
+ 'value': '90',
+ 'valuetype': 'integer',
+ 'category': 'audit',
+ 'description': 'Number of days to retain audit logs (0 = keep forever)'
+ },
+ # Authentication settings
+ {
+ 'key': 'saml_enabled',
+ 'value': 'false',
+ 'valuetype': 'boolean',
+ 'category': 'auth',
+ 'description': 'Enable SAML SSO authentication'
+ },
+ {
+ 'key': 'saml_idp_metadata_url',
+ 'value': '',
+ 'valuetype': 'string',
+ 'category': 'auth',
+ 'description': 'SAML Identity Provider metadata URL'
+ },
+ {
+ 'key': 'saml_entity_id',
+ 'value': '',
+ 'valuetype': 'string',
+ 'category': 'auth',
+ 'description': 'SAML Service Provider entity ID (e.g., https://shopdb.example.com)'
+ },
+ {
+ 'key': 'saml_acs_url',
+ 'value': '',
+ 'valuetype': 'string',
+ 'category': 'auth',
+ 'description': 'SAML Assertion Consumer Service URL'
+ },
+ {
+ 'key': 'saml_allow_local_login',
+ 'value': 'true',
+ 'valuetype': 'boolean',
+ 'category': 'auth',
+ 'description': 'Allow local username/password login when SAML is enabled'
+ },
+ {
+ 'key': 'saml_auto_create_users',
+ 'value': 'true',
+ 'valuetype': 'boolean',
+ 'category': 'auth',
+ 'description': 'Automatically create users on first SAML login'
+ },
+ {
+ 'key': 'saml_admin_group',
+ 'value': '',
+ 'valuetype': 'string',
+ 'category': 'auth',
+ 'description': 'SAML group name that grants admin role'
+ },
+ ]
+
+ created = 0
+ for d in defaults:
+ if not Setting.query.filter_by(key=d['key']).first():
+ setting = Setting(**d)
+ db.session.add(setting)
+ created += 1
+
+ db.session.commit()
+ invalidate_settings_cache()
+
+ return success_response({'created': created}, message=f'{created} default settings created')
diff --git a/shopdb/core/api/users.py b/shopdb/core/api/users.py
new file mode 100644
index 0000000..754252c
--- /dev/null
+++ b/shopdb/core/api/users.py
@@ -0,0 +1,351 @@
+"""User management API routes."""
+
+from flask import Blueprint, request
+from flask_jwt_extended import jwt_required, current_user
+from werkzeug.security import generate_password_hash
+
+from shopdb.extensions import db
+from shopdb.core.models import User, Role, Permission, AuditLog
+from shopdb.utils.responses import success_response, error_response, ErrorCodes
+
+users_bp = Blueprint('users', __name__)
+
+
+@users_bp.route('', methods=['GET'])
+@jwt_required()
+def list_users():
+ """List all users."""
+ if not current_user.hasrole('admin'):
+ return error_response(ErrorCodes.FORBIDDEN, 'Admin access required', http_code=403)
+
+ users = User.query.order_by(User.username).all()
+ return success_response([user_to_dict(u) for u in users])
+
+
+@users_bp.route('/', methods=['GET'])
+@jwt_required()
+def get_user(userid: int):
+ """Get a single user."""
+ if not current_user.hasrole('admin') and current_user.userid != userid:
+ return error_response(ErrorCodes.FORBIDDEN, 'Access denied', http_code=403)
+
+ user = User.query.get(userid)
+ if not user:
+ return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404)
+
+ return success_response(user_to_dict(user))
+
+
+@users_bp.route('', methods=['POST'])
+@jwt_required()
+def create_user():
+ """Create a new user."""
+ if not current_user.hasrole('admin'):
+ return error_response(ErrorCodes.FORBIDDEN, 'Admin access required', http_code=403)
+
+ data = request.get_json()
+ if not data:
+ return error_response(ErrorCodes.VALIDATION_ERROR, 'Request body required')
+
+ # Validate required fields
+ if not data.get('username'):
+ return error_response(ErrorCodes.VALIDATION_ERROR, 'Username is required')
+ if not data.get('email'):
+ return error_response(ErrorCodes.VALIDATION_ERROR, 'Email is required')
+ if not data.get('password'):
+ return error_response(ErrorCodes.VALIDATION_ERROR, 'Password is required')
+
+ # Check uniqueness
+ if User.query.filter_by(username=data['username']).first():
+ return error_response(ErrorCodes.CONFLICT, 'Username already exists', http_code=409)
+ if User.query.filter_by(email=data['email']).first():
+ return error_response(ErrorCodes.CONFLICT, 'Email already exists', http_code=409)
+
+ user = User(
+ username=data['username'],
+ email=data['email'],
+ passwordhash=generate_password_hash(data['password']),
+ firstname=data.get('firstname'),
+ lastname=data.get('lastname'),
+ isactive=data.get('isactive', True)
+ )
+
+ # Assign roles
+ role_ids = data.get('roles', [])
+ if role_ids:
+ roles = Role.query.filter(Role.roleid.in_(role_ids)).all()
+ user.roles = roles
+
+ db.session.add(user)
+
+ # Audit log
+ AuditLog.log('created', 'User', entityname=user.username)
+
+ db.session.commit()
+
+ return success_response(user_to_dict(user), message='User created', http_code=201)
+
+
+@users_bp.route('/', methods=['PUT'])
+@jwt_required()
+def update_user(userid: int):
+ """Update a user."""
+ if not current_user.hasrole('admin') and current_user.userid != userid:
+ return error_response(ErrorCodes.FORBIDDEN, 'Access denied', http_code=403)
+
+ user = User.query.get(userid)
+ if not user:
+ return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404)
+
+ data = request.get_json()
+ if not data:
+ return error_response(ErrorCodes.VALIDATION_ERROR, 'Request body required')
+
+ changes = {}
+
+ # Update fields
+ if 'email' in data and data['email'] != user.email:
+ if User.query.filter(User.email == data['email'], User.userid != userid).first():
+ return error_response(ErrorCodes.CONFLICT, 'Email already in use', http_code=409)
+ changes['email'] = {'old': user.email, 'new': data['email']}
+ user.email = data['email']
+
+ if 'firstname' in data:
+ if data['firstname'] != user.firstname:
+ changes['firstname'] = {'old': user.firstname, 'new': data['firstname']}
+ user.firstname = data['firstname']
+
+ if 'lastname' in data:
+ if data['lastname'] != user.lastname:
+ changes['lastname'] = {'old': user.lastname, 'new': data['lastname']}
+ user.lastname = data['lastname']
+
+ # Admin-only fields
+ if current_user.hasrole('admin'):
+ if 'isactive' in data:
+ if data['isactive'] != user.isactive:
+ changes['isactive'] = {'old': user.isactive, 'new': data['isactive']}
+ user.isactive = data['isactive']
+
+ if 'roles' in data:
+ old_roles = [r.rolename for r in user.roles]
+ roles = Role.query.filter(Role.roleid.in_(data['roles'])).all()
+ new_roles = [r.rolename for r in roles]
+ if set(old_roles) != set(new_roles):
+ changes['roles'] = {'old': old_roles, 'new': new_roles}
+ user.roles = roles
+
+ # Unlock user
+ if data.get('unlock'):
+ user.lockeduntil = None
+ user.failedlogins = 0
+ changes['unlocked'] = {'old': True, 'new': False}
+
+ # Password change
+ if 'password' in data and data['password']:
+ user.passwordhash = generate_password_hash(data['password'])
+ changes['password'] = {'old': '***', 'new': '***'}
+
+ if changes:
+ AuditLog.log('updated', 'User', entityid=user.userid, entityname=user.username, changes=changes)
+
+ db.session.commit()
+
+ return success_response(user_to_dict(user), message='User updated')
+
+
+@users_bp.route('/', methods=['DELETE'])
+@jwt_required()
+def delete_user(userid: int):
+ """Delete a user."""
+ if not current_user.hasrole('admin'):
+ return error_response(ErrorCodes.FORBIDDEN, 'Admin access required', http_code=403)
+
+ if current_user.userid == userid:
+ return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot delete your own account')
+
+ user = User.query.get(userid)
+ if not user:
+ return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404)
+
+ username = user.username
+ db.session.delete(user)
+
+ AuditLog.log('deleted', 'User', entityid=userid, entityname=username)
+
+ db.session.commit()
+
+ return success_response(None, message='User deleted')
+
+
+# Permissions endpoints
+@users_bp.route('/permissions', methods=['GET'])
+@jwt_required()
+def list_permissions():
+ """List all permissions grouped by category."""
+ permissions = Permission.query.order_by(Permission.category, Permission.name).all()
+
+ # Group by category
+ grouped = {}
+ for p in permissions:
+ if p.category not in grouped:
+ grouped[p.category] = []
+ grouped[p.category].append({
+ 'permissionid': p.permissionid,
+ 'name': p.name,
+ 'description': p.description
+ })
+
+ return success_response({
+ 'permissions': [{
+ 'permissionid': p.permissionid,
+ 'name': p.name,
+ 'description': p.description,
+ 'category': p.category
+ } for p in permissions],
+ 'grouped': grouped
+ })
+
+
+# Roles endpoints
+@users_bp.route('/roles', methods=['GET'])
+@jwt_required()
+def list_roles():
+ """List all roles with their permissions."""
+ roles = Role.query.order_by(Role.rolename).all()
+ return success_response([{
+ 'roleid': r.roleid,
+ 'rolename': r.rolename,
+ 'description': r.description,
+ 'usercount': r.users.count(),
+ 'permissions': [p.name for p in r.permissions],
+ 'isadmin': r.rolename == 'admin'
+ } for r in roles])
+
+
+@users_bp.route('/roles', methods=['POST'])
+@jwt_required()
+def create_role():
+ """Create a new role."""
+ if not current_user.hasrole('admin'):
+ return error_response(ErrorCodes.FORBIDDEN, 'Admin access required', http_code=403)
+
+ data = request.get_json()
+ if not data or not data.get('rolename'):
+ return error_response(ErrorCodes.VALIDATION_ERROR, 'Role name is required')
+
+ if Role.query.filter_by(rolename=data['rolename']).first():
+ return error_response(ErrorCodes.CONFLICT, 'Role already exists', http_code=409)
+
+ role = Role(
+ rolename=data['rolename'],
+ description=data.get('description')
+ )
+
+ # Assign permissions
+ if 'permissions' in data:
+ perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all()
+ role.permissions = perms
+
+ db.session.add(role)
+
+ AuditLog.log('created', 'Role', entityname=role.rolename)
+
+ db.session.commit()
+
+ return success_response({
+ 'roleid': role.roleid,
+ 'rolename': role.rolename,
+ 'description': role.description,
+ 'permissions': [p.name for p in role.permissions]
+ }, message='Role created', http_code=201)
+
+
+@users_bp.route('/roles/', methods=['PUT'])
+@jwt_required()
+def update_role(roleid: int):
+ """Update a role."""
+ if not current_user.hasrole('admin'):
+ return error_response(ErrorCodes.FORBIDDEN, 'Admin access required', http_code=403)
+
+ role = Role.query.get(roleid)
+ if not role:
+ return error_response(ErrorCodes.NOT_FOUND, 'Role not found', http_code=404)
+
+ # Cannot modify admin role permissions
+ if role.rolename == 'admin' and 'permissions' in request.get_json():
+ return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot modify admin role permissions')
+
+ data = request.get_json()
+ changes = {}
+
+ if 'description' in data:
+ if data['description'] != role.description:
+ changes['description'] = {'old': role.description, 'new': data['description']}
+ role.description = data['description']
+
+ # Update permissions
+ if 'permissions' in data and role.rolename != 'admin':
+ old_perms = [p.name for p in role.permissions]
+ perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all()
+ new_perms = [p.name for p in perms]
+ if set(old_perms) != set(new_perms):
+ changes['permissions'] = {'old': old_perms, 'new': new_perms}
+ role.permissions = perms
+
+ if changes:
+ AuditLog.log('updated', 'Role', entityid=role.roleid, entityname=role.rolename, changes=changes)
+
+ db.session.commit()
+
+ return success_response({
+ 'roleid': role.roleid,
+ 'rolename': role.rolename,
+ 'description': role.description,
+ 'permissions': [p.name for p in role.permissions]
+ }, message='Role updated')
+
+
+@users_bp.route('/roles/', methods=['DELETE'])
+@jwt_required()
+def delete_role(roleid: int):
+ """Delete a role."""
+ if not current_user.hasrole('admin'):
+ return error_response(ErrorCodes.FORBIDDEN, 'Admin access required', http_code=403)
+
+ role = Role.query.get(roleid)
+ if not role:
+ return error_response(ErrorCodes.NOT_FOUND, 'Role not found', http_code=404)
+
+ if role.rolename == 'admin':
+ return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot delete the admin role')
+
+ if role.users.count() > 0:
+ return error_response(ErrorCodes.VALIDATION_ERROR, f'Role is assigned to {role.users.count()} user(s)')
+
+ rolename = role.rolename
+ db.session.delete(role)
+
+ AuditLog.log('deleted', 'Role', entityid=roleid, entityname=rolename)
+
+ db.session.commit()
+
+ return success_response(None, message='Role deleted')
+
+
+def user_to_dict(user: User) -> dict:
+ """Convert user to dict for API response."""
+ return {
+ 'userid': user.userid,
+ 'username': user.username,
+ 'email': user.email,
+ 'firstname': user.firstname,
+ 'lastname': user.lastname,
+ 'isactive': user.isactive,
+ 'islocked': user.islocked,
+ 'lastlogindate': user.lastlogindate.isoformat() + 'Z' if user.lastlogindate else None,
+ 'failedlogins': user.failedlogins,
+ 'roles': [{'roleid': r.roleid, 'rolename': r.rolename} for r in user.roles],
+ 'createddate': user.createddate.isoformat() + 'Z' if user.createddate else None,
+ 'modifieddate': user.modifieddate.isoformat() + 'Z' if user.modifieddate else None
+ }
diff --git a/shopdb/core/models/__init__.py b/shopdb/core/models/__init__.py
index 29205c7..472f867 100644
--- a/shopdb/core/models/__init__.py
+++ b/shopdb/core/models/__init__.py
@@ -10,9 +10,11 @@ from .location import Location
from .operatingsystem import OperatingSystem
from .relationship import MachineRelationship, AssetRelationship, RelationshipType
from .communication import Communication, CommunicationType
-from .user import User, Role
+from .user import User, Role, Permission
from .application import Application, AppVersion, AppOwner, SupportTeam, InstalledApp
from .knowledgebase import KnowledgeBase
+from .setting import Setting
+from .auditlog import AuditLog
__all__ = [
# Base
@@ -44,6 +46,7 @@ __all__ = [
# Auth
'User',
'Role',
+ 'Permission',
# Applications
'Application',
'AppVersion',
@@ -52,4 +55,8 @@ __all__ = [
'InstalledApp',
# Knowledge Base
'KnowledgeBase',
+ # Settings
+ 'Setting',
+ # Audit
+ 'AuditLog',
]
diff --git a/shopdb/core/models/auditlog.py b/shopdb/core/models/auditlog.py
new file mode 100644
index 0000000..ef93221
--- /dev/null
+++ b/shopdb/core/models/auditlog.py
@@ -0,0 +1,156 @@
+"""Audit log model for tracking changes."""
+
+from datetime import datetime
+from shopdb.extensions import db
+
+
+class AuditLog(db.Model):
+ """
+ Audit log for tracking user actions.
+
+ Records who did what, when, from where, and what changed.
+ """
+ __tablename__ = 'auditlogs'
+
+ auditlogid = db.Column(db.Integer, primary_key=True)
+
+ # Who
+ userid = db.Column(db.Integer, db.ForeignKey('users.userid'), nullable=True)
+ username = db.Column(db.String(100), nullable=True) # Denormalized for history
+
+ # When
+ timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True)
+
+ # Where (client info)
+ ipaddress = db.Column(db.String(45), nullable=True) # IPv6 max length
+ useragent = db.Column(db.String(255), nullable=True)
+
+ # What
+ action = db.Column(db.String(20), nullable=False, index=True) # created, updated, deleted
+ entitytype = db.Column(db.String(50), nullable=False, index=True) # Asset, Printer, Setting, etc.
+ entityid = db.Column(db.Integer, nullable=True) # ID of the affected record
+ entityname = db.Column(db.String(255), nullable=True) # Human-readable identifier
+
+ # Changes (stored as JSON text)
+ _changes = db.Column('changes', db.Text, nullable=True) # {"field": {"old": x, "new": y}, ...}
+
+ @property
+ def changes(self):
+ """Get changes as dict."""
+ if self._changes:
+ import json
+ try:
+ return json.loads(self._changes)
+ except (json.JSONDecodeError, TypeError):
+ return None
+ return None
+
+ @changes.setter
+ def changes(self, value):
+ """Set changes from dict."""
+ if value is not None:
+ import json
+ self._changes = json.dumps(value)
+ else:
+ self._changes = None
+
+ # Additional context
+ details = db.Column(db.Text, nullable=True) # Optional description
+
+ # Relationship
+ user = db.relationship('User', backref=db.backref('auditlogs', lazy='dynamic'))
+
+ def to_dict(self):
+ return {
+ 'auditlogid': self.auditlogid,
+ 'userid': self.userid,
+ 'username': self.username,
+ 'timestamp': self.timestamp.isoformat() + 'Z' if self.timestamp else None,
+ 'ipaddress': self.ipaddress,
+ 'useragent': self.useragent,
+ 'action': self.action,
+ 'entitytype': self.entitytype,
+ 'entityid': self.entityid,
+ 'entityname': self.entityname,
+ 'changes': self.changes,
+ 'details': self.details
+ }
+
+ @classmethod
+ def log(cls, action: str, entitytype: str, entityid: int = None,
+ entityname: str = None, changes: dict = None, details: str = None,
+ user=None, request=None):
+ """
+ Create an audit log entry.
+
+ Args:
+ action: 'created', 'updated', 'deleted'
+ entitytype: Type of entity (e.g., 'Asset', 'Printer', 'Setting')
+ entityid: ID of the affected record
+ entityname: Human-readable name/identifier
+ changes: Dict of field changes {"field": {"old": x, "new": y}}
+ details: Optional description
+ user: Current user object (or will try to get from flask-jwt-extended)
+ request: Flask request object (or will try to get current request)
+ """
+ from flask import request as flask_request
+ from flask_jwt_extended import current_user, verify_jwt_in_request
+
+ # Get user info
+ if user is None:
+ try:
+ verify_jwt_in_request(optional=True)
+ user = current_user
+ except:
+ pass
+
+ userid = user.userid if user else None
+ username = user.username if user else None
+
+ # Get request info
+ req = request or flask_request
+ ipaddress = None
+ useragent = None
+
+ if req:
+ # Handle proxy forwarding
+ ipaddress = req.headers.get('X-Forwarded-For', req.remote_addr)
+ if ipaddress and ',' in ipaddress:
+ ipaddress = ipaddress.split(',')[0].strip()
+ useragent = req.headers.get('User-Agent', '')[:255]
+
+ entry = cls(
+ userid=userid,
+ username=username,
+ ipaddress=ipaddress,
+ useragent=useragent,
+ action=action,
+ entitytype=entitytype,
+ entityid=entityid,
+ entityname=entityname,
+ changes=changes,
+ details=details
+ )
+
+ db.session.add(entry)
+ # Don't commit here - let the caller handle transaction
+ return entry
+
+ @classmethod
+ def log_create(cls, entitytype: str, entity, name_field: str = 'name'):
+ """Log a create action."""
+ entityname = getattr(entity, name_field, None) or str(entity)
+ entityid = getattr(entity, f'{entitytype.lower()}id', None) or getattr(entity, 'id', None)
+ return cls.log('created', entitytype, entityid=entityid, entityname=entityname)
+
+ @classmethod
+ def log_update(cls, entitytype: str, entity, changes: dict, name_field: str = 'name'):
+ """Log an update action with changes."""
+ entityname = getattr(entity, name_field, None) or str(entity)
+ entityid = getattr(entity, f'{entitytype.lower()}id', None) or getattr(entity, 'id', None)
+ return cls.log('updated', entitytype, entityid=entityid, entityname=entityname, changes=changes)
+
+ @classmethod
+ def log_delete(cls, entitytype: str, entityid: int, entityname: str = None):
+ """Log a delete action."""
+ return cls.log('deleted', entitytype, entityid=entityid, entityname=entityname)
diff --git a/shopdb/core/models/setting.py b/shopdb/core/models/setting.py
new file mode 100644
index 0000000..b7f896b
--- /dev/null
+++ b/shopdb/core/models/setting.py
@@ -0,0 +1,73 @@
+"""System settings model for key-value configuration storage."""
+
+from datetime import datetime
+from shopdb.extensions import db
+
+
+class Setting(db.Model):
+ """
+ Key-value store for system settings.
+
+ Settings can be managed via the admin UI and are cached
+ for performance.
+ """
+ __tablename__ = 'settings'
+
+ settingid = db.Column(db.Integer, primary_key=True, autoincrement=True)
+ key = db.Column(db.String(100), unique=True, nullable=False, index=True)
+ value = db.Column(db.Text, nullable=True)
+ valuetype = db.Column(db.String(20), default='string') # string, boolean, integer, json
+ category = db.Column(db.String(50), default='general') # For grouping in UI
+ description = db.Column(db.String(255), nullable=True)
+ createddate = db.Column(db.DateTime, default=datetime.utcnow)
+ modifieddate = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+ def to_dict(self):
+ return {
+ 'settingid': self.settingid,
+ 'key': self.key,
+ 'value': self.get_typed_value(),
+ 'valuetype': self.valuetype,
+ 'category': self.category,
+ 'description': self.description,
+ 'createddate': self.createddate.isoformat() + 'Z' if self.createddate else None,
+ 'modifieddate': self.modifieddate.isoformat() + 'Z' if self.modifieddate else None,
+ }
+
+ def get_typed_value(self):
+ """Return value converted to its proper type."""
+ if self.value is None:
+ return None
+ if self.valuetype == 'boolean':
+ return self.value.lower() in ('true', '1', 'yes')
+ if self.valuetype == 'integer':
+ try:
+ return int(self.value)
+ except (ValueError, TypeError):
+ return 0
+ return self.value
+
+ @classmethod
+ def get(cls, key: str, default=None):
+ """Get a setting value by key."""
+ setting = cls.query.filter_by(key=key).first()
+ if setting:
+ return setting.get_typed_value()
+ return default
+
+ @classmethod
+ def set(cls, key: str, value, valuetype: str = 'string', category: str = 'general', description: str = None):
+ """Set a setting value, creating if it doesn't exist."""
+ setting = cls.query.filter_by(key=key).first()
+ if not setting:
+ setting = cls(key=key, valuetype=valuetype, category=category, description=description)
+ db.session.add(setting)
+
+ # Convert value to string for storage
+ if isinstance(value, bool):
+ setting.value = 'true' if value else 'false'
+ else:
+ setting.value = str(value) if value is not None else None
+
+ db.session.commit()
+ return setting
diff --git a/shopdb/core/models/user.py b/shopdb/core/models/user.py
index 31f8e73..aa97806 100644
--- a/shopdb/core/models/user.py
+++ b/shopdb/core/models/user.py
@@ -12,6 +12,98 @@ userroles = db.Table(
db.Column('roleid', db.Integer, db.ForeignKey('roles.roleid'), primary_key=True)
)
+# Association table for role permissions (many-to-many)
+rolepermissions = db.Table(
+ 'rolepermissions',
+ db.Column('roleid', db.Integer, db.ForeignKey('roles.roleid'), primary_key=True),
+ db.Column('permissionid', db.Integer, db.ForeignKey('permissions.permissionid'), primary_key=True)
+)
+
+
+class Permission(db.Model):
+ """
+ Permission model for granular access control.
+
+ Permissions are predefined and assigned to roles.
+ """
+ __tablename__ = 'permissions'
+
+ permissionid = db.Column(db.Integer, primary_key=True)
+ name = db.Column(db.String(50), unique=True, nullable=False)
+ description = db.Column(db.String(255))
+ category = db.Column(db.String(50), default='general') # For grouping in UI
+
+ # Predefined permissions
+ PERMISSIONS = [
+ # Assets
+ ('assets.view', 'View assets', 'assets'),
+ ('assets.create', 'Create assets', 'assets'),
+ ('assets.edit', 'Edit assets', 'assets'),
+ ('assets.delete', 'Delete assets', 'assets'),
+ # Equipment
+ ('equipment.view', 'View equipment', 'equipment'),
+ ('equipment.create', 'Create equipment', 'equipment'),
+ ('equipment.edit', 'Edit equipment', 'equipment'),
+ ('equipment.delete', 'Delete equipment', 'equipment'),
+ # Computers
+ ('computers.view', 'View computers', 'computers'),
+ ('computers.create', 'Create computers', 'computers'),
+ ('computers.edit', 'Edit computers', 'computers'),
+ ('computers.delete', 'Delete computers', 'computers'),
+ # Printers
+ ('printers.view', 'View printers', 'printers'),
+ ('printers.create', 'Create printers', 'printers'),
+ ('printers.edit', 'Edit printers', 'printers'),
+ ('printers.delete', 'Delete printers', 'printers'),
+ # Network
+ ('network.view', 'View network devices', 'network'),
+ ('network.create', 'Create network devices', 'network'),
+ ('network.edit', 'Edit network devices', 'network'),
+ ('network.delete', 'Delete network devices', 'network'),
+ # Applications
+ ('applications.view', 'View applications', 'applications'),
+ ('applications.create', 'Create applications', 'applications'),
+ ('applications.edit', 'Edit applications', 'applications'),
+ ('applications.delete', 'Delete applications', 'applications'),
+ # Knowledge Base
+ ('kb.view', 'View knowledge base', 'knowledgebase'),
+ ('kb.create', 'Create KB articles', 'knowledgebase'),
+ ('kb.edit', 'Edit KB articles', 'knowledgebase'),
+ ('kb.delete', 'Delete KB articles', 'knowledgebase'),
+ # Notifications
+ ('notifications.view', 'View notifications', 'notifications'),
+ ('notifications.create', 'Create notifications', 'notifications'),
+ ('notifications.edit', 'Edit notifications', 'notifications'),
+ ('notifications.delete', 'Delete notifications', 'notifications'),
+ # Reports
+ ('reports.view', 'View reports', 'reports'),
+ ('reports.export', 'Export reports', 'reports'),
+ # Settings
+ ('settings.view', 'View settings', 'admin'),
+ ('settings.edit', 'Edit settings', 'admin'),
+ # Users
+ ('users.view', 'View users', 'admin'),
+ ('users.create', 'Create users', 'admin'),
+ ('users.edit', 'Edit users', 'admin'),
+ ('users.delete', 'Delete users', 'admin'),
+ # Audit
+ ('audit.view', 'View audit logs', 'admin'),
+ ]
+
+ def __repr__(self):
+ return f""
+
+ @classmethod
+ def seed(cls):
+ """Seed predefined permissions."""
+ created = 0
+ for name, description, category in cls.PERMISSIONS:
+ if not cls.query.filter_by(name=name).first():
+ perm = cls(name=name, description=description, category=category)
+ db.session.add(perm)
+ created += 1
+ return created
+
class Role(BaseModel):
"""User role model."""
@@ -21,9 +113,29 @@ class Role(BaseModel):
rolename = db.Column(db.String(50), unique=True, nullable=False)
description = db.Column(db.Text)
+ # Permissions relationship
+ permissions = db.relationship(
+ 'Permission',
+ secondary=rolepermissions,
+ backref=db.backref('roles', lazy='dynamic')
+ )
+
def __repr__(self):
return f""
+ def haspermission(self, permission_name: str) -> bool:
+ """Check if role has a specific permission."""
+ # Admin role has all permissions
+ if self.rolename == 'admin':
+ return True
+ return any(p.name == permission_name for p in self.permissions)
+
+ def getpermissionnames(self) -> list:
+ """Get list of permission names."""
+ if self.rolename == 'admin':
+ return [p[0] for p in Permission.PERMISSIONS]
+ return [p.name for p in self.permissions]
+
class User(BaseModel):
"""User model for authentication."""
@@ -64,10 +176,19 @@ class User(BaseModel):
"""Check if user has a specific role."""
return any(r.rolename == rolename for r in self.roles)
+ def haspermission(self, permission_name: str) -> bool:
+ """Check if user has a specific permission through any role."""
+ # Admin role has all permissions
+ if self.hasrole('admin'):
+ return True
+ return any(r.haspermission(permission_name) for r in self.roles)
+
def getpermissions(self) -> list:
- """Get list of permission names from roles."""
- # Simple role-based permissions
- perms = []
+ """Get list of all permission names from all roles."""
+ if self.hasrole('admin'):
+ return [p[0] for p in Permission.PERMISSIONS]
+
+ perms = set()
for role in self.roles:
- perms.append(role.rolename)
- return perms
+ perms.update(role.getpermissionnames())
+ return list(perms)
diff --git a/shopdb/extensions.py b/shopdb/extensions.py
index f1eee9a..847a03b 100644
--- a/shopdb/extensions.py
+++ b/shopdb/extensions.py
@@ -5,6 +5,7 @@ from flask_migrate import Migrate
from flask_jwt_extended import JWTManager
from flask_cors import CORS
from flask_marshmallow import Marshmallow
+from flask_caching import Cache
# Initialize extensions without app
db = SQLAlchemy()
@@ -12,6 +13,7 @@ migrate = Migrate()
jwt = JWTManager()
cors = CORS()
ma = Marshmallow()
+cache = Cache()
def init_extensions(app):
@@ -23,3 +25,4 @@ def init_extensions(app):
r"/api/*": {"origins": app.config.get('CORS_ORIGINS', '*')}
})
ma.init_app(app)
+ cache.init_app(app)