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:
@@ -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',
|
||||
]
|
||||
|
||||
156
shopdb/core/models/auditlog.py
Normal file
156
shopdb/core/models/auditlog.py
Normal 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)
|
||||
73
shopdb/core/models/setting.py
Normal file
73
shopdb/core/models/setting.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user