Add system settings, audit logging, user management, and dark mode fixes

System Settings:
- Add SystemSettings.vue with Zabbix integration, SMTP/email config, SAML SSO settings
- Add Setting model with key-value storage and typed values
- Add settings API with caching

Audit Logging:
- Add AuditLog model tracking user, IP, action, entity changes
- Add comprehensive audit logging to all CRUD operations:
  - Machines, Computers, Equipment, Network devices, VLANs, Subnets
  - Printers, USB devices (including checkout/checkin)
  - Applications, Settings, Users/Roles
- Track old/new values for all field changes
- Mask sensitive values (passwords, tokens) in logs

User Management:
- Add UsersList.vue with full user CRUD
- Add Role management with granular permissions
- Add 41 predefined permissions across 10 categories
- Add users API with roles and permissions endpoints

Reports:
- Add TonerReport.vue for printer supply monitoring

Dark Mode Fixes:
- Fix map position section in PCForm, PrinterForm
- Fix alert-warning in KnowledgeBaseDetail
- All components now use CSS variables for theming

CLI Commands:
- Add flask seed permissions
- Add flask seed settings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-02-04 22:16:56 -05:00
parent 9efdb5f52d
commit e18c7c2d87
40 changed files with 4221 additions and 39 deletions

View File

@@ -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',
]

View File

@@ -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)

View File

@@ -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

View File

@@ -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"<Permission {self.name}>"
@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"<Role {self.rolename}>"
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)