Files
shopdb-flask/shopdb/core/api/settings.py
cproudlock e18c7c2d87 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>
2026-02-04 22:16:56 -05:00

294 lines
9.2 KiB
Python

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