"""Admin API endpoints for user management, settings, and audit logs.""" from datetime import datetime, timedelta from flask import Blueprint, request, jsonify from flask_jwt_extended import get_jwt_identity from sqlalchemy import func from app import db from app.models import User, AuditLog from app.middleware.rbac import admin_required, get_current_user from app.services.audit_service import AuditService from app.services.settings_service import SettingsService from app.services.permission_service import PermissionService admin_bp = Blueprint('admin', __name__) # ============================================ # User Management Endpoints # ============================================ @admin_bp.route('/users', methods=['GET']) @admin_required def list_users(): """List all users.""" users = User.query.order_by(User.created_at.desc()).all() return jsonify({ 'users': [user.to_dict() for user in users] }), 200 @admin_bp.route('/users', methods=['POST']) @admin_required def create_user(): """Create a new user.""" data = request.get_json() current_user_id = get_jwt_identity() if not data: return jsonify({'error': 'No data provided'}), 400 email = data.get('email') username = data.get('username') password = data.get('password') role = data.get('role', User.ROLE_DEVELOPER) if not all([email, username, password]): return jsonify({'error': 'Missing required fields: email, username, password'}), 400 if role not in User.VALID_ROLES: return jsonify({'error': f'Invalid role. Must be one of: {", ".join(User.VALID_ROLES)}'}), 400 if User.query.filter_by(email=email).first(): return jsonify({'error': 'Email already registered'}), 409 if User.query.filter_by(username=username).first(): return jsonify({'error': 'Username already taken'}), 409 if len(password) < 8: return jsonify({'error': 'Password must be at least 8 characters'}), 400 user = User( email=email, username=username, role=role, created_by=current_user_id, is_active=data.get('is_active', True) ) user.set_password(password) db.session.add(user) db.session.commit() # Log the action AuditService.log_user_action( action=AuditLog.ACTION_USER_CREATE, user_id=current_user_id, target_user_id=user.id, details={'username': username, 'role': role} ) db.session.commit() return jsonify({ 'message': 'User created successfully', 'user': user.to_dict() }), 201 @admin_bp.route('/users/', methods=['GET']) @admin_required def get_user(user_id): """Get a specific user.""" user = User.query.get(user_id) if not user: return jsonify({'error': 'User not found'}), 404 return jsonify({'user': user.to_dict()}), 200 @admin_bp.route('/users/', methods=['PUT']) @admin_required def update_user(user_id): """Update a user.""" data = request.get_json() current_user_id = get_jwt_identity() user = User.query.get(user_id) if not user: return jsonify({'error': 'User not found'}), 404 changes = {} # Update email if 'email' in data and data['email'] != user.email: existing = User.query.filter_by(email=data['email']).first() if existing and existing.id != user.id: return jsonify({'error': 'Email already registered'}), 409 changes['email'] = {'old': user.email, 'new': data['email']} user.email = data['email'] # Update username if 'username' in data and data['username'] != user.username: existing = User.query.filter_by(username=data['username']).first() if existing and existing.id != user.id: return jsonify({'error': 'Username already taken'}), 409 changes['username'] = {'old': user.username, 'new': data['username']} user.username = data['username'] # Update role if 'role' in data and data['role'] != user.role: if data['role'] not in User.VALID_ROLES: return jsonify({'error': f'Invalid role. Must be one of: {", ".join(User.VALID_ROLES)}'}), 400 # Prevent demoting the last admin if user.role == User.ROLE_ADMIN and data['role'] != User.ROLE_ADMIN: admin_count = User.query.filter_by(role=User.ROLE_ADMIN).count() if admin_count <= 1: return jsonify({'error': 'Cannot demote the last admin'}), 400 changes['role'] = {'old': user.role, 'new': data['role']} user.role = data['role'] # Update password if 'password' in data and data['password']: if len(data['password']) < 8: return jsonify({'error': 'Password must be at least 8 characters'}), 400 user.set_password(data['password']) changes['password'] = 'changed' # Update custom permissions (inline with user update) if 'permissions' in data and user.role != User.ROLE_ADMIN: from app.services.permission_service import PermissionService perm_error = PermissionService.validate_permissions(data['permissions']) if perm_error: return jsonify({'error': perm_error}), 400 user.set_permissions(data['permissions']) changes['permissions'] = 'updated' # Update active status if 'is_active' in data and data['is_active'] != user.is_active: # Prevent deactivating self if user.id == current_user_id and not data['is_active']: return jsonify({'error': 'Cannot deactivate your own account'}), 400 changes['is_active'] = {'old': user.is_active, 'new': data['is_active']} user.is_active = data['is_active'] # Log enable/disable separately action = AuditLog.ACTION_USER_ENABLE if data['is_active'] else AuditLog.ACTION_USER_DISABLE AuditService.log_user_action( action=action, user_id=current_user_id, target_user_id=user.id, details={'username': user.username} ) db.session.commit() # Log the update if changes: AuditService.log_user_action( action=AuditLog.ACTION_USER_UPDATE, user_id=current_user_id, target_user_id=user.id, details={'changes': changes} ) db.session.commit() return jsonify({ 'message': 'User updated successfully', 'user': user.to_dict() }), 200 @admin_bp.route('/users/', methods=['DELETE']) @admin_required def delete_user(user_id): """Delete a user.""" current_user_id = get_jwt_identity() user = User.query.get(user_id) if not user: return jsonify({'error': 'User not found'}), 404 # Prevent self-deletion if user.id == current_user_id: return jsonify({'error': 'Cannot delete your own account'}), 400 # Prevent deleting the last admin if user.role == User.ROLE_ADMIN: admin_count = User.query.filter_by(role=User.ROLE_ADMIN).count() if admin_count <= 1: return jsonify({'error': 'Cannot delete the last admin'}), 400 username = user.username user_id_deleted = user.id db.session.delete(user) db.session.commit() # Log the action AuditService.log_user_action( action=AuditLog.ACTION_USER_DELETE, user_id=current_user_id, target_user_id=user_id_deleted, details={'username': username} ) db.session.commit() return jsonify({'message': 'User deleted successfully'}), 200 # ============================================ # Audit Log Endpoints # ============================================ @admin_bp.route('/audit-logs', methods=['GET']) @admin_required def get_audit_logs(): """Get audit logs with pagination and filtering.""" page = request.args.get('page', 1, type=int) per_page = request.args.get('per_page', 50, type=int) action = request.args.get('action') user_id = request.args.get('user_id', type=int) target_type = request.args.get('target_type') # Limit per_page to reasonable values per_page = min(per_page, 100) pagination = AuditService.get_logs( page=page, per_page=per_page, action=action, user_id=user_id, target_type=target_type ) return jsonify({ 'logs': [log.to_dict() for log in pagination.items], 'pagination': { 'page': pagination.page, 'per_page': pagination.per_page, 'total': pagination.total, 'pages': pagination.pages, 'has_next': pagination.has_next, 'has_prev': pagination.has_prev } }), 200 @admin_bp.route('/audit-logs/actions', methods=['GET']) @admin_required def get_audit_log_actions(): """Get list of unique action types in audit logs.""" actions = db.session.query(AuditLog.action).distinct().all() return jsonify({ 'actions': [a[0] for a in actions] }), 200 # ============================================ # System Settings Endpoints # ============================================ @admin_bp.route('/settings', methods=['GET']) @admin_required def get_settings(): """Get all system settings.""" settings = SettingsService.get_all_with_metadata() return jsonify({'settings': settings}), 200 @admin_bp.route('/settings', methods=['PUT']) @admin_required def update_settings(): """Update system settings.""" data = request.get_json() current_user_id = get_jwt_identity() if not data: return jsonify({'error': 'No data provided'}), 400 updated = [] for key, value in data.items(): old_value = SettingsService.get(key) SettingsService.set(key, value, user_id=current_user_id) updated.append(key) # Log the change AuditService.log_settings_change( user_id=current_user_id, key=key, old_value=old_value, new_value=value ) db.session.commit() return jsonify({ 'message': 'Settings updated successfully', 'updated': updated }), 200 @admin_bp.route('/settings/', methods=['GET']) @admin_required def get_setting(key): """Get a specific setting.""" value = SettingsService.get(key) if value is None: return jsonify({'error': 'Setting not found'}), 404 return jsonify({'key': key, 'value': value}), 200 @admin_bp.route('/settings/', methods=['PUT']) @admin_required def update_setting(key): """Update a specific setting.""" data = request.get_json() current_user_id = get_jwt_identity() if 'value' not in data: return jsonify({'error': 'No value provided'}), 400 old_value = SettingsService.get(key) SettingsService.set(key, data['value'], user_id=current_user_id) # Log the change AuditService.log_settings_change( user_id=current_user_id, key=key, old_value=old_value, new_value=data['value'] ) db.session.commit() return jsonify({ 'message': 'Setting updated successfully', 'key': key, 'value': data['value'] }), 200 # ============================================ # Admin Statistics Endpoints # ============================================ @admin_bp.route('/stats', methods=['GET']) @admin_required def get_admin_stats(): """Get admin statistics.""" user_count = User.query.count() active_users = User.query.filter_by(is_active=True).count() admin_count = User.query.filter_by(role=User.ROLE_ADMIN).count() developer_count = User.query.filter_by(role=User.ROLE_DEVELOPER).count() viewer_count = User.query.filter_by(role=User.ROLE_VIEWER).count() # Recent logins recent_logins = AuditLog.query.filter_by( action=AuditLog.ACTION_LOGIN ).order_by(AuditLog.created_at.desc()).limit(10).all() return jsonify({ 'users': { 'total': user_count, 'active': active_users, 'by_role': { 'admin': admin_count, 'developer': developer_count, 'viewer': viewer_count } }, 'recent_logins': [log.to_dict() for log in recent_logins] }), 200 # ============================================ # Permission Endpoints # ============================================ @admin_bp.route('/users//permissions', methods=['GET']) @admin_required def get_user_permissions(user_id): """Get a user's resolved permissions.""" perms = PermissionService.get_user_permissions(user_id) if perms is None: return jsonify({'error': 'User not found'}), 404 return jsonify({'permissions': perms}), 200 @admin_bp.route('/users//permissions', methods=['PUT']) @admin_required def update_user_permissions(user_id): """Update a user's custom permissions.""" data = request.get_json() current_user_id = get_jwt_identity() if not data or 'permissions' not in data: return jsonify({'error': 'No permissions provided'}), 400 result = PermissionService.update_user_permissions(user_id, data['permissions']) if not result['success']: return jsonify({'error': result['error']}), 400 AuditService.log_user_action( action=AuditLog.ACTION_USER_PERMISSIONS_UPDATE, user_id=current_user_id, target_user_id=user_id, details={'permissions': data['permissions']} ) db.session.commit() return jsonify({ 'message': 'Permissions updated', 'permissions': result['permissions'] }), 200 @admin_bp.route('/users//permissions/reset', methods=['POST']) @admin_required def reset_user_permissions(user_id): """Reset user permissions to role defaults.""" current_user_id = get_jwt_identity() result = PermissionService.reset_to_role_defaults(user_id) if not result['success']: return jsonify({'error': result['error']}), 400 AuditService.log_user_action( action=AuditLog.ACTION_USER_PERMISSIONS_RESET, user_id=current_user_id, target_user_id=user_id, ) db.session.commit() return jsonify({ 'message': 'Permissions reset to role defaults', 'permissions': result['permissions'] }), 200 @admin_bp.route('/permissions/templates', methods=['GET']) @admin_required def get_permission_templates(): """Get role template definitions and feature list.""" return jsonify({ 'features': User.PERMISSION_FEATURES, 'templates': User.ROLE_PERMISSION_TEMPLATES, }), 200 # ============================================ # Activity Dashboard Endpoints # ============================================ @admin_bp.route('/activity/summary', methods=['GET']) @admin_required def get_activity_summary(): """Get activity summary: active users today, actions this week, top users.""" now = datetime.utcnow() today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) week_start = today_start - timedelta(days=7) # Active users today (distinct users with audit log entries) active_today = db.session.query( func.count(func.distinct(AuditLog.user_id)) ).filter( AuditLog.created_at >= today_start, AuditLog.user_id.isnot(None) ).scalar() or 0 # Actions this week actions_this_week = db.session.query( func.count(AuditLog.id) ).filter( AuditLog.created_at >= week_start ).scalar() or 0 # Total users total_users = User.query.filter_by(is_active=True).count() # Top 5 most active users this week top_users_query = db.session.query( AuditLog.user_id, func.count(AuditLog.id).label('action_count') ).filter( AuditLog.created_at >= week_start, AuditLog.user_id.isnot(None) ).group_by(AuditLog.user_id).order_by( func.count(AuditLog.id).desc() ).limit(5).all() top_users = [] for user_id, count in top_users_query: user = User.query.get(user_id) if user: top_users.append({ 'user_id': user_id, 'username': user.username, 'action_count': count }) # Daily action counts for the past 7 days daily_counts = [] for i in range(7): day_start = today_start - timedelta(days=6 - i) day_end = day_start + timedelta(days=1) count = db.session.query(func.count(AuditLog.id)).filter( AuditLog.created_at >= day_start, AuditLog.created_at < day_end ).scalar() or 0 daily_counts.append({ 'date': day_start.strftime('%Y-%m-%d'), 'count': count }) return jsonify({ 'active_users_today': active_today, 'actions_this_week': actions_this_week, 'total_users': total_users, 'top_users': top_users, 'daily_counts': daily_counts, }), 200 @admin_bp.route('/activity/feed', methods=['GET']) @admin_required def get_activity_feed(): """Get paginated activity feed with filters.""" page = request.args.get('page', 1, type=int) per_page = min(request.args.get('per_page', 50, type=int), 100) user_id = request.args.get('user_id', type=int) action = request.args.get('action') start_date = request.args.get('start_date') end_date = request.args.get('end_date') kwargs = { 'page': page, 'per_page': per_page, 'user_id': user_id, 'action': action, } if start_date: try: kwargs['start_date'] = datetime.fromisoformat(start_date) except ValueError: pass if end_date: try: kwargs['end_date'] = datetime.fromisoformat(end_date) except ValueError: pass pagination = AuditService.get_logs(**kwargs) return jsonify({ 'logs': [log.to_dict() for log in pagination.items], 'pagination': { 'page': pagination.page, 'per_page': pagination.per_page, 'total': pagination.total, 'pages': pagination.pages, 'has_next': pagination.has_next, 'has_prev': pagination.has_prev } }), 200