bmc_hub/app/settings/backend/router.py

520 lines
17 KiB
Python
Raw Normal View History

"""
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
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 == "case_types":
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
"""
execute_query(
seed_query,
(
"case_types",
'["ticket", "opgave", "ordre", "projekt", "service"]',
"system",
"Sags-typer",
"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, 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, 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, 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, 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 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 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
}
}
}
@router.get("/ai-prompts", tags=["Settings"])
async def get_ai_prompts():
"""Get all AI prompts (defaults merged with custom overrides)"""
prompts = _get_default_prompts()
try:
# Check for custom overrides in DB
# Note: Table ai_prompts must rely on migration 066
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
class PromptUpdate(BaseModel):
prompt_text: str
@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")