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

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

View File

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

View File

@@ -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/<entitytype>/<int:entityid>', 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}

View File

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

293
shopdb/core/api/settings.py Normal file
View File

@@ -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('/<key>', 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('/<key>', 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')

351
shopdb/core/api/users.py Normal file
View File

@@ -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('/<int:userid>', 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('/<int:userid>', 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('/<int:userid>', 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/<int:roleid>', 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/<int:roleid>', 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
}