"""Admin API endpoints for Flare ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Provides authentication, project, version, and API management endpoints. """ import os import sys import hashlib import json import jwt from datetime import datetime, timedelta, timezone from typing import Optional, List, Dict, Any from pathlib import Path import threading import time import bcrypt from fastapi import APIRouter, HTTPException, Depends, Body, Query from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from pydantic import BaseModel, Field from utils import log # ===================== JWT Config ===================== def get_jwt_config(): """Get JWT configuration based on environment""" work_mode = os.getenv("WORK_MODE", "on-premise") if work_mode == "hfcloud": # Cloud mode - use secrets from environment jwt_secret = os.getenv("JWT_SECRET") if not jwt_secret: log("⚠️ WARNING: JWT_SECRET not found in environment, using fallback") jwt_secret = "flare-admin-secret-key-change-in-production" # Fallback else: # On-premise mode - use .env file from dotenv import load_dotenv load_dotenv() jwt_secret = os.getenv("JWT_SECRET", "flare-admin-secret-key-change-in-production") return { "secret": jwt_secret, "algorithm": os.getenv("JWT_ALGORITHM", "HS256"), "expiration_hours": int(os.getenv("JWT_EXPIRATION_HOURS", "24")) } # ===================== Constants & Config ===================== router = APIRouter(prefix="/api") security = HTTPBearer() # ===================== Models ===================== class LoginRequest(BaseModel): username: str password: str class LoginResponse(BaseModel): token: str username: str class ChangePasswordRequest(BaseModel): current_password: str new_password: str class EnvironmentUpdate(BaseModel): work_mode: str cloud_token: Optional[str] = None spark_endpoint: str internal_prompt: Optional[str] = None class ProjectCreate(BaseModel): name: str caption: Optional[str] = "" icon: Optional[str] = "folder" description: Optional[str] = "" default_language: str = "Turkish" supported_languages: List[str] = Field(default_factory=lambda: ["tr"]) timezone: str = "Europe/Istanbul" region: str = "tr-TR" class ProjectUpdate(BaseModel): caption: str icon: Optional[str] = "folder" description: Optional[str] = "" default_language: str = "Turkish" supported_languages: List[str] = Field(default_factory=lambda: ["tr"]) timezone: str = "Europe/Istanbul" region: str = "tr-TR" last_update_date: str class VersionCreate(BaseModel): caption: str source_version_id: int | None = None # None → boş template class IntentModel(BaseModel): name: str caption: Optional[str] = "" locale: str = "tr-TR" detection_prompt: str examples: List[str] = [] parameters: List[Dict[str, Any]] = [] action: str fallback_timeout_prompt: Optional[str] = None fallback_error_prompt: Optional[str] = None class VersionUpdate(BaseModel): caption: str general_prompt: str llm: Dict[str, Any] intents: List[IntentModel] last_update_date: str class APICreate(BaseModel): name: str url: str method: str = "POST" headers: Dict[str, str] = {} body_template: Dict[str, Any] = {} timeout_seconds: int = 10 retry: Dict[str, Any] = Field(default_factory=lambda: {"retry_count": 3, "backoff_seconds": 2, "strategy": "static"}) proxy: Optional[str] = None auth: Optional[Dict[str, Any]] = None response_prompt: Optional[str] = None response_mappings: List[Dict[str, Any]] = [] # Yeni alan class APIUpdate(BaseModel): url: str method: str headers: Dict[str, str] body_template: Dict[str, Any] timeout_seconds: int retry: Dict[str, Any] proxy: Optional[str] auth: Optional[Dict[str, Any]] response_prompt: Optional[str] response_mappings: List[Dict[str, Any]] = [] # Yeni alan last_update_date: str class TestRequest(BaseModel): test_type: str # "all", "ui", "backend", "integration", "spark" # ===================== Helpers ===================== def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str: """Verify JWT token and return username""" jwt_config = get_jwt_config() try: payload = jwt.decode( credentials.credentials, jwt_config["secret"], algorithms=[jwt_config["algorithm"]] ) username = payload.get("sub") if username is None: raise HTTPException(status_code=401, detail="Invalid token") return username except jwt.ExpiredSignatureError: raise HTTPException(status_code=401, detail="Token expired") except jwt.InvalidTokenError: # Bu genel JWT hatalarını yakalar raise HTTPException(status_code=401, detail="Invalid token") def hash_password(password: str, salt: str = None) -> tuple[str, str]: """Hash password with bcrypt. Returns (hashed_password, salt)""" if salt is None: salt = bcrypt.gensalt().decode('utf-8') # Ensure salt is bytes salt_bytes = salt.encode('utf-8') if isinstance(salt, str) else salt # Hash the password hashed = bcrypt.hashpw(password.encode('utf-8'), salt_bytes) return hashed.decode('utf-8'), salt def verify_password(password: str, hashed: str, salt: str = None) -> bool: """Verify password against hash""" try: # For bcrypt hashes (they contain salt) if hashed.startswith('$2b$') or hashed.startswith('$2a$'): return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8')) # For legacy SHA256 hashes return hashlib.sha256(password.encode()).hexdigest() == hashed except Exception as e: log(f"Password verification error: {e}") return False def load_config(): """Load service_config.jsonc""" config_path = Path("service_config.jsonc") if not config_path.exists(): return {"config": {}, "projects": [], "apis": []} with open(config_path, 'r', encoding='utf-8') as f: content = f.read() # Remove comments for JSON parsing lines = [] for line in content.split('\n'): stripped = line.strip() if not stripped.startswith('//'): lines.append(line) clean_content = '\n'.join(lines) return json.loads(clean_content) def save_config(config: dict): """Save config back to service_config.jsonc""" with open("service_config.jsonc", 'w', encoding='utf-8') as f: json.dump(config, f, indent=2, ensure_ascii=False) def get_timestamp(): """Get current timestamp in ISO format with milliseconds""" return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" def add_activity_log(config: dict, username: str, action: str, entity_type: str, entity_id: Any, entity_name: str, details: str = ""): """Add activity log entry""" if "activity_log" not in config: config["activity_log"] = [] # Get next ID log_id = max([log.get("id", 0) for log in config["activity_log"]], default=0) + 1 config["activity_log"].append({ "id": log_id, "timestamp": get_timestamp(), "user": username, "action": action, "entity_type": entity_type, "entity_id": entity_id, "entity_name": entity_name, "details": details }) # Keep only last 1000 entries if len(config["activity_log"]) > 1000: config["activity_log"] = config["activity_log"][-1000:] async def _spark_project_control(action: str, project_name: str, username: str): """Common function for Spark project control""" if not project_name: raise HTTPException(status_code=400, detail="project_name is required") config = load_config() spark_endpoint = config.get("config", {}).get("spark_endpoint", "").rstrip("/") spark_token = _get_spark_token() if not spark_endpoint: raise HTTPException(status_code=400, detail="Spark endpoint not configured") if not spark_token: raise HTTPException(status_code=400, detail="Spark token not configured") headers = { "Authorization": f"Bearer {spark_token}", "Content-Type": "application/json" } try: async with httpx.AsyncClient(timeout=30) as client: if action == "delete": response = await client.delete( f"{spark_endpoint}/project/delete", json={"project_name": project_name}, headers=headers ) else: response = await client.post( f"{spark_endpoint}/project/{action}", json={"project_name": project_name}, headers=headers ) response.raise_for_status() return response.json() except httpx.HTTPStatusError as e: error_detail = e.response.json() if e.response.text else {"error": str(e)} raise HTTPException(status_code=e.response.status_code, detail=error_detail) except Exception as e: log(f"❌ Spark {action} failed: {e}") raise HTTPException(status_code=500, detail=str(e)) def _get_spark_token() -> Optional[str]: """Get Spark token based on work_mode""" config = load_config() work_mode = config.get("config", {}).get("work_mode", "on-premise") if work_mode in ("hfcloud", "cloud"): # Cloud mode - use HuggingFace Secrets token = os.getenv("SPARK_TOKEN") if not token: log("❌ SPARK_TOKEN not found in HuggingFace Secrets!") return token else: # On-premise mode - use .env file from dotenv import load_dotenv load_dotenv() return os.getenv("SPARK_TOKEN") async def notify_spark_manual(project: dict, version: dict, global_config: dict): """Manual Spark notification (similar to notify_spark but returns response)""" import httpx spark_endpoint = global_config.get("spark_endpoint", "").rstrip("/") spark_token = _get_spark_token() if not spark_endpoint: raise ValueError("Spark endpoint not configured") if not spark_token: raise ValueError("Spark token not configured") work_mode = global_config.get("work_mode", "hfcloud") cloud_token = global_config.get("cloud_token", "") # Decrypt token if needed if cloud_token and cloud_token.startswith("enc:"): from encryption_utils import decrypt cloud_token = decrypt(cloud_token) payload = { "work_mode": work_mode, "cloud_token": cloud_token, "project_name": project["name"], "project_version": version["id"], "repo_id": version["llm"]["repo_id"], "generation_config": version["llm"]["generation_config"], "use_fine_tune": version["llm"]["use_fine_tune"], "fine_tune_zip": version["llm"]["fine_tune_zip"] if version["llm"]["use_fine_tune"] else None } headers = { "Authorization": f"Bearer {spark_token}", "Content-Type": "application/json" } log(f"🚀 Manually notifying Spark about {project['name']} v{version['id']}") async with httpx.AsyncClient(timeout=30) as client: response = await client.post(spark_endpoint + "/startup", json=payload, headers=headers) response.raise_for_status() result = response.json() log(f"✅ Spark manual notification successful: {result.get('message', 'OK')}") return result # ===================== Auth Endpoints ===================== @router.post("/login", response_model=LoginResponse) async def login(request: LoginRequest): """Authenticate user and return JWT token""" config = load_config() users = config.get("config", {}).get("users", []) # Find user user = next((u for u in users if u["username"] == request.username), None) if not user: raise HTTPException(status_code=401, detail="Invalid credentials") # Verify password if not verify_password(request.password, user["password_hash"], user.get("salt")): raise HTTPException(status_code=401, detail="Invalid credentials") # Generate JWT token jwt_config = get_jwt_config() payload = { "sub": request.username, "exp": datetime.now(timezone.utc) + timedelta(hours=jwt_config["expiration_hours"]) } token = jwt.encode(payload, jwt_config["secret"], algorithm=jwt_config["algorithm"]) log(f"✅ User '{request.username}' logged in") return LoginResponse(token=token, username=request.username) @router.post("/change-password") async def change_password( request: ChangePasswordRequest, username: str = Depends(verify_token) ): """Change user password""" config = load_config() users = config.get("config", {}).get("users", []) # Find user user = next((u for u in users if u["username"] == username), None) if not user: raise HTTPException(status_code=404, detail="User not found") # Verify current password if not verify_password(request.current_password, user["password_hash"], user.get("salt")): raise HTTPException(status_code=401, detail="Current password is incorrect") # Hash new password new_hash, new_salt = hash_password(request.new_password) user["password_hash"] = new_hash user["salt"] = new_salt # Save config save_config(config) log(f"✅ Password changed for user '{username}'") return {"success": True} # ===================== Environment Endpoints ===================== @router.get("/environment") async def get_environment(username: str = Depends(verify_token)): """Get environment configuration""" config = load_config() env_config = config.get("config", {}) return { "work_mode": env_config.get("work_mode", "on-premise"), "cloud_token": env_config.get("cloud_token", ""), "spark_endpoint": env_config.get("spark_endpoint", "http://localhost:7861"), "internal_prompt": env_config.get("internal_prompt", "") } @router.put("/environment") async def update_environment( update: EnvironmentUpdate, username: str = Depends(verify_token) ): """Update environment configuration""" config = load_config() # Update config config["config"]["work_mode"] = update.work_mode config["config"]["cloud_token"] = update.cloud_token or "" config["config"]["spark_endpoint"] = update.spark_endpoint config["config"]["internal_prompt"] = update.internal_prompt or "" # Yeni alan config["config"]["last_update_date"] = get_timestamp() config["config"]["last_update_user"] = username # Add activity log add_activity_log(config, username, "UPDATE_ENVIRONMENT", "config", "environment", "environment", f"Changed to {update.work_mode}") # Save save_config(config) log(f"✅ Environment updated to {update.work_mode} by {username}") return {"success": True} # ===================== Project Endpoints ===================== @router.get("/projects/names") def list_enabled_projects(): """Get list of enabled project names for chat""" cfg = load_config() projects = cfg.get("projects", []) return [p["name"] for p in projects if p.get("enabled", False) and not p.get("deleted", False)] @router.get("/projects") async def list_projects( include_deleted: bool = False, username: str = Depends(verify_token) ): """List all projects""" config = load_config() projects = config.get("projects", []) # Filter deleted if needed if not include_deleted: projects = [p for p in projects if not p.get("deleted", False)] return projects @router.get("/projects/{project_id}") async def get_project( project_id: int, username: str = Depends(verify_token) ): """Get single project by ID""" try: config = load_config() projects = config.get("projects", []) project = next((p for p in projects if p.get("id") == project_id), None) if not project or project.get("deleted", False): raise HTTPException(status_code=404, detail="Project not found") return project except HTTPException: raise except Exception as e: log(f"Failed to get project: {e}") raise HTTPException(status_code=500, detail=str(e)) # POST /api/projects @router.post("/projects") async def create_project( project_data: ProjectCreate, username: str = Depends(verify_token) ): cfg = load_config() # Yeni proje ID'si project_id = cfg["config"].get("project_id_counter", 0) + 1 cfg["config"]["project_id_counter"] = project_id # Proje gövdesi - yeni alanlarla new_project = { "id": project_id, "name": project_data.name, "caption": project_data.caption, "icon": project_data.icon, "description": project_data.description, "default_language": project_data.default_language, "supported_languages": project_data.supported_languages, "timezone": project_data.timezone, "region": project_data.region, "enabled": False, "deleted": False, "created_date": datetime.utcnow().isoformat(), "created_by": username, "last_update_date": datetime.utcnow().isoformat(), "last_update_user": username, # Versiyon sayaçları "version_id_counter": 1, # İlk versiyon "versions": [{ "id": 1, "no": 1, "caption": "Version 1", "description": "Initial version", "published": False, "default_api": "", "llm": { "repo_id": "", "generation_config": { "max_new_tokens": 512, "temperature": 0.7, "top_p": 0.95, "top_k": 50, "repetition_penalty": 1.1 }, "use_fine_tune": False, "fine_tune_zip": "" }, "intents": [], "parameters": [], "created_date": datetime.utcnow().isoformat(), "created_by": username, "last_update_date": datetime.utcnow().isoformat(), "last_update_user": username, "deleted": False, "publish_date": None, "published_by": None }] } cfg.setdefault("projects", []).append(new_project) save_config(cfg) add_activity_log(cfg, username, "CREATE_PROJECT", "project", project_id, new_project["name"]) save_config(cfg) return new_project @router.put("/projects/{project_id}") async def update_project( project_id: int, update: ProjectUpdate, username: str = Depends(verify_token) ): """Update project""" config = load_config() # Find project project = next((p for p in config.get("projects", []) if p["id"] == project_id), None) if not project: raise HTTPException(status_code=404, detail="Project not found") # Check race condition if project.get("last_update_date") != update.last_update_date: raise HTTPException(status_code=409, detail="Project was modified by another user") # Update - yeni alanlarla project["caption"] = update.caption project["icon"] = update.icon project["description"] = update.description project["default_language"] = update.default_language project["supported_languages"] = update.supported_languages project["timezone"] = update.timezone project["region"] = update.region project["last_update_date"] = get_timestamp() project["last_update_user"] = username # Add activity log add_activity_log(config, username, "UPDATE_PROJECT", "project", project_id, project["name"]) # Save save_config(config) log(f"✅ Project '{project['name']}' updated by {username}") return project @router.delete("/projects/{project_id}") async def delete_project(project_id: int, username: str = Depends(verify_token)): """Delete project (soft delete)""" config = load_config() # Find project project = next((p for p in config.get("projects", []) if p["id"] == project_id), None) if not project: raise HTTPException(status_code=404, detail="Project not found") # Soft delete project["deleted"] = True project["last_update_date"] = get_timestamp() project["last_update_user"] = username # Add activity log add_activity_log(config, username, "DELETE_PROJECT", "project", project_id, project["name"]) # Save save_config(config) log(f"✅ Project '{project['name']}' deleted by {username}") return {"success": True} @router.patch("/projects/{project_id}/toggle") async def toggle_project(project_id: int, username: str = Depends(verify_token)): """Toggle project enabled status""" config = load_config() # Find project project = next((p for p in config.get("projects", []) if p["id"] == project_id), None) if not project: raise HTTPException(status_code=404, detail="Project not found") # Toggle project["enabled"] = not project.get("enabled", False) project["last_update_date"] = get_timestamp() project["last_update_user"] = username # Add activity log action = "ENABLE_PROJECT" if project["enabled"] else "DISABLE_PROJECT" add_activity_log(config, username, action, "project", project_id, project["name"]) # Save save_config(config) log(f"✅ Project '{project['name']}' {'enabled' if project['enabled'] else 'disabled'} by {username}") return {"enabled": project["enabled"]} # ===================== Version Endpoints ===================== @router.get("/projects/{project_id}/versions") async def list_versions( project_id: int, include_deleted: bool = False, username: str = Depends(verify_token) ): """List project versions""" config = load_config() # Find project project = next((p for p in config.get("projects", []) if p["id"] == project_id), None) if not project: raise HTTPException(status_code=404, detail="Project not found") versions = project.get("versions", []) # Filter deleted if needed if not include_deleted: versions = [v for v in versions if not v.get("deleted", False)] return versions @router.post("/projects/{project_id}/versions") async def create_version( project_id: int, version_data: VersionCreate, username: str = Depends(verify_token) ): """Create new version""" config = load_config() # Find project project = next((p for p in config.get("projects", []) if p["id"] == project_id), None) if not project: raise HTTPException(status_code=404, detail="Project not found") # Get next version ID version_id = project.get("version_id_counter", 0) + 1 project["version_id_counter"] = version_id # Get next version number existing_versions = [v for v in project.get("versions", []) if not v.get("deleted", False)] version_no = max([v.get("no", 0) for v in existing_versions], default=0) + 1 # Create base version new_version = { "id": version_id, "no": version_no, "caption": version_data.caption, "description": f"Version {version_no}", "published": False, "deleted": False, "created_date": get_timestamp(), "created_by": username, "last_update_date": get_timestamp(), "last_update_user": username, "publish_date": None, "published_by": None } # Copy from source version if specified if version_data.source_version_id: source_version = next( (v for v in project.get("versions", []) if v["id"] == version_data.source_version_id), None ) if source_version: # Copy configuration from source new_version.update({ "general_prompt": source_version.get("general_prompt", ""), "default_api": source_version.get("default_api", ""), "llm": source_version.get("llm", {}).copy(), "intents": [intent.copy() for intent in source_version.get("intents", [])], "parameters": [param.copy() for param in source_version.get("parameters", [])] }) else: # Empty template new_version.update({ "general_prompt": "", "default_api": "", "llm": { "repo_id": "", "generation_config": { "max_new_tokens": 512, "temperature": 0.7, "top_p": 0.95, "top_k": 50, "repetition_penalty": 1.1 }, "use_fine_tune": False, "fine_tune_zip": "" }, "intents": [], "parameters": [] }) # Add to project if "versions" not in project: project["versions"] = [] project["versions"].append(new_version) # Update project timestamp project["last_update_date"] = get_timestamp() project["last_update_user"] = username # Add activity log add_activity_log(config, username, "CREATE_VERSION", "version", version_id, f"{project['name']} v{version_no}") # Save save_config(config) log(f"✅ Version {version_no} created for project '{project['name']}' by {username}") return new_version @router.put("/projects/{project_id}/versions/{version_id}") async def update_version( project_id: int, version_id: int, update: VersionUpdate, username: str = Depends(verify_token) ): """Update version""" config = load_config() # Find project and version project = next((p for p in config.get("projects", []) if p["id"] == project_id), None) if not project: raise HTTPException(status_code=404, detail="Project not found") version = next((v for v in project.get("versions", []) if v["id"] == version_id), None) if not version: raise HTTPException(status_code=404, detail="Version not found") # Check race condition if version.get("last_update_date") != update.last_update_date: raise HTTPException(status_code=409, detail="Version was modified by another user") # Cannot update published version if version.get("published", False): raise HTTPException(status_code=400, detail="Cannot modify published version") # Update version version["caption"] = update.caption version["general_prompt"] = update.general_prompt version["llm"] = update.llm version["intents"] = [intent.dict() for intent in update.intents] version["last_update_date"] = get_timestamp() version["last_update_user"] = username # Update project timestamp project["last_update_date"] = get_timestamp() project["last_update_user"] = username # Add activity log add_activity_log(config, username, "UPDATE_VERSION", "version", version_id, f"{project['name']} v{version['no']}") # Save save_config(config) log(f"✅ Version {version['no']} updated for project '{project['name']}' by {username}") return version @router.post("/projects/{project_id}/versions/{version_id}/publish") async def publish_version( project_id: int, version_id: int, username: str = Depends(verify_token) ): """Publish version""" config = load_config() # Find project and version project = next((p for p in config.get("projects", []) if p["id"] == project_id), None) if not project: raise HTTPException(status_code=404, detail="Project not found") version = next((v for v in project.get("versions", []) if v["id"] == version_id), None) if not version: raise HTTPException(status_code=404, detail="Version not found") # Unpublish all other versions for v in project.get("versions", []): if v["id"] != version_id: v["published"] = False # Publish this version version["published"] = True version["publish_date"] = get_timestamp() version["published_by"] = username version["last_update_date"] = get_timestamp() version["last_update_user"] = username # Update project timestamp project["last_update_date"] = get_timestamp() project["last_update_user"] = username # Add activity log add_activity_log(config, username, "PUBLISH_VERSION", "version", version_id, f"{project['name']} v{version['no']}") # Save save_config(config) log(f"✅ Version {version_id} published for project '{project['name']}' by {username}") # Notify Spark if project is enabled if project.get("enabled", False): try: await notify_spark_manual(project, version, config.get("config", {})) except Exception as e: log(f"⚠️ Failed to notify Spark: {e}") # Don't fail the publish return {"success": True} @router.delete("/projects/{project_id}/versions/{version_id}") async def delete_version( project_id: int, version_id: int, username: str = Depends(verify_token) ): """Delete version (soft delete)""" config = load_config() # Find project and version project = next((p for p in config.get("projects", []) if p["id"] == project_id), None) if not project: raise HTTPException(status_code=404, detail="Project not found") version = next((v for v in project.get("versions", []) if v["id"] == version_id), None) if not version: raise HTTPException(status_code=404, detail="Version not found") # Cannot delete published version if version.get("published", False): raise HTTPException(status_code=400, detail="Cannot delete published version") # Soft delete version["deleted"] = True version["last_update_date"] = get_timestamp() version["last_update_user"] = username project["last_update_date"] = get_timestamp() project["last_update_user"] = username # Add activity log add_activity_log(config, username, "DELETE_VERSION", "version", version_id, f"{project['name']} v{version_id}") # Save save_config(config) log(f"✅ Version {version_id} deleted for project '{project['name']}' by {username}") return {"success": True} # ===================== API Endpoints ===================== @router.get("/apis") async def list_apis( include_deleted: bool = False, username: str = Depends(verify_token) ): """List all APIs""" config = load_config() apis = config.get("apis", []) # Filter deleted if needed if not include_deleted: apis = [a for a in apis if not a.get("deleted", False)] return apis @router.post("/apis") async def create_api(api: APICreate, username: str = Depends(verify_token)): """Create new API""" config = load_config() # Check duplicate name existing = [a for a in config.get("apis", []) if a["name"] == api.name] if existing: raise HTTPException(status_code=400, detail="API name already exists") # Create API new_api = api.dict() new_api["deleted"] = False new_api["created_date"] = get_timestamp() new_api["created_by"] = username new_api["last_update_date"] = get_timestamp() new_api["last_update_user"] = username if "apis" not in config: config["apis"] = [] config["apis"].append(new_api) # Add activity log add_activity_log(config, username, "CREATE_API", "api", api.name, api.name) # Save save_config(config) log(f"✅ API '{api.name}' created by {username}") return new_api @router.put("/apis/{api_name}") async def update_api( api_name: str, update: APIUpdate, username: str = Depends(verify_token) ): """Update API""" config = load_config() # Find API api = next((a for a in config.get("apis", []) if a["name"] == api_name), None) if not api: raise HTTPException(status_code=404, detail="API not found") # Check race condition if api.get("last_update_date") != update.last_update_date: raise HTTPException(status_code=409, detail="API was modified by another user") # Check if API is in use for project in config.get("projects", []): for version in project.get("versions", []): for intent in version.get("intents", []): if intent.get("action") == api_name and version.get("published", False): raise HTTPException(status_code=400, detail=f"API is used in published version of project '{project['name']}'") # Update update_dict = update.dict() del update_dict["last_update_date"] api.update(update_dict) api["last_update_date"] = get_timestamp() api["last_update_user"] = username # Add activity log add_activity_log(config, username, "UPDATE_API", "api", api_name, api_name) # Save save_config(config) log(f"✅ API '{api_name}' updated by {username}") return api @router.delete("/apis/{api_name}") async def delete_api(api_name: str, username: str = Depends(verify_token)): """Delete API (soft delete)""" config = load_config() # Find API api = next((a for a in config.get("apis", []) if a["name"] == api_name), None) if not api: raise HTTPException(status_code=404, detail="API not found") # Check if API is in use for project in config.get("projects", []): for version in project.get("versions", []): for intent in version.get("intents", []): if intent.get("action") == api_name: raise HTTPException(status_code=400, detail=f"API is used in project '{project['name']}'") # Soft delete api["deleted"] = True api["last_update_date"] = get_timestamp() api["last_update_user"] = username # Add activity log add_activity_log(config, username, "DELETE_API", "api", api_name, api_name) # Save save_config(config) log(f"✅ API '{api_name}' deleted by {username}") return {"success": True} # ===================== Spark Integration Endpoints ===================== @router.post("/spark/startup") async def spark_startup(request: dict = Body(...), username: str = Depends(verify_token)): """Trigger Spark startup for a project""" project_name = request.get("project_name") if not project_name: raise HTTPException(status_code=400, detail="project_name is required") config = load_config() # Find project project = next((p for p in config.get("projects", []) if p["name"] == project_name), None) if not project: raise HTTPException(status_code=404, detail=f"Project not found: {project_name}") # Find published version version = next((v for v in project.get("versions", []) if v.get("published", False)), None) if not version: raise HTTPException(status_code=400, detail=f"No published version found for project: {project_name}") # Notify Spark try: result = await notify_spark_manual(project, version, config.get("config", {})) return {"message": result.get("message", "Spark startup initiated")} except Exception as e: log(f"❌ Spark startup failed: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.get("/spark/projects") async def spark_get_projects(username: str = Depends(verify_token)): """Get Spark project list""" config = load_config() spark_endpoint = config.get("config", {}).get("spark_endpoint", "").rstrip("/") spark_token = _get_spark_token() if not spark_endpoint: raise HTTPException(status_code=400, detail="Spark endpoint not configured") if not spark_token: raise HTTPException(status_code=400, detail="Spark token not configured") headers = { "Authorization": f"Bearer {spark_token}" } try: async with httpx.AsyncClient(timeout=30) as client: response = await client.get(spark_endpoint + "/project/list", headers=headers) response.raise_for_status() return response.json() except Exception as e: log(f"❌ Failed to get Spark projects: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.post("/spark/project/enable") async def spark_enable_project(request: dict = Body(...), username: str = Depends(verify_token)): """Enable project in Spark""" return await _spark_project_control("enable", request.get("project_name"), username) @router.post("/spark/project/disable") async def spark_disable_project(request: dict = Body(...), username: str = Depends(verify_token)): """Disable project in Spark""" return await _spark_project_control("disable", request.get("project_name"), username) @router.delete("/spark/project/{project_name}") async def spark_delete_project(project_name: str, username: str = Depends(verify_token)): """Delete project from Spark""" return await _spark_project_control("delete", project_name, username) # ===================== Test Endpoints ===================== @router.post("/apis/test") async def test_api(api: APICreate, username: str = Depends(verify_token)): """Test API endpoint""" import requests try: # Prepare request headers = api.headers.copy() # Add sample auth token for testing if api.auth and api.auth.get("enabled"): headers["Authorization"] = "Bearer test_token_12345" # Make request response = requests.request( method=api.method, url=api.url, headers=headers, json=api.body_template if api.method in ["POST", "PUT", "PATCH"] else None, params=api.body_template if api.method == "GET" else None, timeout=api.timeout_seconds, proxies={"http": api.proxy, "https": api.proxy} if api.proxy else None ) return { "success": True, "status_code": response.status_code, "response": response.text[:500], # First 500 chars "headers": dict(response.headers) } except Exception as e: return { "success": False, "error": str(e) } @router.post("/test/run-all") async def run_all_tests( request: TestRequest, username: str = Depends(verify_token) ): """Run all tests""" # TODO: Implement test runner return { "status": "completed", "total": 10, "passed": 8, "failed": 2, "details": [] } # ===================== Import/Export Endpoints ===================== @router.post("/projects/import") async def import_project( project_data: dict = Body(...), username: str = Depends(verify_token) ): """Import project from JSON""" config = load_config() # Validate structure if "name" not in project_data: raise HTTPException(status_code=400, detail="Invalid project data") # Check duplicate name existing = [p for p in config.get("projects", []) if p["name"] == project_data["name"] and not p.get("deleted", False)] if existing: raise HTTPException(status_code=400, detail="Project name already exists") # Get new project ID project_id = config["config"].get("project_id_counter", 0) + 1 config["config"]["project_id_counter"] = project_id # Create new project new_project = { "id": project_id, "name": project_data["name"], "caption": project_data.get("caption", ""), "icon": project_data.get("icon", "folder"), "description": project_data.get("description", ""), "enabled": False, "deleted": False, "created_date": get_timestamp(), "created_by": username, "last_update_date": get_timestamp(), "last_update_user": username, "version_id_counter": 1, "versions": [] } # Import versions for idx, version_data in enumerate(project_data.get("versions", [])): new_version = { "id": idx + 1, "no": idx + 1, "caption": version_data.get("caption", f"Version {idx + 1}"), "description": version_data.get("description", ""), "published": False, "deleted": False, "created_date": get_timestamp(), "created_by": username, "last_update_date": get_timestamp(), "last_update_user": username, "publish_date": None, "published_by": None, "general_prompt": version_data.get("general_prompt", ""), "default_api": version_data.get("default_api", ""), "llm": version_data.get("llm", {}), "intents": version_data.get("intents", []), "parameters": version_data.get("parameters", []) } new_project["versions"].append(new_version) new_project["version_id_counter"] = idx + 1 # Add to config if "projects" not in config: config["projects"] = [] config["projects"].append(new_project) # Add activity log add_activity_log(config, username, "IMPORT_PROJECT", "project", project_id, new_project["name"]) # Save save_config(config) log(f"✅ Project '{new_project['name']}' imported by {username}") return new_project @router.get("/projects/{project_id}/export") async def export_project( project_id: int, username: str = Depends(verify_token) ): """Export project as JSON""" config = load_config() # Find project project = next((p for p in config.get("projects", []) if p["id"] == project_id), None) if not project: raise HTTPException(status_code=404, detail="Project not found") # Create export data export_data = { "name": project["name"], "caption": project.get("caption", ""), "icon": project.get("icon", "folder"), "description": project.get("description", ""), "versions": [] } # Export versions for version in project.get("versions", []): if not version.get("deleted", False): export_version = { "caption": version.get("caption", ""), "description": version.get("description", ""), "general_prompt": version.get("general_prompt", ""), "default_api": version.get("default_api", ""), "llm": version.get("llm", {}), "intents": version.get("intents", []), "parameters": version.get("parameters", []) } export_data["versions"].append(export_version) # Add activity log add_activity_log(config, username, "EXPORT_PROJECT", "project", project_id, project["name"]) save_config(config) log(f"✅ Project '{project['name']}' exported by {username}") return export_data # ===================== Activity Log Endpoints ===================== @router.get("/activity-log") async def get_activity_log( limit: int = Query(100, ge=1, le=1000), username: str = Depends(verify_token) ): """Get activity log""" config = load_config() logs = config.get("activity_log", []) # Return latest entries (format as paginated response if needed) return logs[-limit:] # ===================== Cleanup Task ===================== def cleanup_old_logs(): """Cleanup old activity logs (runs in background)""" while True: try: config = load_config() if "activity_log" in config and len(config["activity_log"]) > 5000: # Keep only last 1000 entries config["activity_log"] = config["activity_log"][-1000:] save_config(config) log("🧹 Cleaned up old activity logs") except Exception as e: log(f"Error in cleanup task: {e}") # Run every hour time.sleep(3600) def start_cleanup_task(): """Start cleanup task in background""" cleanup_thread = threading.Thread(target=cleanup_old_logs, daemon=True) cleanup_thread.start() log("🧹 Started activity log cleanup task")