Files
ServerKit/backend/app/api/templates.py
T
Juan Denis 85831b9b54 Add metric filter toggles and improve validation UX
Introduces clickable filter buttons to toggle CPU, memory, and disk metrics in MetricsGraph, replacing the static legend. Refines app name validation in both backend and frontend for template installation, allowing 2+ character names and improving error handling. WorkflowBuilder now handles missing workflow data gracefully and supports a fullscreen layout. Updates related styles for new filter legend and fullscreen workflow view.
2026-01-19 13:43:46 -05:00

419 lines
14 KiB
Python

"""
Template API Endpoints
Provides REST endpoints for:
- Listing and browsing templates
- Template installation
- App updates from templates
- Template repository management
"""
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from app.models import User, Application
from app.services.template_service import TemplateService
templates_bp = Blueprint('templates', __name__)
def admin_required(fn):
"""Decorator to require admin role."""
from functools import wraps
@wraps(fn)
def wrapper(*args, **kwargs):
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
if not user or user.role != 'admin':
return jsonify({'error': 'Admin access required'}), 403
return fn(*args, **kwargs)
return wrapper
# ==================== TEMPLATE BROWSING ====================
@templates_bp.route('/', methods=['GET'])
@jwt_required()
def list_templates():
"""List all available templates."""
category = request.args.get('category')
search = request.args.get('search')
templates = TemplateService.list_all_templates(category=category, search=search)
return jsonify({
'templates': templates,
'count': len(templates)
}), 200
@templates_bp.route('/categories', methods=['GET'])
@jwt_required()
def get_categories():
"""Get all template categories."""
categories = TemplateService.get_categories()
return jsonify({'categories': categories}), 200
@templates_bp.route('/<template_id>', methods=['GET'])
@jwt_required()
def get_template(template_id):
"""Get detailed template information."""
result = TemplateService.get_template(template_id)
if not result.get('success'):
return jsonify(result), 404
template = result['template']
# Add processed variable info - handle both list and dict formats
# Auto-generated types that users don't need to fill in
auto_generated_types = ['port', 'password', 'random', 'uuid']
variables = []
raw_vars = template.get('variables', [])
if isinstance(raw_vars, list):
# New list format: [{name: 'PORT', type: 'port', ...}, ...]
for var in raw_vars:
if isinstance(var, dict):
var_type = var.get('type', 'string')
variables.append({
'name': var.get('name', ''),
'description': var.get('description', ''),
'type': var_type,
'default': var.get('default', ''),
'required': var.get('required', False),
'options': var.get('options', None),
'auto_generated': var_type in auto_generated_types,
'hidden': var_type == 'port' # Ports are hidden from users
})
elif isinstance(raw_vars, dict):
# Old dict format: {PORT: {type: 'port', ...}, ...}
for var_name, var_config in raw_vars.items():
if isinstance(var_config, dict):
var_type = var_config.get('type', 'string')
variables.append({
'name': var_name,
'description': var_config.get('description', ''),
'type': var_type,
'default': var_config.get('default', ''),
'required': var_config.get('required', False),
'options': var_config.get('options', None),
'auto_generated': var_type in auto_generated_types,
'hidden': var_type == 'port'
})
return jsonify({
'template': {
'id': template_id,
'name': template.get('name'),
'version': template.get('version'),
'description': template.get('description'),
'icon': template.get('icon'),
'categories': template.get('categories', []),
'source': template.get('source'),
'documentation': template.get('documentation'),
'website': template.get('website'),
'variables': variables,
'has_compose': 'compose' in template,
'has_dockerfile': 'dockerfile' in template,
'scripts': list(template.get('scripts', {}).keys()),
'requirements': template.get('requirements', {}),
'ports': template.get('ports', [])
}
}), 200
# ==================== TEMPLATE INSTALLATION ====================
@templates_bp.route('/<template_id>/install', methods=['POST'])
@jwt_required()
@admin_required
def install_template(template_id):
"""Install a template as a new application."""
try:
current_user_id = get_jwt_identity()
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
app_name = data.get('app_name')
if not app_name:
return jsonify({'error': 'app_name is required'}), 400
# Validate app name
import re
if not re.match(r'^[a-z0-9][a-z0-9-]*[a-z0-9]$', app_name) or len(app_name) < 3:
return jsonify({
'error': 'App name must be lowercase alphanumeric with hyphens, at least 3 characters'
}), 400
user_variables = data.get('variables', {})
result = TemplateService.install_template(
template_id=template_id,
app_name=app_name,
user_variables=user_variables,
user_id=current_user_id
)
return jsonify(result), 201 if result.get('success') else 400
except Exception as e:
import traceback
error_trace = traceback.format_exc()
print(f"Template install error: {error_trace}")
return jsonify({'error': str(e), 'trace': error_trace}), 500
@templates_bp.route('/validate-install', methods=['POST'])
@jwt_required()
def validate_installation():
"""Validate template installation parameters before installing."""
try:
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
template_id = data.get('template_id')
app_name = data.get('app_name')
user_variables = data.get('variables', {})
errors = []
# Validate app name
import re
if not app_name:
errors.append('App name is required')
elif len(app_name) < 2:
errors.append('App name must be at least 2 characters')
elif not re.match(r'^[a-z0-9]([a-z0-9-]*[a-z0-9])?$', app_name):
errors.append('App name must be lowercase alphanumeric with optional hyphens (not at start/end)')
# Check if app name is taken
if app_name:
existing = Application.query.filter_by(name=app_name).first()
if existing:
errors.append(f'An application named "{app_name}" already exists')
# Validate template exists and check required variables
if template_id:
result = TemplateService.get_template(template_id)
if not result.get('success'):
errors.append('Template not found')
else:
template = result['template']
raw_vars = template.get('variables', [])
# Handle both list and dict formats
if isinstance(raw_vars, list):
for var in raw_vars:
if isinstance(var, dict) and var.get('required', False):
var_name = var.get('name', '')
if var_name and var_name not in user_variables:
errors.append(f'Required variable "{var_name}" is not provided')
elif isinstance(raw_vars, dict):
for var_name, var_config in raw_vars.items():
if var_config.get('required', False) and var_name not in user_variables:
errors.append(f'Required variable "{var_name}" is not provided')
else:
errors.append('Template ID is required')
if errors:
return jsonify({'valid': False, 'errors': errors}), 400
return jsonify({'valid': True}), 200
except Exception as e:
import traceback
error_trace = traceback.format_exc()
print(f"Validate install error: {error_trace}")
return jsonify({'error': str(e), 'trace': error_trace}), 500
@templates_bp.route('/test-db-connection', methods=['POST'])
@jwt_required()
def test_db_connection():
"""Test database connection before template installation.
Used to validate external database connections for templates
like wordpress-external-db before attempting installation.
Request body:
host: Database host (required)
port: Database port (default: 3306)
user: Database username (required)
password: Database password (required)
database: Database name (required)
Returns:
200: Connection successful
400: Connection failed or missing parameters
"""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
required = ['host', 'user', 'password', 'database']
for field in required:
if field not in data:
return jsonify({'error': f'Missing required field: {field}'}), 400
result = TemplateService.validate_mysql_connection(
host=data.get('host'),
port=data.get('port', 3306),
user=data.get('user'),
password=data.get('password'),
database=data.get('database')
)
if result.get('success'):
response = {'success': True, 'message': 'Connection successful'}
if result.get('warning'):
response['warning'] = result.get('warning')
return jsonify(response), 200
else:
return jsonify({
'success': False,
'error': result.get('error')
}), 400
# ==================== APP UPDATES ====================
@templates_bp.route('/apps/<int:app_id>/check-update', methods=['GET'])
@jwt_required()
def check_app_update(app_id):
"""Check if an installed app has updates available."""
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
app = Application.query.get(app_id)
if not app:
return jsonify({'error': 'Application not found'}), 404
if user.role != 'admin' and app.user_id != current_user_id:
return jsonify({'error': 'Access denied'}), 403
result = TemplateService.check_updates(app_id)
return jsonify(result), 200 if result.get('success') else 400
@templates_bp.route('/apps/<int:app_id>/update', methods=['POST'])
@jwt_required()
@admin_required
def update_app(app_id):
"""Update an app to the latest template version."""
current_user_id = get_jwt_identity()
app = Application.query.get(app_id)
if not app:
return jsonify({'error': 'Application not found'}), 404
result = TemplateService.update_app(app_id, user_id=current_user_id)
return jsonify(result), 200 if result.get('success') else 400
@templates_bp.route('/apps/<int:app_id>/template-info', methods=['GET'])
@jwt_required()
def get_app_template_info(app_id):
"""Get template installation info for an app."""
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
app = Application.query.get(app_id)
if not app:
return jsonify({'error': 'Application not found'}), 404
if user.role != 'admin' and app.user_id != current_user_id:
return jsonify({'error': 'Access denied'}), 403
info = TemplateService.get_installed_info(app_id)
if info:
return jsonify({'installed_from_template': True, 'info': info}), 200
return jsonify({'installed_from_template': False}), 200
# ==================== REPOSITORY MANAGEMENT ====================
@templates_bp.route('/repos', methods=['GET'])
@jwt_required()
def list_repositories():
"""List configured template repositories."""
repos = TemplateService.list_repositories()
return jsonify({'repositories': repos}), 200
@templates_bp.route('/repos', methods=['POST'])
@jwt_required()
@admin_required
def add_repository():
"""Add a template repository."""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
name = data.get('name')
url = data.get('url')
if not name or not url:
return jsonify({'error': 'name and url are required'}), 400
result = TemplateService.add_repository(name, url)
return jsonify(result), 201 if result.get('success') else 400
@templates_bp.route('/repos', methods=['DELETE'])
@jwt_required()
@admin_required
def remove_repository():
"""Remove a template repository."""
data = request.get_json()
url = data.get('url') if data else None
if not url:
return jsonify({'error': 'url is required'}), 400
result = TemplateService.remove_repository(url)
return jsonify(result), 200 if result.get('success') else 400
@templates_bp.route('/sync', methods=['POST'])
@jwt_required()
@admin_required
def sync_templates():
"""Sync templates from all repositories."""
result = TemplateService.sync_templates()
return jsonify(result), 200
# ==================== LOCAL TEMPLATE MANAGEMENT ====================
@templates_bp.route('/local', methods=['POST'])
@jwt_required()
@admin_required
def create_local_template():
"""Create a local template."""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
result = TemplateService.create_local_template(data)
return jsonify(result), 201 if result.get('success') else 400
@templates_bp.route('/local/<template_id>', methods=['DELETE'])
@jwt_required()
@admin_required
def delete_local_template(template_id):
"""Delete a local template."""
result = TemplateService.delete_local_template(template_id)
return jsonify(result), 200 if result.get('success') else 404