""" Settings and User Management API Router """ from fastapi import APIRouter, HTTPException from typing import List, Optional, Dict from pydantic import BaseModel from app.core.database import execute_query from app.core.config import settings import httpx import time import logging logger = logging.getLogger(__name__) router = APIRouter() # Pydantic Models class Setting(BaseModel): id: int key: str value: Optional[str] category: str description: Optional[str] value_type: str is_public: bool class SettingUpdate(BaseModel): value: str class User(BaseModel): id: int username: str email: Optional[str] full_name: Optional[str] is_active: bool last_login: Optional[str] created_at: str class UserCreate(BaseModel): username: str email: str password: str full_name: Optional[str] = None class UserUpdate(BaseModel): email: Optional[str] = None full_name: Optional[str] = None is_active: Optional[bool] = None # Settings Endpoints @router.get("/settings", response_model=List[Setting], tags=["Settings"]) async def get_settings(category: Optional[str] = None): """Get all settings or filter by category""" query = "SELECT * FROM settings" params = [] if category: query += " WHERE category = %s" params.append(category) query += " ORDER BY category, key" result = execute_query(query, tuple(params) if params else None) return result or [] @router.get("/settings/{key}", response_model=Setting, tags=["Settings"]) async def get_setting(key: str): """Get a specific setting by key""" query = "SELECT * FROM settings WHERE key = %s" result = execute_query(query, (key,)) if not result and key in {"case_types", "case_type_module_defaults"}: seed_query = """ INSERT INTO settings (key, value, category, description, value_type, is_public) VALUES (%s, %s, %s, %s, %s, %s) ON CONFLICT (key) DO NOTHING """ if key == "case_types": execute_query( seed_query, ( "case_types", '["ticket", "opgave", "ordre", "projekt", "service"]', "system", "Sags-typer", "json", True, ) ) if key == "case_type_module_defaults": execute_query( seed_query, ( "case_type_module_defaults", '{"ticket": ["relations", "call-history", "files", "emails", "hardware", "locations", "contacts", "customers", "wiki", "todo-steps", "time", "solution", "sales", "subscription", "reminders", "calendar"], "opgave": ["relations", "call-history", "files", "emails", "hardware", "locations", "contacts", "customers", "wiki", "todo-steps", "time", "solution", "sales", "subscription", "reminders", "calendar"], "ordre": ["relations", "call-history", "files", "emails", "hardware", "locations", "contacts", "customers", "wiki", "todo-steps", "time", "solution", "sales", "subscription", "reminders", "calendar"], "projekt": ["relations", "call-history", "files", "emails", "hardware", "locations", "contacts", "customers", "wiki", "todo-steps", "time", "solution", "sales", "subscription", "reminders", "calendar"], "service": ["relations", "call-history", "files", "emails", "hardware", "locations", "contacts", "customers", "wiki", "todo-steps", "time", "solution", "sales", "subscription", "reminders", "calendar"]}', "system", "Standard moduler pr. sagstype", "json", True, ) ) result = execute_query(query, (key,)) if not result: raise HTTPException(status_code=404, detail="Setting not found") return result[0] @router.put("/settings/{key}", response_model=Setting, tags=["Settings"]) async def update_setting(key: str, setting: SettingUpdate): """Update a setting value""" query = """ UPDATE settings SET value = %s, updated_at = CURRENT_TIMESTAMP WHERE key = %s RETURNING * """ result = execute_query(query, (setting.value, key)) if not result: raise HTTPException(status_code=404, detail="Setting not found") logger.info(f"✅ Updated setting: {key}") return result[0] @router.get("/settings/categories/list", tags=["Settings"]) async def get_setting_categories(): """Get list of all setting categories""" query = "SELECT DISTINCT category FROM settings ORDER BY category" result = execute_query(query) return [row['category'] for row in result] if result else [] @router.post("/settings/sync-from-env", tags=["Settings"]) async def sync_settings_from_env(): """Sync settings from .env file into database (only updates empty values)""" from app.core.config import settings as env_settings mapping = { 'vtiger_enabled': str(env_settings.VTIGER_ENABLED).lower(), 'vtiger_url': env_settings.VTIGER_URL or '', 'vtiger_username': env_settings.VTIGER_USERNAME or '', 'economic_enabled': str(env_settings.ECONOMIC_ENABLED).lower(), 'economic_app_secret': env_settings.ECONOMIC_APP_SECRET_TOKEN or '', 'economic_agreement_token': env_settings.ECONOMIC_AGREEMENT_GRANT_TOKEN or '', } updated_count = 0 for key, value in mapping.items(): # Only update if current value is empty or NULL query = """ UPDATE settings SET value = %s, updated_at = CURRENT_TIMESTAMP WHERE key = %s AND (value IS NULL OR value = '') RETURNING key """ result = execute_query(query, (value, key)) if result: updated_count += 1 logger.info(f"✅ Synced {key} from .env") return { "message": f"Synced {updated_count} settings from .env file", "updated_count": updated_count } # User Management Endpoints @router.get("/users", response_model=List[User], tags=["Users"]) async def get_users(is_active: Optional[bool] = None): """Get all users""" query = "SELECT user_id as id, username, email, full_name, is_active, last_login_at as last_login, created_at FROM users" params = [] if is_active is not None: query += " WHERE is_active = %s" params.append(is_active) query += " ORDER BY username" result = execute_query(query, tuple(params) if params else None) return result or [] @router.get("/users/{user_id}", response_model=User, tags=["Users"]) async def get_user(user_id: int): """Get user by ID""" query = "SELECT user_id as id, username, email, full_name, is_active, last_login_at as last_login, created_at FROM users WHERE user_id = %s" result = execute_query(query, (user_id,)) if not result: raise HTTPException(status_code=404, detail="User not found") return result[0] @router.post("/users", response_model=User, tags=["Users"]) async def create_user(user: UserCreate): """Create a new user""" # Check if username exists existing = execute_query("SELECT user_id FROM users WHERE username = %s", (user.username,)) if existing: raise HTTPException(status_code=400, detail="Username already exists") # Hash password (simple SHA256 for now - should use bcrypt in production) import hashlib password_hash = hashlib.sha256(user.password.encode()).hexdigest() query = """ INSERT INTO users (username, email, password_hash, full_name, is_active) VALUES (%s, %s, %s, %s, true) RETURNING user_id as id, username, email, full_name, is_active, last_login_at as last_login, created_at """ result = execute_query(query, (user.username, user.email, password_hash, user.full_name)) if not result: raise HTTPException(status_code=500, detail="Failed to create user") logger.info(f"✅ Created user: {user.username}") return result[0] @router.put("/users/{user_id}", response_model=User, tags=["Users"]) async def update_user(user_id: int, user: UserUpdate): """Update user details""" # Check if user exists existing = execute_query("SELECT user_id FROM users WHERE user_id = %s", (user_id,)) if not existing: raise HTTPException(status_code=404, detail="User not found") # Build update query update_fields = [] params = [] if user.email is not None: update_fields.append("email = %s") params.append(user.email) if user.full_name is not None: update_fields.append("full_name = %s") params.append(user.full_name) if user.is_active is not None: update_fields.append("is_active = %s") params.append(user.is_active) if not update_fields: raise HTTPException(status_code=400, detail="No fields to update") params.append(user_id) query = f""" UPDATE users SET {', '.join(update_fields)}, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s RETURNING user_id as id, username, email, full_name, is_active, last_login_at as last_login, created_at """ result = execute_query(query, tuple(params)) if not result: raise HTTPException(status_code=500, detail="Failed to update user") logger.info(f"✅ Updated user: {user_id}") return result[0] @router.delete("/users/{user_id}", tags=["Users"]) async def deactivate_user(user_id: int): """Deactivate a user (soft delete)""" query = """ UPDATE users SET is_active = false, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s RETURNING user_id as id """ result = execute_query(query, (user_id,)) if not result: raise HTTPException(status_code=404, detail="User not found") logger.info(f"✅ Deactivated user: {user_id}") return {"message": "User deactivated successfully"} @router.post("/users/{user_id}/reset-password", tags=["Users"]) async def reset_user_password(user_id: int, new_password: str): """Reset user password""" import hashlib password_hash = hashlib.sha256(new_password.encode()).hexdigest() query = """ UPDATE users SET password_hash = %s, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s RETURNING user_id as id """ result = execute_query(query, (password_hash, user_id)) if not result: raise HTTPException(status_code=404, detail="User not found") logger.info(f"✅ Reset password for user: {user_id}") return {"message": "Password reset successfully"} # AI Prompts Management def _get_default_prompts(): """Helper to get default system prompts""" from app.services.ollama_service import OllamaService ollama_service = OllamaService() return { "invoice_extraction": { "name": "📄 Faktura Udtrækning (Invoice Parser)", "description": "System prompt brugt til at udtrække data fra fakturaer og kreditnotaer via Ollama LLM. Håndterer danske nummerformater, datoer og linjegenkendelse.", "model": ollama_service.model, "endpoint": ollama_service.endpoint, "prompt": ollama_service._build_system_prompt(), "parameters": { "temperature": 0.1, "top_p": 0.9, "num_predict": 2000 } }, "ticket_classification": { "name": "🎫 Ticket Klassificering (Auto-Triage)", "description": "Klassificerer indkomne tickets baseret på emne og indhold. Tildeler kategori, prioritet og ansvarlig team.", "model": ollama_service.model, "endpoint": ollama_service.endpoint, "prompt": """Du er en erfaren IT-supporter der skal klassificere indkomne support-sager. Dine opgaver er: 1. Analyser emne og beskrivelse 2. Bestem Kategori: [Hardware, Software, Netværk, Adgang, Andet] 3. Bestem Prioritet: [Lav, Mellem, Høj, Kritisk] 4. Foreslå handlingsplan (kort punktform) Output skal være gyldig JSON: { "category": "string", "priority": "string", "summary": "string", "suggested_actions": ["string"] }""", "parameters": { "temperature": 0.3, "top_p": 0.95, "num_predict": 1000 } }, "ticket_summary": { "name": "📝 Ticket Summering (Fakturagrundlag)", "description": "Analyserer alle kommentarer og noter i en ticket for at lave et kort, præcist resumé til fakturaen eller kunden.", "model": ollama_service.model, "endpoint": ollama_service.endpoint, "prompt": """Du er en administrativ assistent der skal gøre en it-sag klar til fakturering. Opgave: Læs historikken igennem og skriv et kort resumé af det udførte arbejde. - Fokusér på løsningen, ikke problemet - Brug professionelt sprog - Undlad interne tekniske detaljer (medmindre relevant for faktura) - Sprog: Dansk - Længde: 2-3 sætninger Input: [Liste af kommentarer] Output: [Fakturastekst]""", "parameters": { "temperature": 0.2, "top_p": 0.9, "num_predict": 500 } }, "kb_generation": { "name": "📚 Vidensbank Generator (Solution to Article)", "description": "Omdanner en løst ticket til en generel vejledning til vidensbanken.", "model": ollama_service.model, "endpoint": ollama_service.endpoint, "prompt": """Du er teknisk forfatter. Din opgave er at omskrive en konkret support-sag til en generel vejledning. Regler: 1. Fjern alle kunde-specifikke data (navne, IP-adresser, passwords) 2. Strukturer som: - Problem - Årsag (hvis kendt) - Løsning (Trin-for-trin guide) 3. Brug letforståeligt dansk 4. Formater med Markdown Input: [Ticket Beskrivelse + Løsning] Output: [Markdown Guide]""", "parameters": { "temperature": 0.4, "top_p": 0.9, "num_predict": 2000 } }, "troubleshooting_assistant": { "name": "🔧 Fejlsøgnings Copilot (Tech Helper)", "description": "Fungerer som en senior-tekniker der giver sparring på en fejlbeskrivelse. Foreslår konkrete fejlsøgningstrin og kommandoer.", "model": ollama_service.model, "endpoint": ollama_service.endpoint, "prompt": """Du er en Senior Systemadministrator med 20 års erfaring. En junior-tekniker spørger om hjælp til et problem. Din opgave: 1. Analyser symptomerne 2. List de 3 mest sandsynlige årsager 3. Foreslå en trin-for-trin fejlsøgningsplan (start med det mest sandsynlige) 4. Nævn relevante værktøjer eller kommandoer (Windows/Linux/Network) Vær kortfattet, teknisk præcis og handlingsorienteret. Sprog: Dansk (men engelske fagtermer er OK). Input: [Fejlbeskrivelse] Output: [Markdown Guide]""", "parameters": { "temperature": 0.3, "top_p": 0.9, "num_predict": 1500 } }, "sentiment_analysis": { "name": "🌡️ Sentiment Analyse (Kunde-Humør)", "description": "Analyserer tonen i en kundehenvendelse for at vurdere hast, frustration og risiko. Bruges til prioritering.", "model": ollama_service.model, "endpoint": ollama_service.endpoint, "prompt": """Analyser tonen i følgende tekst fra en kunde. Bestem følgende: 1. Sentiment: [Positiv, Neutral, Frustreret, Vred] 2. Hastegrad-indikatorer: Er der ord der indikerer panik eller kritisk hast? 3. Risikovurdering (0-10): Hvor stor risiko er der for at kunden forlader os? (10=Høj) Returner resultatet som JSON format. Input: [Kunde Tekst] Output: { "sentiment": "...", "urgency": "...", "risk_score": 0 }""", "parameters": { "temperature": 0.1, "top_p": 0.9, "num_predict": 500 } }, "meeting_action_items": { "name": "📋 Mødenoter til Opgaver (Action Extraction)", "description": "Scanner rå mødereferater eller notater og udtrækker konkrete 'Action Items', deadlines og ansvarlige personer.", "model": ollama_service.model, "endpoint": ollama_service.endpoint, "prompt": """Du er en effektiv projektleder-assistent. Din opgave er at scanne mødereferater og udtrække "Action Items". For hver opgave skal du finde: - Aktivitet (Hvad skal gøres?) - Ansvarlig (Hvem?) - Deadline (Hvornår? Hvis nævnt) Ignorer løs snak og diskussioner. Fokusér kun på beslutninger og opgaver der skal udføres. Outputtet skal være en punktopstilling. Input: [Mødenoter] Output: [Liste af opgaver]""", "parameters": { "temperature": 0.2, "top_p": 0.9, "num_predict": 1000 } } } def _get_prompts_with_overrides() -> Dict: """Get AI prompts with DB overrides applied""" prompts = _get_default_prompts() try: rows = execute_query("SELECT key, prompt_text FROM ai_prompts") if rows: for row in rows: if row['key'] in prompts: prompts[row['key']]['prompt'] = row['prompt_text'] prompts[row['key']]['is_custom'] = True except Exception as e: logger.warning(f"Could not load custom ai prompts: {e}") return prompts def _get_test_input_for_prompt(key: str) -> str: """Default test input per prompt type""" examples = { "invoice_extraction": "FAKTURA 2026-1001 fra Demo A/S. CVR 12345678. Total 1.250,00 DKK inkl moms.", "ticket_classification": "Emne: Kan ikke logge på VPN. Beskrivelse: Flere brugere er ramt siden i morges.", "ticket_summary": "Bruger havde netværksfejl. Router genstartet og DNS opdateret. Forbindelse virker nu stabilt.", "kb_generation": "Problem: Outlook åbner ikke. Løsning: Reparer Office installation og nulstil profil.", "troubleshooting_assistant": "Server svarer langsomt efter opdatering. CPU er høj, disk IO er normal.", "sentiment_analysis": "Jeg er meget frustreret, systemet er nede igen og vi mister kunder!", "meeting_action_items": "Peter opdaterer firewall fredag. Anna sender status til kunden mandag.", } return examples.get(key, "Skriv kort: AI test OK") @router.get("/ai-prompts", tags=["Settings"]) async def get_ai_prompts(): """Get all AI prompts (defaults merged with custom overrides)""" return _get_prompts_with_overrides() class PromptUpdate(BaseModel): prompt_text: str class PromptTestRequest(BaseModel): test_input: Optional[str] = None prompt_text: Optional[str] = None @router.put("/ai-prompts/{key}", tags=["Settings"]) async def update_ai_prompt(key: str, update: PromptUpdate): """Override a system prompt with a custom one""" defaults = _get_default_prompts() if key not in defaults: raise HTTPException(status_code=404, detail="Unknown prompt key") try: # Upsert query = """ INSERT INTO ai_prompts (key, prompt_text, updated_at) VALUES (%s, %s, CURRENT_TIMESTAMP) ON CONFLICT (key) DO UPDATE SET prompt_text = EXCLUDED.prompt_text, updated_at = CURRENT_TIMESTAMP RETURNING key """ execute_query(query, (key, update.prompt_text)) return {"message": "Prompt updated", "key": key} except Exception as e: logger.error(f"Error saving prompt: {e}") raise HTTPException(status_code=500, detail="Could not save prompt") @router.delete("/ai-prompts/{key}", tags=["Settings"]) async def reset_ai_prompt(key: str): """Reset a prompt to its system default""" try: execute_query("DELETE FROM ai_prompts WHERE key = %s", (key,)) return {"message": "Prompt reset to default"} except Exception as e: logger.error(f"Error resetting prompt: {e}") raise HTTPException(status_code=500, detail="Could not reset prompt") @router.post("/ai-prompts/{key}/test", tags=["Settings"]) async def test_ai_prompt(key: str, payload: PromptTestRequest): """Run a quick AI test for a specific system prompt""" prompts = _get_prompts_with_overrides() if key not in prompts: raise HTTPException(status_code=404, detail="Unknown prompt key") prompt_cfg = prompts[key] model = prompt_cfg.get("model") or settings.OLLAMA_MODEL endpoint = prompt_cfg.get("endpoint") or settings.OLLAMA_ENDPOINT prompt_text = (payload.prompt_text or prompt_cfg.get("prompt") or "").strip() if not prompt_text: raise HTTPException(status_code=400, detail="Prompt text is empty") test_input = (payload.test_input or _get_test_input_for_prompt(key)).strip() if not test_input: raise HTTPException(status_code=400, detail="Test input is empty") start = time.perf_counter() try: use_chat_api = model.startswith("qwen3") async with httpx.AsyncClient(timeout=60.0) as client: if use_chat_api: response = await client.post( f"{endpoint}/api/chat", json={ "model": model, "messages": [ {"role": "system", "content": prompt_text}, {"role": "user", "content": test_input}, ], "stream": False, "options": {"temperature": 0.2, "num_predict": 600}, }, ) else: response = await client.post( f"{endpoint}/api/generate", json={ "model": model, "prompt": f"{prompt_text}\n\nBrugerinput:\n{test_input}", "stream": False, "options": {"temperature": 0.2, "num_predict": 600}, }, ) if response.status_code != 200: raise HTTPException( status_code=502, detail=f"AI endpoint fejl: {response.status_code} - {response.text[:300]}", ) data = response.json() if use_chat_api: message_data = data.get("message", {}) ai_response = (message_data.get("content") or message_data.get("thinking") or "").strip() else: ai_response = (data.get("response") or "").strip() if not ai_response: raise HTTPException(status_code=502, detail="AI returnerede tomt svar") latency_ms = int((time.perf_counter() - start) * 1000) return { "ok": True, "key": key, "model": model, "endpoint": endpoint, "test_input": test_input, "ai_response": ai_response, "latency_ms": latency_ms, } except HTTPException: raise except Exception as e: logger.error(f"❌ AI prompt test failed for {key}: {e}") raise HTTPException(status_code=500, detail=f"Kunne ikke teste AI prompt: {str(e)}")