ciyidogan commited on
Commit
73a43b1
·
verified ·
1 Parent(s): a4521a1

Update routes/admin_routes.py

Browse files
Files changed (1) hide show
  1. routes/admin_routes.py +1048 -1048
routes/admin_routes.py CHANGED
@@ -1,1049 +1,1049 @@
1
- """Admin API endpoints for Flare (Refactored)
2
- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
3
- Provides authentication, project, version, and API management endpoints with provider support.
4
- """
5
-
6
- import os
7
- import time
8
- import threading
9
- import hashlib
10
- import bcrypt
11
- from typing import Optional, Dict, List, Any
12
- from datetime import datetime, timedelta, timezone
13
- from fastapi import APIRouter, HTTPException, Depends, Query, Response, Body
14
- from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
15
- from pydantic import BaseModel, Field
16
- import httpx
17
- from functools import wraps
18
-
19
- from utils.utils import verify_token, create_token, get_current_timestamp
20
- from config.config_provider import ConfigProvider
21
- from utils.logger import log_info, log_error, log_warning, log_debug
22
- from utils.exceptions import (
23
- FlareException,
24
- RaceConditionError,
25
- ValidationError,
26
- ResourceNotFoundError,
27
- AuthenticationError,
28
- AuthorizationError,
29
- DuplicateResourceError
30
- )
31
- from config.config_models import VersionConfig, IntentConfig, LLMConfiguration
32
-
33
- # ===================== Constants & Config =====================
34
- security = HTTPBearer()
35
- router = APIRouter(tags=["admin"])
36
-
37
- # ===================== Decorators =====================
38
- def handle_exceptions(func):
39
- """Decorator to handle exceptions consistently"""
40
- @wraps(func)
41
- async def wrapper(*args, **kwargs):
42
- try:
43
- return await func(*args, **kwargs)
44
- except HTTPException:
45
- # HTTPException'ları olduğu gibi geçir
46
- raise
47
- except FlareException:
48
- # Let global handlers deal with our custom exceptions
49
- raise
50
- except Exception as e:
51
- # Log and convert unexpected exceptions to HTTP 500
52
- log_error(f"❌ Unexpected error in {func.__name__}", e)
53
- raise HTTPException(status_code=500, detail=str(e))
54
- return wrapper
55
-
56
- # ===================== Models =====================
57
- class LoginRequest(BaseModel):
58
- username: str
59
- password: str
60
-
61
- class LoginResponse(BaseModel):
62
- token: str
63
- username: str
64
-
65
- class ChangePasswordRequest(BaseModel):
66
- current_password: str
67
- new_password: str
68
-
69
- class ProviderSettingsUpdate(BaseModel):
70
- name: str
71
- api_key: Optional[str] = None
72
- endpoint: Optional[str] = None
73
- settings: Dict[str, Any] = Field(default_factory=dict)
74
-
75
- class EnvironmentUpdate(BaseModel):
76
- llm_provider: ProviderSettingsUpdate
77
- tts_provider: ProviderSettingsUpdate
78
- stt_provider: ProviderSettingsUpdate
79
- parameter_collection_config: Optional[Dict[str, Any]] = None
80
-
81
- class ProjectCreate(BaseModel):
82
- name: str
83
- caption: Optional[str] = ""
84
- icon: Optional[str] = "folder"
85
- description: Optional[str] = ""
86
- default_locale: str = "tr"
87
- supported_locales: List[str] = Field(default_factory=lambda: ["tr"])
88
- timezone: str = "Europe/Istanbul"
89
- region: str = "tr-TR"
90
-
91
- class ProjectUpdate(BaseModel):
92
- caption: str
93
- icon: Optional[str] = "folder"
94
- description: Optional[str] = ""
95
- default_locale: str = "tr"
96
- supported_locales: List[str] = Field(default_factory=lambda: ["tr"])
97
- timezone: str = "Europe/Istanbul"
98
- region: str = "tr-TR"
99
- last_update_date: str
100
-
101
- class VersionCreate(BaseModel):
102
- caption: str
103
- source_version_no: int | None = None
104
-
105
- class IntentModel(BaseModel):
106
- name: str
107
- caption: Optional[str] = ""
108
- detection_prompt: str
109
- examples: List[Dict[str, str]] = [] # LocalizedExample format
110
- parameters: List[Dict[str, Any]] = []
111
- action: str
112
- fallback_timeout_prompt: Optional[str] = None
113
- fallback_error_prompt: Optional[str] = None
114
-
115
- class VersionUpdate(BaseModel):
116
- caption: str
117
- general_prompt: str
118
- llm: Dict[str, Any]
119
- intents: List[IntentModel]
120
- last_update_date: str
121
-
122
- class APICreate(BaseModel):
123
- name: str
124
- url: str
125
- method: str = "POST"
126
- headers: Dict[str, str] = {}
127
- body_template: Dict[str, Any] = {}
128
- timeout_seconds: int = 10
129
- retry: Dict[str, Any] = Field(default_factory=lambda: {"retry_count": 3, "backoff_seconds": 2, "strategy": "static"})
130
- proxy: Optional[str] = None
131
- auth: Optional[Dict[str, Any]] = None
132
- response_prompt: Optional[str] = None
133
- response_mappings: List[Dict[str, Any]] = []
134
-
135
- class APIUpdate(BaseModel):
136
- url: str
137
- method: str
138
- headers: Dict[str, str]
139
- body_template: Dict[str, Any]
140
- timeout_seconds: int
141
- retry: Dict[str, Any]
142
- proxy: Optional[str]
143
- auth: Optional[Dict[str, Any]]
144
- response_prompt: Optional[str]
145
- response_mappings: List[Dict[str, Any]] = []
146
- last_update_date: str
147
-
148
- class TestRequest(BaseModel):
149
- test_type: str # "all", "ui", "backend", "integration", "spark"
150
-
151
- # ===================== Auth Endpoints =====================
152
- @router.post("/login", response_model=LoginResponse)
153
- @handle_exceptions
154
- async def login(request: LoginRequest):
155
- """User login endpoint"""
156
- cfg = ConfigProvider.get()
157
-
158
- # Find user
159
- user = next((u for u in cfg.global_config.users if u.username == request.username), None)
160
- if not user:
161
- raise HTTPException(status_code=401, detail="Invalid credentials")
162
-
163
- # Verify password - Try both bcrypt and SHA256 for backward compatibility
164
- password_valid = False
165
-
166
- # First try bcrypt (new format)
167
- try:
168
- if user.password_hash.startswith("$2b$") or user.password_hash.startswith("$2a$"):
169
- password_valid = bcrypt.checkpw(request.password.encode('utf-8'), user.password_hash.encode('utf-8'))
170
- except:
171
- pass
172
-
173
- # If not valid, try SHA256 (old format)
174
- if not password_valid:
175
- sha256_hash = hashlib.sha256(request.password.encode('utf-8')).hexdigest()
176
- password_valid = (user.password_hash == sha256_hash)
177
-
178
- if not password_valid:
179
- raise HTTPException(status_code=401, detail="Invalid credentials")
180
-
181
- # Create token
182
- token = create_token(request.username)
183
-
184
- log_info(f"✅ User '{request.username}' logged in successfully")
185
- return LoginResponse(token=token, username=request.username)
186
-
187
- @router.post("/change-password")
188
- @handle_exceptions
189
- async def change_password(
190
- request: ChangePasswordRequest,
191
- username: str = Depends(verify_token)
192
- ):
193
- """Change user password"""
194
- cfg = ConfigProvider.get()
195
-
196
- # Find user
197
- user = next((u for u in cfg.global_config.users if u.username == username), None)
198
- if not user:
199
- raise HTTPException(status_code=404, detail="User not found")
200
-
201
- # Verify current password - Try both bcrypt and SHA256 for backward compatibility
202
- password_valid = False
203
-
204
- # First try bcrypt (new format)
205
- try:
206
- if user.password_hash.startswith("$2b$") or user.password_hash.startswith("$2a$"):
207
- password_valid = bcrypt.checkpw(request.current_password.encode('utf-8'), user.password_hash.encode('utf-8'))
208
- except:
209
- pass
210
-
211
- # If not valid, try SHA256 (old format)
212
- if not password_valid:
213
- sha256_hash = hashlib.sha256(request.current_password.encode('utf-8')).hexdigest()
214
- password_valid = (user.password_hash == sha256_hash)
215
-
216
- if not password_valid:
217
- raise HTTPException(status_code=401, detail="Current password is incorrect")
218
-
219
- # Generate new password hash (always use bcrypt for new passwords)
220
- salt = bcrypt.gensalt()
221
- new_hash = bcrypt.hashpw(request.new_password.encode('utf-8'), salt)
222
-
223
- # Update user
224
- user.password_hash = new_hash.decode('utf-8')
225
- user.salt = salt.decode('utf-8')
226
-
227
- # Save configuration via ConfigProvider
228
- ConfigProvider.save(cfg, username)
229
-
230
- log_info(f"✅ Password changed for user '{username}'")
231
- return {"success": True}
232
-
233
- # ===================== Locales Endpoints =====================
234
- @router.get("/locales")
235
- @handle_exceptions
236
- async def get_available_locales(username: str = Depends(verify_token)):
237
- """Get all system-supported locales"""
238
- from locale_manager import LocaleManager
239
-
240
- locales = LocaleManager.get_available_locales_with_names()
241
-
242
- return {
243
- "locales": locales,
244
- "default": LocaleManager.get_default_locale()
245
- }
246
-
247
- @router.get("/locales/{locale_code}")
248
- @handle_exceptions
249
- async def get_locale_details(
250
- locale_code: str,
251
- username: str = Depends(verify_token)
252
- ):
253
- """Get detailed information for a specific locale"""
254
- from locale_manager import LocaleManager
255
-
256
- locale_info = LocaleManager.get_locale_details(locale_code)
257
-
258
- if not locale_info:
259
- raise HTTPException(status_code=404, detail=f"Locale '{locale_code}' not found")
260
-
261
- return locale_info
262
-
263
- # ===================== Environment Endpoints =====================
264
- @router.get("/environment")
265
- @handle_exceptions
266
- async def get_environment(username: str = Depends(verify_token)):
267
- """Get environment configuration with provider info"""
268
- cfg = ConfigProvider.get()
269
- env_config = cfg.global_config
270
-
271
- # Provider tabanlı yeni yapıyı destekle
272
- response = {}
273
-
274
- # LLM Provider
275
- if hasattr(env_config, 'llm_provider'):
276
- response["llm_provider"] = env_config.llm_provider
277
-
278
- # TTS Provider
279
- if hasattr(env_config, 'tts_provider'):
280
- response["tts_provider"] = env_config.tts_provider
281
-
282
- # STT Provider
283
- if hasattr(env_config, 'stt_provider'):
284
- response["stt_provider"] = env_config.stt_provider
285
- else:
286
- response["stt_provider"] = {
287
- "name": getattr(env_config, 'stt_engine', 'no_stt'),
288
- "api_key": getattr(env_config, 'stt_engine_api_key', None) or "",
289
- "endpoint": None,
290
- "settings": getattr(env_config, 'stt_settings', {})
291
- }
292
-
293
- # Provider listesi
294
- if hasattr(env_config, 'providers'):
295
- providers_list = []
296
- for provider in env_config.providers:
297
- providers_list.append(provider)
298
- response["providers"] = providers_list
299
- else:
300
- # Varsayılan provider listesi
301
- response["providers"] = [
302
- {
303
- "type": "llm",
304
- "name": "spark_cloud",
305
- "display_name": "Spark LLM (Cloud)",
306
- "requires_endpoint": True,
307
- "requires_api_key": True,
308
- "requires_repo_info": False
309
- },
310
- {
311
- "type": "llm",
312
- "name": "gpt-4o",
313
- "display_name": "GPT-4o",
314
- "requires_endpoint": True,
315
- "requires_api_key": True,
316
- "requires_repo_info": False
317
- },
318
- {
319
- "type": "llm",
320
- "name": "gpt-4o-mini",
321
- "display_name": "GPT-4o Mini",
322
- "requires_endpoint": True,
323
- "requires_api_key": True,
324
- "requires_repo_info": False
325
- },
326
- {
327
- "type": "tts",
328
- "name": "no_tts",
329
- "display_name": "No TTS",
330
- "requires_endpoint": False,
331
- "requires_api_key": False,
332
- "requires_repo_info": False
333
- },
334
- {
335
- "type": "tts",
336
- "name": "elevenlabs",
337
- "display_name": "ElevenLabs",
338
- "requires_endpoint": False,
339
- "requires_api_key": True,
340
- "requires_repo_info": False
341
- },
342
- {
343
- "type": "stt",
344
- "name": "no_stt",
345
- "display_name": "No STT",
346
- "requires_endpoint": False,
347
- "requires_api_key": False,
348
- "requires_repo_info": False
349
- },
350
- {
351
- "type": "stt",
352
- "name": "google",
353
- "display_name": "Google Cloud STT",
354
- "requires_endpoint": False,
355
- "requires_api_key": True,
356
- "requires_repo_info": False
357
- }
358
- ]
359
-
360
- # Parameter collection config
361
- if hasattr(env_config, 'parameter_collection_config'):
362
- response["parameter_collection_config"] = env_config.parameter_collection_config
363
- else:
364
- # Varsayılan değerler
365
- response["parameter_collection_config"] = {
366
- "max_params_per_question": 2,
367
- "retry_unanswered": True,
368
- "smart_grouping": True,
369
- "collection_prompt": "You are a helpful assistant collecting information from the user..."
370
- }
371
-
372
- return response
373
-
374
- @router.put("/environment")
375
- @handle_exceptions
376
- async def update_environment(
377
- update: EnvironmentUpdate,
378
- username: str = Depends(verify_token)
379
- ):
380
- """Update environment configuration with provider validation"""
381
- log_info(f"📝 Updating environment config by {username}")
382
-
383
- cfg = ConfigProvider.get()
384
-
385
- # Validate LLM provider
386
- llm_provider_def = cfg.global_config.get_provider_config("llm", update.llm_provider.name)
387
- if not llm_provider_def:
388
- raise HTTPException(status_code=400, detail=f"Unknown LLM provider: {update.llm_provider.name}")
389
-
390
- if llm_provider_def.requires_api_key and not update.llm_provider.api_key:
391
- raise HTTPException(status_code=400, detail=f"{llm_provider_def.display_name} requires API key")
392
-
393
- if llm_provider_def.requires_endpoint and not update.llm_provider.endpoint:
394
- raise HTTPException(status_code=400, detail=f"{llm_provider_def.display_name} requires endpoint")
395
-
396
- # Validate TTS provider
397
- tts_provider_def = cfg.global_config.get_provider_config("tts", update.tts_provider.name)
398
- if not tts_provider_def:
399
- raise HTTPException(status_code=400, detail=f"Unknown TTS provider: {update.tts_provider.name}")
400
-
401
- if tts_provider_def.requires_api_key and not update.tts_provider.api_key:
402
- raise HTTPException(status_code=400, detail=f"{tts_provider_def.display_name} requires API key")
403
-
404
- # Validate STT provider
405
- stt_provider_def = cfg.global_config.get_provider_config("stt", update.stt_provider.name)
406
- if not stt_provider_def:
407
- raise HTTPException(status_code=400, detail=f"Unknown STT provider: {update.stt_provider.name}")
408
-
409
- if stt_provider_def.requires_api_key and not update.stt_provider.api_key:
410
- raise HTTPException(status_code=400, detail=f"{stt_provider_def.display_name} requires API key")
411
-
412
- # Update via ConfigProvider
413
- ConfigProvider.update_environment(update.model_dump(), username)
414
-
415
- log_info(f"✅ Environment updated to LLM: {update.llm_provider.name}, TTS: {update.tts_provider.name}, STT: {update.stt_provider.name} by {username}")
416
- return {"success": True}
417
-
418
- # ===================== Project Endpoints =====================
419
- @router.get("/projects/names")
420
- @handle_exceptions
421
- async def list_enabled_projects():
422
- """Get list of enabled project names for chat"""
423
- cfg = ConfigProvider.get()
424
- return [p.name for p in cfg.projects if p.enabled and not getattr(p, 'deleted', False)]
425
-
426
- @router.get("/projects")
427
- @handle_exceptions
428
- async def list_projects(
429
- include_deleted: bool = False,
430
- username: str = Depends(verify_token)
431
- ):
432
- """List all projects"""
433
- cfg = ConfigProvider.get()
434
- projects = cfg.projects
435
-
436
- # Filter deleted if needed
437
- if not include_deleted:
438
- projects = [p for p in projects if not getattr(p, 'deleted', False)]
439
-
440
- return [p.model_dump() for p in projects]
441
-
442
- @router.get("/projects/{project_id}")
443
- @handle_exceptions
444
- async def get_project(
445
- project_id: int,
446
- username: str = Depends(verify_token)
447
- ):
448
- """Get single project by ID"""
449
- project = ConfigProvider.get_project(project_id)
450
- if not project or getattr(project, 'deleted', False):
451
- raise HTTPException(status_code=404, detail="Project not found")
452
-
453
- return project.model_dump()
454
-
455
- @router.post("/projects")
456
- @handle_exceptions
457
- async def create_project(
458
- project: ProjectCreate,
459
- username: str = Depends(verify_token)
460
- ):
461
- """Create new project with initial version"""
462
- # Validate supported locales
463
- from config.locale_manager import LocaleManager
464
-
465
- invalid_locales = LocaleManager.validate_project_languages(project.supported_locales)
466
- if invalid_locales:
467
- available_locales = LocaleManager.get_available_locales_with_names()
468
- available_codes = [locale['code'] for locale in available_locales]
469
- raise HTTPException(
470
- status_code=400,
471
- detail=f"Unsupported locales: {', '.join(invalid_locales)}. Available locales: {', '.join(available_codes)}"
472
- )
473
-
474
- # Check if default locale is in supported locales
475
- if project.default_locale not in project.supported_locales:
476
- raise HTTPException(
477
- status_code=400,
478
- detail="Default locale must be one of the supported locales"
479
- )
480
-
481
- # Debug log for project creation
482
- log_debug(f"🔍 Creating project '{project.name}' with default_locale: {project.default_locale}")
483
-
484
- new_project = ConfigProvider.create_project(project.model_dump(), username)
485
-
486
- # Debug log for initial version
487
- if new_project.versions:
488
- initial_version = new_project.versions[0]
489
- log_debug(f"🔍 Initial version created - no: {initial_version.no}, published: {initial_version.published}, type: {type(initial_version.published)}")
490
-
491
- log_info(f"✅ Project '{project.name}' created by {username}")
492
- return new_project.model_dump()
493
-
494
- @router.put("/projects/{project_id}")
495
- @handle_exceptions
496
- async def update_project(
497
- project_id: int,
498
- update: ProjectUpdate,
499
- username: str = Depends(verify_token)
500
- ):
501
- """Update existing project with race condition handling"""
502
- log_info(f"🔍 Update request for project {project_id} by {username}")
503
- log_info(f"🔍 Received last_update_date: {update.last_update_date}")
504
-
505
- # Mevcut project'i al ve durumunu logla
506
- current_project = ConfigProvider.get_project(project_id)
507
- if current_project:
508
- log_info(f"🔍 Current project last_update_date: {current_project.last_update_date}")
509
- log_info(f"🔍 Current project last_update_user: {current_project.last_update_user}")
510
-
511
- # Optimistic locking kontrolü
512
- result = ConfigProvider.update_project(
513
- project_id,
514
- update.model_dump(),
515
- username,
516
- expected_last_update=update.last_update_date
517
- )
518
-
519
- log_info(f"✅ Project {project_id} updated by {username}")
520
- return result
521
-
522
- @router.delete("/projects/{project_id}")
523
- @handle_exceptions
524
- async def delete_project(project_id: int, username: str = Depends(verify_token)):
525
- """Delete project (soft delete)"""
526
- ConfigProvider.delete_project(project_id, username)
527
-
528
- log_info(f"✅ Project deleted by {username}")
529
- return {"success": True}
530
-
531
- @router.patch("/projects/{project_id}/toggle")
532
- async def toggle_project(project_id: int, username: str = Depends(verify_token)):
533
- """Toggle project enabled status"""
534
- enabled = ConfigProvider.toggle_project(project_id, username)
535
-
536
- log_info(f"✅ Project {'enabled' if enabled else 'disabled'} by {username}")
537
- return {"enabled": enabled}
538
-
539
- # ===================== Import/Export Endpoints =====================
540
- @router.get("/projects/{project_id}/export")
541
- @handle_exceptions
542
- async def export_project(
543
- project_id: int,
544
- username: str = Depends(verify_token)
545
- ):
546
- """Export project as JSON"""
547
- project = ConfigProvider.get_project(project_id)
548
- if not project:
549
- raise HTTPException(status_code=404, detail="Project not found")
550
-
551
- # Prepare export data
552
- export_data = {
553
- "name": project.name,
554
- "caption": project.caption,
555
- "icon": project.icon,
556
- "description": project.description,
557
- "default_locale": project.default_locale,
558
- "supported_locales": project.supported_locales,
559
- "timezone": project.timezone,
560
- "region": project.region,
561
- "versions": []
562
- }
563
-
564
- # Add versions (only non-deleted)
565
- for version in project.versions:
566
- if not getattr(version, 'deleted', False):
567
- version_data = {
568
- "caption": version.caption,
569
- "description": getattr(version, 'description', ''),
570
- "general_prompt": version.general_prompt,
571
- "welcome_prompt": getattr(version, 'welcome_prompt', None),
572
- "llm": version.llm.model_dump() if version.llm else {},
573
- "intents": [intent.model_dump() for intent in version.intents]
574
- }
575
- export_data["versions"].append(version_data)
576
-
577
- log_info(f"✅ Project '{project.name}' exported by {username}")
578
-
579
- return export_data
580
-
581
- @router.post("/projects/import")
582
- @handle_exceptions
583
- async def import_project(
584
- project_data: dict = Body(...),
585
- username: str = Depends(verify_token)
586
- ):
587
- """Import project from JSON"""
588
- # Validate required fields
589
- if not project_data.get('name'):
590
- raise HTTPException(status_code=400, detail="Project name is required")
591
-
592
- # Check for duplicate name
593
- cfg = ConfigProvider.get()
594
- if any(p.name == project_data['name'] for p in cfg.projects if not p.deleted):
595
- raise HTTPException(
596
- status_code=409,
597
- detail=f"Project with name '{project_data['name']}' already exists"
598
- )
599
-
600
- # Create project
601
- new_project_data = {
602
- "name": project_data['name'],
603
- "caption": project_data.get('caption', project_data['name']),
604
- "icon": project_data.get('icon', 'folder'),
605
- "description": project_data.get('description', ''),
606
- "default_locale": project_data.get('default_locale', 'tr'),
607
- "supported_locales": project_data.get('supported_locales', ['tr']),
608
- "timezone": project_data.get('timezone', 'Europe/Istanbul'),
609
- "region": project_data.get('region', 'tr-TR')
610
- }
611
-
612
- # Create project
613
- new_project = ConfigProvider.create_project(new_project_data, username)
614
-
615
- # Import versions
616
- if 'versions' in project_data and project_data['versions']:
617
- # Remove the initial version that was auto-created
618
- if new_project.versions:
619
- new_project.versions.clear()
620
-
621
- # Add imported versions
622
- for idx, version_data in enumerate(project_data['versions']):
623
- version = VersionConfig(
624
- no=idx + 1,
625
- caption=version_data.get('caption', f'Version {idx + 1}'),
626
- description=version_data.get('description', ''),
627
- published=False, # Imported versions are unpublished
628
- deleted=False,
629
- general_prompt=version_data.get('general_prompt', ''),
630
- welcome_prompt=version_data.get('welcome_prompt'),
631
- llm=LLMConfiguration(**version_data.get('llm', {
632
- 'repo_id': '',
633
- 'generation_config': {
634
- 'max_new_tokens': 512,
635
- 'temperature': 0.7,
636
- 'top_p': 0.9
637
- },
638
- 'use_fine_tune': False,
639
- 'fine_tune_zip': ''
640
- })),
641
- intents=[IntentConfig(**intent) for intent in version_data.get('intents', [])],
642
- created_date=get_current_timestamp(),
643
- created_by=username
644
- )
645
- new_project.versions.append(version)
646
-
647
- # Update version counter
648
- new_project.version_id_counter = len(new_project.versions) + 1
649
-
650
- # Save updated project
651
- ConfigProvider.save(cfg, username)
652
-
653
- log_info(f"✅ Project '{new_project.name}' imported by {username}")
654
-
655
- return {"success": True, "project_id": new_project.id, "project_name": new_project.name}
656
-
657
- # ===================== Version Endpoints =====================
658
- @router.get("/projects/{project_id}/versions")
659
- @handle_exceptions
660
- async def list_versions(
661
- project_id: int,
662
- include_deleted: bool = False,
663
- username: str = Depends(verify_token)
664
- ):
665
- """List project versions"""
666
- project = ConfigProvider.get_project(project_id)
667
- if not project:
668
- raise HTTPException(status_code=404, detail="Project not found")
669
-
670
- versions = project.versions
671
-
672
- # Filter deleted if needed
673
- if not include_deleted:
674
- versions = [v for v in versions if not getattr(v, 'deleted', False)]
675
-
676
- return [v.model_dump() for v in versions]
677
-
678
- @router.post("/projects/{project_id}/versions")
679
- @handle_exceptions
680
- async def create_version(
681
- project_id: int,
682
- version_data: VersionCreate,
683
- username: str = Depends(verify_token)
684
- ):
685
- """Create new version"""
686
- new_version = ConfigProvider.create_version(project_id, version_data.model_dump(), username)
687
-
688
- log_info(f"✅ Version created for project {project_id} by {username}")
689
- return new_version.model_dump()
690
-
691
- @router.put("/projects/{project_id}/versions/{version_no}")
692
- @handle_exceptions
693
- async def update_version(
694
- project_id: int,
695
- version_no: int,
696
- update: VersionUpdate,
697
- force: bool = Query(default=False, description="Force update despite conflicts"),
698
- username: str = Depends(verify_token)
699
- ):
700
- """Update version with race condition handling"""
701
- log_debug(f"🔍 Version update request - project: {project_id}, version: {version_no}, user: {username}")
702
-
703
- # Force parametresi kontrolü
704
- if force:
705
- log_warning(f"⚠️ Force update requested for version {version_no} by {username}")
706
-
707
- result = ConfigProvider.update_version(
708
- project_id,
709
- version_no,
710
- update.model_dump(),
711
- username,
712
- expected_last_update=update.last_update_date if not force else None
713
- )
714
-
715
- log_info(f"✅ Version {version_no} updated by {username}")
716
- return result
717
-
718
- @router.post("/projects/{project_id}/versions/{version_no}/publish")
719
- @handle_exceptions
720
- async def publish_version(
721
- project_id: int,
722
- version_no: int,
723
- username: str = Depends(verify_token)
724
- ):
725
- """Publish version"""
726
- project, version = ConfigProvider.publish_version(project_id, version_no, username)
727
-
728
- log_info(f"✅ Version {version_no} published for project '{project.name}' by {username}")
729
-
730
- # Notify LLM provider if project is enabled and provider requires repo info
731
- cfg = ConfigProvider.get()
732
- llm_provider_def = cfg.global_config.get_provider_config("llm", cfg.global_config.llm_provider.name)
733
-
734
- if project.enabled and llm_provider_def and llm_provider_def.requires_repo_info:
735
- try:
736
- await notify_llm_startup(project, version)
737
- except Exception as e:
738
- log_error(f"⚠️ Failed to notify LLM provider", e)
739
- # Don't fail the publish
740
-
741
- return {"success": True}
742
-
743
- @router.delete("/projects/{project_id}/versions/{version_no}")
744
- @handle_exceptions
745
- async def delete_version(
746
- project_id: int,
747
- version_no: int,
748
- username: str = Depends(verify_token)
749
- ):
750
- """Delete version (soft delete)"""
751
- ConfigProvider.delete_version(project_id, version_no, username)
752
-
753
- log_info(f"✅ Version {version_no} deleted for project {project_id} by {username}")
754
- return {"success": True}
755
-
756
- @router.get("/projects/{project_name}/versions")
757
- @handle_exceptions
758
- async def get_project_versions(
759
- project_name: str,
760
- username: str = Depends(verify_token)
761
- ):
762
- """Get all versions of a project for testing"""
763
- cfg = ConfigProvider.get()
764
-
765
- # Find project
766
- project = next((p for p in cfg.projects if p.name == project_name), None)
767
- if not project:
768
- raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found")
769
-
770
- # Return versions with their status
771
- versions = []
772
- for v in project.versions:
773
- if not getattr(v, 'deleted', False):
774
- versions.append({
775
- "version_number": v.no,
776
- "caption": v.caption,
777
- "published": v.published,
778
- "description": getattr(v, 'description', ''),
779
- "intent_count": len(v.intents),
780
- "created_date": getattr(v, 'created_date', None),
781
- "is_current": v.published # Published version is current
782
- })
783
-
784
- return {
785
- "project_name": project_name,
786
- "project_caption": project.caption,
787
- "versions": versions
788
- }
789
-
790
- @router.get("/projects/{project_id}/versions/{version1_id}/compare/{version2_id}")
791
- @handle_exceptions
792
- async def compare_versions(
793
- project_id: int,
794
- version1_no: int,
795
- version2_no: int,
796
- username: str = Depends(verify_token)
797
- ):
798
- """Compare two versions and return differences"""
799
- project = ConfigProvider.get_project(project_id)
800
- if not project:
801
- raise HTTPException(status_code=404, detail="Project not found")
802
-
803
- v1 = next((v for v in project.versions if v.no == version1_no), None)
804
- v2 = next((v for v in project.versions if v.no == version2_no), None)
805
-
806
- if not v1 or not v2:
807
- raise HTTPException(status_code=404, detail="Version not found")
808
-
809
- # Deep comparison
810
- differences = {
811
- 'general_prompt': {
812
- 'changed': v1.general_prompt != v2.general_prompt,
813
- 'v1': v1.general_prompt,
814
- 'v2': v2.general_prompt
815
- },
816
- 'intents': {
817
- 'added': [],
818
- 'removed': [],
819
- 'modified': []
820
- }
821
- }
822
-
823
- # Compare intents
824
- v1_intents = {i.name: i for i in v1.intents}
825
- v2_intents = {i.name: i for i in v2.intents}
826
-
827
- # Find added/removed
828
- differences['intents']['added'] = list(set(v2_intents.keys()) - set(v1_intents.keys()))
829
- differences['intents']['removed'] = list(set(v1_intents.keys()) - set(v2_intents.keys()))
830
-
831
- # Find modified
832
- for intent_name in set(v1_intents.keys()) & set(v2_intents.keys()):
833
- i1, i2 = v1_intents[intent_name], v2_intents[intent_name]
834
- if i1.model_dump() != i2.model_dump():
835
- differences['intents']['modified'].append({
836
- 'name': intent_name,
837
- 'differences': compare_intent_details(i1, i2)
838
- })
839
-
840
- log_info(
841
- f"Version comparison performed",
842
- user=username,
843
- project_id=project_id,
844
- version1_id=version1_id,
845
- version2_id=version2_id
846
- )
847
-
848
- return differences
849
-
850
- # ===================== API Endpoints =====================
851
- @router.get("/apis")
852
- @handle_exceptions
853
- async def list_apis(
854
- include_deleted: bool = False,
855
- username: str = Depends(verify_token)
856
- ):
857
- """List all APIs"""
858
- cfg = ConfigProvider.get()
859
- apis = cfg.apis
860
-
861
- # Filter deleted if needed
862
- if not include_deleted:
863
- apis = [a for a in apis if not getattr(a, 'deleted', False)]
864
-
865
- return [a.model_dump() for a in apis]
866
-
867
- @router.post("/apis")
868
- @handle_exceptions
869
- async def create_api(api: APICreate, username: str = Depends(verify_token)):
870
- """Create new API"""
871
- try:
872
- new_api = ConfigProvider.create_api(api.model_dump(), username)
873
-
874
- log_info(f"✅ API '{api.name}' created by {username}")
875
- return new_api.model_dump()
876
- except DuplicateResourceError as e:
877
- # DuplicateResourceError'ı handle et
878
- raise HTTPException(status_code=409, detail=str(e))
879
-
880
- @router.put("/apis/{api_name}")
881
- @handle_exceptions
882
- async def update_api(
883
- api_name: str,
884
- update: APIUpdate,
885
- username: str = Depends(verify_token)
886
- ):
887
- """Update API configuration with race condition handling"""
888
- result = ConfigProvider.update_api(
889
- api_name,
890
- update.model_dump(),
891
- username,
892
- expected_last_update=update.last_update_date
893
- )
894
-
895
- log_info(f"✅ API '{api_name}' updated by {username}")
896
- return result
897
-
898
- @router.delete("/apis/{api_name}")
899
- @handle_exceptions
900
- async def delete_api(api_name: str, username: str = Depends(verify_token)):
901
- """Delete API (soft delete)"""
902
- ConfigProvider.delete_api(api_name, username)
903
-
904
- log_info(f"✅ API '{api_name}' deleted by {username}")
905
- return {"success": True}
906
-
907
- @router.post("/validate/regex")
908
- @handle_exceptions
909
- async def validate_regex(
910
- request: dict = Body(...),
911
- username: str = Depends(verify_token)
912
- ):
913
- """Validate regex pattern"""
914
- pattern = request.get("pattern", "")
915
- test_value = request.get("test_value", "")
916
-
917
- import re
918
- compiled_regex = re.compile(pattern)
919
- matches = bool(compiled_regex.match(test_value))
920
-
921
- return {
922
- "valid": True,
923
- "matches": matches,
924
- "pattern": pattern,
925
- "test_value": test_value
926
- }
927
-
928
- # ===================== Test Endpoints =====================
929
- @router.post("/test/run-all")
930
- @handle_exceptions
931
- async def run_all_tests(
932
- request: TestRequest,
933
- username: str = Depends(verify_token)
934
- ):
935
- """Run all tests"""
936
- log_info(f"🧪 Running {request.test_type} tests requested by {username}")
937
-
938
- # TODO: Implement test runner
939
- # For now, return mock results
940
- return {
941
- "test_run_id": "test_" + datetime.now().isoformat(),
942
- "status": "running",
943
- "total_tests": 60,
944
- "completed": 0,
945
- "passed": 0,
946
- "failed": 0,
947
- "message": "Test run started"
948
- }
949
-
950
- @router.get("/test/status/{test_run_id}")
951
- @handle_exceptions
952
- async def get_test_status(
953
- test_run_id: str,
954
- username: str = Depends(verify_token)
955
- ):
956
- """Get test run status"""
957
- # TODO: Implement test status tracking
958
- return {
959
- "test_run_id": test_run_id,
960
- "status": "completed",
961
- "total_tests": 60,
962
- "completed": 60,
963
- "passed": 57,
964
- "failed": 3,
965
- "duration": 340.5,
966
- "details": []
967
- }
968
-
969
- # ===================== Activity Log =====================
970
- @router.get("/activity-log")
971
- @handle_exceptions
972
- async def get_activity_log(
973
- limit: int = Query(100, ge=1, le=1000),
974
- entity_type: Optional[str] = None,
975
- username: str = Depends(verify_token)
976
- ):
977
- """Get activity log"""
978
- cfg = ConfigProvider.get()
979
- logs = cfg.activity_log
980
-
981
- # Filter by entity type if specified
982
- if entity_type:
983
- logs = [l for l in logs if l.entity_type == entity_type]
984
-
985
- # Return most recent entries
986
- return logs[-limit:]
987
-
988
- # ===================== Helper Functions =====================
989
- async def notify_llm_startup(project, version):
990
- """Notify LLM provider about project startup"""
991
- from llm.llm_factory import LLMFactory
992
-
993
- try:
994
- llm_provider = LLMFactory.create_provider()
995
-
996
- # Build project config for startup
997
- project_config = {
998
- "name": project.name,
999
- "version_no": version.no,
1000
- "repo_id": version.llm.repo_id,
1001
- "generation_config": version.llm.generation_config,
1002
- "use_fine_tune": version.llm.use_fine_tune,
1003
- "fine_tune_zip": version.llm.fine_tune_zip
1004
- }
1005
-
1006
- success = await llm_provider.startup(project_config)
1007
- if success:
1008
- log_info(f"✅ LLM provider notified for project '{project.name}'")
1009
- else:
1010
- log_info(f"⚠️ LLM provider notification failed for project '{project.name}'")
1011
-
1012
- except Exception as e:
1013
- log_error("❌ Error notifying LLM provider", e)
1014
- raise
1015
-
1016
- # ===================== Cleanup Task =====================
1017
- def cleanup_activity_log():
1018
- """Cleanup old activity log entries"""
1019
- while True:
1020
- try:
1021
- cfg = ConfigProvider.get()
1022
-
1023
- # Keep only last 30 days
1024
- cutoff = datetime.now() - timedelta(days=30)
1025
- cutoff_str = cutoff.isoformat()
1026
-
1027
- original_count = len(cfg.activity_log)
1028
- cfg.activity_log = [
1029
- log for log in cfg.activity_log
1030
- if hasattr(log, 'timestamp') and str(log.timestamp) >= cutoff_str
1031
- ]
1032
-
1033
- if len(cfg.activity_log) < original_count:
1034
- removed = original_count - len(cfg.activity_log)
1035
- log_info(f"🧹 Cleaned up {removed} old activity log entries")
1036
- # ConfigProvider.save(cfg, "system") kullanmalıyız
1037
- ConfigProvider.save(cfg, "system")
1038
-
1039
- except Exception as e:
1040
- log_error("❌ Activity log cleanup error", e)
1041
-
1042
- # Run every hour
1043
- time.sleep(3600)
1044
-
1045
- def start_cleanup_task():
1046
- """Start the cleanup task in background"""
1047
- thread = threading.Thread(target=cleanup_activity_log, daemon=True)
1048
- thread.start()
1049
  log_info("🧹 Activity log cleanup task started")
 
1
+ """Admin API endpoints for Flare (Refactored)
2
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
3
+ Provides authentication, project, version, and API management endpoints with provider support.
4
+ """
5
+
6
+ import os
7
+ import time
8
+ import threading
9
+ import hashlib
10
+ import bcrypt
11
+ from typing import Optional, Dict, List, Any
12
+ from datetime import datetime, timedelta, timezone
13
+ from fastapi import APIRouter, HTTPException, Depends, Query, Response, Body
14
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
15
+ from pydantic import BaseModel, Field
16
+ import httpx
17
+ from functools import wraps
18
+
19
+ from utils.utils import verify_token, create_token, get_current_timestamp
20
+ from config.config_provider import ConfigProvider
21
+ from utils.logger import log_info, log_error, log_warning, log_debug
22
+ from utils.exceptions import (
23
+ FlareException,
24
+ RaceConditionError,
25
+ ValidationError,
26
+ ResourceNotFoundError,
27
+ AuthenticationError,
28
+ AuthorizationError,
29
+ DuplicateResourceError
30
+ )
31
+ from config.config_models import VersionConfig, IntentConfig, LLMConfiguration
32
+
33
+ # ===================== Constants & Config =====================
34
+ security = HTTPBearer()
35
+ router = APIRouter(tags=["admin"])
36
+
37
+ # ===================== Decorators =====================
38
+ def handle_exceptions(func):
39
+ """Decorator to handle exceptions consistently"""
40
+ @wraps(func)
41
+ async def wrapper(*args, **kwargs):
42
+ try:
43
+ return await func(*args, **kwargs)
44
+ except HTTPException:
45
+ # HTTPException'ları olduğu gibi geçir
46
+ raise
47
+ except FlareException:
48
+ # Let global handlers deal with our custom exceptions
49
+ raise
50
+ except Exception as e:
51
+ # Log and convert unexpected exceptions to HTTP 500
52
+ log_error(f"❌ Unexpected error in {func.__name__}", e)
53
+ raise HTTPException(status_code=500, detail=str(e))
54
+ return wrapper
55
+
56
+ # ===================== Models =====================
57
+ class LoginRequest(BaseModel):
58
+ username: str
59
+ password: str
60
+
61
+ class LoginResponse(BaseModel):
62
+ token: str
63
+ username: str
64
+
65
+ class ChangePasswordRequest(BaseModel):
66
+ current_password: str
67
+ new_password: str
68
+
69
+ class ProviderSettingsUpdate(BaseModel):
70
+ name: str
71
+ api_key: Optional[str] = None
72
+ endpoint: Optional[str] = None
73
+ settings: Dict[str, Any] = Field(default_factory=dict)
74
+
75
+ class EnvironmentUpdate(BaseModel):
76
+ llm_provider: ProviderSettingsUpdate
77
+ tts_provider: ProviderSettingsUpdate
78
+ stt_provider: ProviderSettingsUpdate
79
+ parameter_collection_config: Optional[Dict[str, Any]] = None
80
+
81
+ class ProjectCreate(BaseModel):
82
+ name: str
83
+ caption: Optional[str] = ""
84
+ icon: Optional[str] = "folder"
85
+ description: Optional[str] = ""
86
+ default_locale: str = "tr"
87
+ supported_locales: List[str] = Field(default_factory=lambda: ["tr"])
88
+ timezone: str = "Europe/Istanbul"
89
+ region: str = "tr-TR"
90
+
91
+ class ProjectUpdate(BaseModel):
92
+ caption: str
93
+ icon: Optional[str] = "folder"
94
+ description: Optional[str] = ""
95
+ default_locale: str = "tr"
96
+ supported_locales: List[str] = Field(default_factory=lambda: ["tr"])
97
+ timezone: str = "Europe/Istanbul"
98
+ region: str = "tr-TR"
99
+ last_update_date: str
100
+
101
+ class VersionCreate(BaseModel):
102
+ caption: str
103
+ source_version_no: int | None = None
104
+
105
+ class IntentModel(BaseModel):
106
+ name: str
107
+ caption: Optional[str] = ""
108
+ detection_prompt: str
109
+ examples: List[Dict[str, str]] = [] # LocalizedExample format
110
+ parameters: List[Dict[str, Any]] = []
111
+ action: str
112
+ fallback_timeout_prompt: Optional[str] = None
113
+ fallback_error_prompt: Optional[str] = None
114
+
115
+ class VersionUpdate(BaseModel):
116
+ caption: str
117
+ general_prompt: str
118
+ llm: Dict[str, Any]
119
+ intents: List[IntentModel]
120
+ last_update_date: str
121
+
122
+ class APICreate(BaseModel):
123
+ name: str
124
+ url: str
125
+ method: str = "POST"
126
+ headers: Dict[str, str] = {}
127
+ body_template: Dict[str, Any] = {}
128
+ timeout_seconds: int = 10
129
+ retry: Dict[str, Any] = Field(default_factory=lambda: {"retry_count": 3, "backoff_seconds": 2, "strategy": "static"})
130
+ proxy: Optional[str] = None
131
+ auth: Optional[Dict[str, Any]] = None
132
+ response_prompt: Optional[str] = None
133
+ response_mappings: List[Dict[str, Any]] = []
134
+
135
+ class APIUpdate(BaseModel):
136
+ url: str
137
+ method: str
138
+ headers: Dict[str, str]
139
+ body_template: Dict[str, Any]
140
+ timeout_seconds: int
141
+ retry: Dict[str, Any]
142
+ proxy: Optional[str]
143
+ auth: Optional[Dict[str, Any]]
144
+ response_prompt: Optional[str]
145
+ response_mappings: List[Dict[str, Any]] = []
146
+ last_update_date: str
147
+
148
+ class TestRequest(BaseModel):
149
+ test_type: str # "all", "ui", "backend", "integration", "spark"
150
+
151
+ # ===================== Auth Endpoints =====================
152
+ @router.post("/login", response_model=LoginResponse)
153
+ @handle_exceptions
154
+ async def login(request: LoginRequest):
155
+ """User login endpoint"""
156
+ cfg = ConfigProvider.get()
157
+
158
+ # Find user
159
+ user = next((u for u in cfg.global_config.users if u.username == request.username), None)
160
+ if not user:
161
+ raise HTTPException(status_code=401, detail="Invalid credentials")
162
+
163
+ # Verify password - Try both bcrypt and SHA256 for backward compatibility
164
+ password_valid = False
165
+
166
+ # First try bcrypt (new format)
167
+ try:
168
+ if user.password_hash.startswith("$2b$") or user.password_hash.startswith("$2a$"):
169
+ password_valid = bcrypt.checkpw(request.password.encode('utf-8'), user.password_hash.encode('utf-8'))
170
+ except:
171
+ pass
172
+
173
+ # If not valid, try SHA256 (old format)
174
+ if not password_valid:
175
+ sha256_hash = hashlib.sha256(request.password.encode('utf-8')).hexdigest()
176
+ password_valid = (user.password_hash == sha256_hash)
177
+
178
+ if not password_valid:
179
+ raise HTTPException(status_code=401, detail="Invalid credentials")
180
+
181
+ # Create token
182
+ token = create_token(request.username)
183
+
184
+ log_info(f"✅ User '{request.username}' logged in successfully")
185
+ return LoginResponse(token=token, username=request.username)
186
+
187
+ @router.post("/change-password")
188
+ @handle_exceptions
189
+ async def change_password(
190
+ request: ChangePasswordRequest,
191
+ username: str = Depends(verify_token)
192
+ ):
193
+ """Change user password"""
194
+ cfg = ConfigProvider.get()
195
+
196
+ # Find user
197
+ user = next((u for u in cfg.global_config.users if u.username == username), None)
198
+ if not user:
199
+ raise HTTPException(status_code=404, detail="User not found")
200
+
201
+ # Verify current password - Try both bcrypt and SHA256 for backward compatibility
202
+ password_valid = False
203
+
204
+ # First try bcrypt (new format)
205
+ try:
206
+ if user.password_hash.startswith("$2b$") or user.password_hash.startswith("$2a$"):
207
+ password_valid = bcrypt.checkpw(request.current_password.encode('utf-8'), user.password_hash.encode('utf-8'))
208
+ except:
209
+ pass
210
+
211
+ # If not valid, try SHA256 (old format)
212
+ if not password_valid:
213
+ sha256_hash = hashlib.sha256(request.current_password.encode('utf-8')).hexdigest()
214
+ password_valid = (user.password_hash == sha256_hash)
215
+
216
+ if not password_valid:
217
+ raise HTTPException(status_code=401, detail="Current password is incorrect")
218
+
219
+ # Generate new password hash (always use bcrypt for new passwords)
220
+ salt = bcrypt.gensalt()
221
+ new_hash = bcrypt.hashpw(request.new_password.encode('utf-8'), salt)
222
+
223
+ # Update user
224
+ user.password_hash = new_hash.decode('utf-8')
225
+ user.salt = salt.decode('utf-8')
226
+
227
+ # Save configuration via ConfigProvider
228
+ ConfigProvider.save(cfg, username)
229
+
230
+ log_info(f"✅ Password changed for user '{username}'")
231
+ return {"success": True}
232
+
233
+ # ===================== Locales Endpoints =====================
234
+ @router.get("/locales")
235
+ @handle_exceptions
236
+ async def get_available_locales(username: str = Depends(verify_token)):
237
+ """Get all system-supported locales"""
238
+ from config.locale_manager import LocaleManager
239
+
240
+ locales = LocaleManager.get_available_locales_with_names()
241
+
242
+ return {
243
+ "locales": locales,
244
+ "default": LocaleManager.get_default_locale()
245
+ }
246
+
247
+ @router.get("/locales/{locale_code}")
248
+ @handle_exceptions
249
+ async def get_locale_details(
250
+ locale_code: str,
251
+ username: str = Depends(verify_token)
252
+ ):
253
+ """Get detailed information for a specific locale"""
254
+ from config.locale_manager import LocaleManager
255
+
256
+ locale_info = LocaleManager.get_locale_details(locale_code)
257
+
258
+ if not locale_info:
259
+ raise HTTPException(status_code=404, detail=f"Locale '{locale_code}' not found")
260
+
261
+ return locale_info
262
+
263
+ # ===================== Environment Endpoints =====================
264
+ @router.get("/environment")
265
+ @handle_exceptions
266
+ async def get_environment(username: str = Depends(verify_token)):
267
+ """Get environment configuration with provider info"""
268
+ cfg = ConfigProvider.get()
269
+ env_config = cfg.global_config
270
+
271
+ # Provider tabanlı yeni yapıyı destekle
272
+ response = {}
273
+
274
+ # LLM Provider
275
+ if hasattr(env_config, 'llm_provider'):
276
+ response["llm_provider"] = env_config.llm_provider
277
+
278
+ # TTS Provider
279
+ if hasattr(env_config, 'tts_provider'):
280
+ response["tts_provider"] = env_config.tts_provider
281
+
282
+ # STT Provider
283
+ if hasattr(env_config, 'stt_provider'):
284
+ response["stt_provider"] = env_config.stt_provider
285
+ else:
286
+ response["stt_provider"] = {
287
+ "name": getattr(env_config, 'stt_engine', 'no_stt'),
288
+ "api_key": getattr(env_config, 'stt_engine_api_key', None) or "",
289
+ "endpoint": None,
290
+ "settings": getattr(env_config, 'stt_settings', {})
291
+ }
292
+
293
+ # Provider listesi
294
+ if hasattr(env_config, 'providers'):
295
+ providers_list = []
296
+ for provider in env_config.providers:
297
+ providers_list.append(provider)
298
+ response["providers"] = providers_list
299
+ else:
300
+ # Varsayılan provider listesi
301
+ response["providers"] = [
302
+ {
303
+ "type": "llm",
304
+ "name": "spark_cloud",
305
+ "display_name": "Spark LLM (Cloud)",
306
+ "requires_endpoint": True,
307
+ "requires_api_key": True,
308
+ "requires_repo_info": False
309
+ },
310
+ {
311
+ "type": "llm",
312
+ "name": "gpt-4o",
313
+ "display_name": "GPT-4o",
314
+ "requires_endpoint": True,
315
+ "requires_api_key": True,
316
+ "requires_repo_info": False
317
+ },
318
+ {
319
+ "type": "llm",
320
+ "name": "gpt-4o-mini",
321
+ "display_name": "GPT-4o Mini",
322
+ "requires_endpoint": True,
323
+ "requires_api_key": True,
324
+ "requires_repo_info": False
325
+ },
326
+ {
327
+ "type": "tts",
328
+ "name": "no_tts",
329
+ "display_name": "No TTS",
330
+ "requires_endpoint": False,
331
+ "requires_api_key": False,
332
+ "requires_repo_info": False
333
+ },
334
+ {
335
+ "type": "tts",
336
+ "name": "elevenlabs",
337
+ "display_name": "ElevenLabs",
338
+ "requires_endpoint": False,
339
+ "requires_api_key": True,
340
+ "requires_repo_info": False
341
+ },
342
+ {
343
+ "type": "stt",
344
+ "name": "no_stt",
345
+ "display_name": "No STT",
346
+ "requires_endpoint": False,
347
+ "requires_api_key": False,
348
+ "requires_repo_info": False
349
+ },
350
+ {
351
+ "type": "stt",
352
+ "name": "google",
353
+ "display_name": "Google Cloud STT",
354
+ "requires_endpoint": False,
355
+ "requires_api_key": True,
356
+ "requires_repo_info": False
357
+ }
358
+ ]
359
+
360
+ # Parameter collection config
361
+ if hasattr(env_config, 'parameter_collection_config'):
362
+ response["parameter_collection_config"] = env_config.parameter_collection_config
363
+ else:
364
+ # Varsayılan değerler
365
+ response["parameter_collection_config"] = {
366
+ "max_params_per_question": 2,
367
+ "retry_unanswered": True,
368
+ "smart_grouping": True,
369
+ "collection_prompt": "You are a helpful assistant collecting information from the user..."
370
+ }
371
+
372
+ return response
373
+
374
+ @router.put("/environment")
375
+ @handle_exceptions
376
+ async def update_environment(
377
+ update: EnvironmentUpdate,
378
+ username: str = Depends(verify_token)
379
+ ):
380
+ """Update environment configuration with provider validation"""
381
+ log_info(f"📝 Updating environment config by {username}")
382
+
383
+ cfg = ConfigProvider.get()
384
+
385
+ # Validate LLM provider
386
+ llm_provider_def = cfg.global_config.get_provider_config("llm", update.llm_provider.name)
387
+ if not llm_provider_def:
388
+ raise HTTPException(status_code=400, detail=f"Unknown LLM provider: {update.llm_provider.name}")
389
+
390
+ if llm_provider_def.requires_api_key and not update.llm_provider.api_key:
391
+ raise HTTPException(status_code=400, detail=f"{llm_provider_def.display_name} requires API key")
392
+
393
+ if llm_provider_def.requires_endpoint and not update.llm_provider.endpoint:
394
+ raise HTTPException(status_code=400, detail=f"{llm_provider_def.display_name} requires endpoint")
395
+
396
+ # Validate TTS provider
397
+ tts_provider_def = cfg.global_config.get_provider_config("tts", update.tts_provider.name)
398
+ if not tts_provider_def:
399
+ raise HTTPException(status_code=400, detail=f"Unknown TTS provider: {update.tts_provider.name}")
400
+
401
+ if tts_provider_def.requires_api_key and not update.tts_provider.api_key:
402
+ raise HTTPException(status_code=400, detail=f"{tts_provider_def.display_name} requires API key")
403
+
404
+ # Validate STT provider
405
+ stt_provider_def = cfg.global_config.get_provider_config("stt", update.stt_provider.name)
406
+ if not stt_provider_def:
407
+ raise HTTPException(status_code=400, detail=f"Unknown STT provider: {update.stt_provider.name}")
408
+
409
+ if stt_provider_def.requires_api_key and not update.stt_provider.api_key:
410
+ raise HTTPException(status_code=400, detail=f"{stt_provider_def.display_name} requires API key")
411
+
412
+ # Update via ConfigProvider
413
+ ConfigProvider.update_environment(update.model_dump(), username)
414
+
415
+ log_info(f"✅ Environment updated to LLM: {update.llm_provider.name}, TTS: {update.tts_provider.name}, STT: {update.stt_provider.name} by {username}")
416
+ return {"success": True}
417
+
418
+ # ===================== Project Endpoints =====================
419
+ @router.get("/projects/names")
420
+ @handle_exceptions
421
+ async def list_enabled_projects():
422
+ """Get list of enabled project names for chat"""
423
+ cfg = ConfigProvider.get()
424
+ return [p.name for p in cfg.projects if p.enabled and not getattr(p, 'deleted', False)]
425
+
426
+ @router.get("/projects")
427
+ @handle_exceptions
428
+ async def list_projects(
429
+ include_deleted: bool = False,
430
+ username: str = Depends(verify_token)
431
+ ):
432
+ """List all projects"""
433
+ cfg = ConfigProvider.get()
434
+ projects = cfg.projects
435
+
436
+ # Filter deleted if needed
437
+ if not include_deleted:
438
+ projects = [p for p in projects if not getattr(p, 'deleted', False)]
439
+
440
+ return [p.model_dump() for p in projects]
441
+
442
+ @router.get("/projects/{project_id}")
443
+ @handle_exceptions
444
+ async def get_project(
445
+ project_id: int,
446
+ username: str = Depends(verify_token)
447
+ ):
448
+ """Get single project by ID"""
449
+ project = ConfigProvider.get_project(project_id)
450
+ if not project or getattr(project, 'deleted', False):
451
+ raise HTTPException(status_code=404, detail="Project not found")
452
+
453
+ return project.model_dump()
454
+
455
+ @router.post("/projects")
456
+ @handle_exceptions
457
+ async def create_project(
458
+ project: ProjectCreate,
459
+ username: str = Depends(verify_token)
460
+ ):
461
+ """Create new project with initial version"""
462
+ # Validate supported locales
463
+ from config.locale_manager import LocaleManager
464
+
465
+ invalid_locales = LocaleManager.validate_project_languages(project.supported_locales)
466
+ if invalid_locales:
467
+ available_locales = LocaleManager.get_available_locales_with_names()
468
+ available_codes = [locale['code'] for locale in available_locales]
469
+ raise HTTPException(
470
+ status_code=400,
471
+ detail=f"Unsupported locales: {', '.join(invalid_locales)}. Available locales: {', '.join(available_codes)}"
472
+ )
473
+
474
+ # Check if default locale is in supported locales
475
+ if project.default_locale not in project.supported_locales:
476
+ raise HTTPException(
477
+ status_code=400,
478
+ detail="Default locale must be one of the supported locales"
479
+ )
480
+
481
+ # Debug log for project creation
482
+ log_debug(f"🔍 Creating project '{project.name}' with default_locale: {project.default_locale}")
483
+
484
+ new_project = ConfigProvider.create_project(project.model_dump(), username)
485
+
486
+ # Debug log for initial version
487
+ if new_project.versions:
488
+ initial_version = new_project.versions[0]
489
+ log_debug(f"🔍 Initial version created - no: {initial_version.no}, published: {initial_version.published}, type: {type(initial_version.published)}")
490
+
491
+ log_info(f"✅ Project '{project.name}' created by {username}")
492
+ return new_project.model_dump()
493
+
494
+ @router.put("/projects/{project_id}")
495
+ @handle_exceptions
496
+ async def update_project(
497
+ project_id: int,
498
+ update: ProjectUpdate,
499
+ username: str = Depends(verify_token)
500
+ ):
501
+ """Update existing project with race condition handling"""
502
+ log_info(f"🔍 Update request for project {project_id} by {username}")
503
+ log_info(f"🔍 Received last_update_date: {update.last_update_date}")
504
+
505
+ # Mevcut project'i al ve durumunu logla
506
+ current_project = ConfigProvider.get_project(project_id)
507
+ if current_project:
508
+ log_info(f"🔍 Current project last_update_date: {current_project.last_update_date}")
509
+ log_info(f"🔍 Current project last_update_user: {current_project.last_update_user}")
510
+
511
+ # Optimistic locking kontrolü
512
+ result = ConfigProvider.update_project(
513
+ project_id,
514
+ update.model_dump(),
515
+ username,
516
+ expected_last_update=update.last_update_date
517
+ )
518
+
519
+ log_info(f"✅ Project {project_id} updated by {username}")
520
+ return result
521
+
522
+ @router.delete("/projects/{project_id}")
523
+ @handle_exceptions
524
+ async def delete_project(project_id: int, username: str = Depends(verify_token)):
525
+ """Delete project (soft delete)"""
526
+ ConfigProvider.delete_project(project_id, username)
527
+
528
+ log_info(f"✅ Project deleted by {username}")
529
+ return {"success": True}
530
+
531
+ @router.patch("/projects/{project_id}/toggle")
532
+ async def toggle_project(project_id: int, username: str = Depends(verify_token)):
533
+ """Toggle project enabled status"""
534
+ enabled = ConfigProvider.toggle_project(project_id, username)
535
+
536
+ log_info(f"✅ Project {'enabled' if enabled else 'disabled'} by {username}")
537
+ return {"enabled": enabled}
538
+
539
+ # ===================== Import/Export Endpoints =====================
540
+ @router.get("/projects/{project_id}/export")
541
+ @handle_exceptions
542
+ async def export_project(
543
+ project_id: int,
544
+ username: str = Depends(verify_token)
545
+ ):
546
+ """Export project as JSON"""
547
+ project = ConfigProvider.get_project(project_id)
548
+ if not project:
549
+ raise HTTPException(status_code=404, detail="Project not found")
550
+
551
+ # Prepare export data
552
+ export_data = {
553
+ "name": project.name,
554
+ "caption": project.caption,
555
+ "icon": project.icon,
556
+ "description": project.description,
557
+ "default_locale": project.default_locale,
558
+ "supported_locales": project.supported_locales,
559
+ "timezone": project.timezone,
560
+ "region": project.region,
561
+ "versions": []
562
+ }
563
+
564
+ # Add versions (only non-deleted)
565
+ for version in project.versions:
566
+ if not getattr(version, 'deleted', False):
567
+ version_data = {
568
+ "caption": version.caption,
569
+ "description": getattr(version, 'description', ''),
570
+ "general_prompt": version.general_prompt,
571
+ "welcome_prompt": getattr(version, 'welcome_prompt', None),
572
+ "llm": version.llm.model_dump() if version.llm else {},
573
+ "intents": [intent.model_dump() for intent in version.intents]
574
+ }
575
+ export_data["versions"].append(version_data)
576
+
577
+ log_info(f"✅ Project '{project.name}' exported by {username}")
578
+
579
+ return export_data
580
+
581
+ @router.post("/projects/import")
582
+ @handle_exceptions
583
+ async def import_project(
584
+ project_data: dict = Body(...),
585
+ username: str = Depends(verify_token)
586
+ ):
587
+ """Import project from JSON"""
588
+ # Validate required fields
589
+ if not project_data.get('name'):
590
+ raise HTTPException(status_code=400, detail="Project name is required")
591
+
592
+ # Check for duplicate name
593
+ cfg = ConfigProvider.get()
594
+ if any(p.name == project_data['name'] for p in cfg.projects if not p.deleted):
595
+ raise HTTPException(
596
+ status_code=409,
597
+ detail=f"Project with name '{project_data['name']}' already exists"
598
+ )
599
+
600
+ # Create project
601
+ new_project_data = {
602
+ "name": project_data['name'],
603
+ "caption": project_data.get('caption', project_data['name']),
604
+ "icon": project_data.get('icon', 'folder'),
605
+ "description": project_data.get('description', ''),
606
+ "default_locale": project_data.get('default_locale', 'tr'),
607
+ "supported_locales": project_data.get('supported_locales', ['tr']),
608
+ "timezone": project_data.get('timezone', 'Europe/Istanbul'),
609
+ "region": project_data.get('region', 'tr-TR')
610
+ }
611
+
612
+ # Create project
613
+ new_project = ConfigProvider.create_project(new_project_data, username)
614
+
615
+ # Import versions
616
+ if 'versions' in project_data and project_data['versions']:
617
+ # Remove the initial version that was auto-created
618
+ if new_project.versions:
619
+ new_project.versions.clear()
620
+
621
+ # Add imported versions
622
+ for idx, version_data in enumerate(project_data['versions']):
623
+ version = VersionConfig(
624
+ no=idx + 1,
625
+ caption=version_data.get('caption', f'Version {idx + 1}'),
626
+ description=version_data.get('description', ''),
627
+ published=False, # Imported versions are unpublished
628
+ deleted=False,
629
+ general_prompt=version_data.get('general_prompt', ''),
630
+ welcome_prompt=version_data.get('welcome_prompt'),
631
+ llm=LLMConfiguration(**version_data.get('llm', {
632
+ 'repo_id': '',
633
+ 'generation_config': {
634
+ 'max_new_tokens': 512,
635
+ 'temperature': 0.7,
636
+ 'top_p': 0.9
637
+ },
638
+ 'use_fine_tune': False,
639
+ 'fine_tune_zip': ''
640
+ })),
641
+ intents=[IntentConfig(**intent) for intent in version_data.get('intents', [])],
642
+ created_date=get_current_timestamp(),
643
+ created_by=username
644
+ )
645
+ new_project.versions.append(version)
646
+
647
+ # Update version counter
648
+ new_project.version_id_counter = len(new_project.versions) + 1
649
+
650
+ # Save updated project
651
+ ConfigProvider.save(cfg, username)
652
+
653
+ log_info(f"✅ Project '{new_project.name}' imported by {username}")
654
+
655
+ return {"success": True, "project_id": new_project.id, "project_name": new_project.name}
656
+
657
+ # ===================== Version Endpoints =====================
658
+ @router.get("/projects/{project_id}/versions")
659
+ @handle_exceptions
660
+ async def list_versions(
661
+ project_id: int,
662
+ include_deleted: bool = False,
663
+ username: str = Depends(verify_token)
664
+ ):
665
+ """List project versions"""
666
+ project = ConfigProvider.get_project(project_id)
667
+ if not project:
668
+ raise HTTPException(status_code=404, detail="Project not found")
669
+
670
+ versions = project.versions
671
+
672
+ # Filter deleted if needed
673
+ if not include_deleted:
674
+ versions = [v for v in versions if not getattr(v, 'deleted', False)]
675
+
676
+ return [v.model_dump() for v in versions]
677
+
678
+ @router.post("/projects/{project_id}/versions")
679
+ @handle_exceptions
680
+ async def create_version(
681
+ project_id: int,
682
+ version_data: VersionCreate,
683
+ username: str = Depends(verify_token)
684
+ ):
685
+ """Create new version"""
686
+ new_version = ConfigProvider.create_version(project_id, version_data.model_dump(), username)
687
+
688
+ log_info(f"✅ Version created for project {project_id} by {username}")
689
+ return new_version.model_dump()
690
+
691
+ @router.put("/projects/{project_id}/versions/{version_no}")
692
+ @handle_exceptions
693
+ async def update_version(
694
+ project_id: int,
695
+ version_no: int,
696
+ update: VersionUpdate,
697
+ force: bool = Query(default=False, description="Force update despite conflicts"),
698
+ username: str = Depends(verify_token)
699
+ ):
700
+ """Update version with race condition handling"""
701
+ log_debug(f"🔍 Version update request - project: {project_id}, version: {version_no}, user: {username}")
702
+
703
+ # Force parametresi kontrolü
704
+ if force:
705
+ log_warning(f"⚠️ Force update requested for version {version_no} by {username}")
706
+
707
+ result = ConfigProvider.update_version(
708
+ project_id,
709
+ version_no,
710
+ update.model_dump(),
711
+ username,
712
+ expected_last_update=update.last_update_date if not force else None
713
+ )
714
+
715
+ log_info(f"✅ Version {version_no} updated by {username}")
716
+ return result
717
+
718
+ @router.post("/projects/{project_id}/versions/{version_no}/publish")
719
+ @handle_exceptions
720
+ async def publish_version(
721
+ project_id: int,
722
+ version_no: int,
723
+ username: str = Depends(verify_token)
724
+ ):
725
+ """Publish version"""
726
+ project, version = ConfigProvider.publish_version(project_id, version_no, username)
727
+
728
+ log_info(f"✅ Version {version_no} published for project '{project.name}' by {username}")
729
+
730
+ # Notify LLM provider if project is enabled and provider requires repo info
731
+ cfg = ConfigProvider.get()
732
+ llm_provider_def = cfg.global_config.get_provider_config("llm", cfg.global_config.llm_provider.name)
733
+
734
+ if project.enabled and llm_provider_def and llm_provider_def.requires_repo_info:
735
+ try:
736
+ await notify_llm_startup(project, version)
737
+ except Exception as e:
738
+ log_error(f"⚠️ Failed to notify LLM provider", e)
739
+ # Don't fail the publish
740
+
741
+ return {"success": True}
742
+
743
+ @router.delete("/projects/{project_id}/versions/{version_no}")
744
+ @handle_exceptions
745
+ async def delete_version(
746
+ project_id: int,
747
+ version_no: int,
748
+ username: str = Depends(verify_token)
749
+ ):
750
+ """Delete version (soft delete)"""
751
+ ConfigProvider.delete_version(project_id, version_no, username)
752
+
753
+ log_info(f"✅ Version {version_no} deleted for project {project_id} by {username}")
754
+ return {"success": True}
755
+
756
+ @router.get("/projects/{project_name}/versions")
757
+ @handle_exceptions
758
+ async def get_project_versions(
759
+ project_name: str,
760
+ username: str = Depends(verify_token)
761
+ ):
762
+ """Get all versions of a project for testing"""
763
+ cfg = ConfigProvider.get()
764
+
765
+ # Find project
766
+ project = next((p for p in cfg.projects if p.name == project_name), None)
767
+ if not project:
768
+ raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found")
769
+
770
+ # Return versions with their status
771
+ versions = []
772
+ for v in project.versions:
773
+ if not getattr(v, 'deleted', False):
774
+ versions.append({
775
+ "version_number": v.no,
776
+ "caption": v.caption,
777
+ "published": v.published,
778
+ "description": getattr(v, 'description', ''),
779
+ "intent_count": len(v.intents),
780
+ "created_date": getattr(v, 'created_date', None),
781
+ "is_current": v.published # Published version is current
782
+ })
783
+
784
+ return {
785
+ "project_name": project_name,
786
+ "project_caption": project.caption,
787
+ "versions": versions
788
+ }
789
+
790
+ @router.get("/projects/{project_id}/versions/{version1_id}/compare/{version2_id}")
791
+ @handle_exceptions
792
+ async def compare_versions(
793
+ project_id: int,
794
+ version1_no: int,
795
+ version2_no: int,
796
+ username: str = Depends(verify_token)
797
+ ):
798
+ """Compare two versions and return differences"""
799
+ project = ConfigProvider.get_project(project_id)
800
+ if not project:
801
+ raise HTTPException(status_code=404, detail="Project not found")
802
+
803
+ v1 = next((v for v in project.versions if v.no == version1_no), None)
804
+ v2 = next((v for v in project.versions if v.no == version2_no), None)
805
+
806
+ if not v1 or not v2:
807
+ raise HTTPException(status_code=404, detail="Version not found")
808
+
809
+ # Deep comparison
810
+ differences = {
811
+ 'general_prompt': {
812
+ 'changed': v1.general_prompt != v2.general_prompt,
813
+ 'v1': v1.general_prompt,
814
+ 'v2': v2.general_prompt
815
+ },
816
+ 'intents': {
817
+ 'added': [],
818
+ 'removed': [],
819
+ 'modified': []
820
+ }
821
+ }
822
+
823
+ # Compare intents
824
+ v1_intents = {i.name: i for i in v1.intents}
825
+ v2_intents = {i.name: i for i in v2.intents}
826
+
827
+ # Find added/removed
828
+ differences['intents']['added'] = list(set(v2_intents.keys()) - set(v1_intents.keys()))
829
+ differences['intents']['removed'] = list(set(v1_intents.keys()) - set(v2_intents.keys()))
830
+
831
+ # Find modified
832
+ for intent_name in set(v1_intents.keys()) & set(v2_intents.keys()):
833
+ i1, i2 = v1_intents[intent_name], v2_intents[intent_name]
834
+ if i1.model_dump() != i2.model_dump():
835
+ differences['intents']['modified'].append({
836
+ 'name': intent_name,
837
+ 'differences': compare_intent_details(i1, i2)
838
+ })
839
+
840
+ log_info(
841
+ f"Version comparison performed",
842
+ user=username,
843
+ project_id=project_id,
844
+ version1_id=version1_id,
845
+ version2_id=version2_id
846
+ )
847
+
848
+ return differences
849
+
850
+ # ===================== API Endpoints =====================
851
+ @router.get("/apis")
852
+ @handle_exceptions
853
+ async def list_apis(
854
+ include_deleted: bool = False,
855
+ username: str = Depends(verify_token)
856
+ ):
857
+ """List all APIs"""
858
+ cfg = ConfigProvider.get()
859
+ apis = cfg.apis
860
+
861
+ # Filter deleted if needed
862
+ if not include_deleted:
863
+ apis = [a for a in apis if not getattr(a, 'deleted', False)]
864
+
865
+ return [a.model_dump() for a in apis]
866
+
867
+ @router.post("/apis")
868
+ @handle_exceptions
869
+ async def create_api(api: APICreate, username: str = Depends(verify_token)):
870
+ """Create new API"""
871
+ try:
872
+ new_api = ConfigProvider.create_api(api.model_dump(), username)
873
+
874
+ log_info(f"✅ API '{api.name}' created by {username}")
875
+ return new_api.model_dump()
876
+ except DuplicateResourceError as e:
877
+ # DuplicateResourceError'ı handle et
878
+ raise HTTPException(status_code=409, detail=str(e))
879
+
880
+ @router.put("/apis/{api_name}")
881
+ @handle_exceptions
882
+ async def update_api(
883
+ api_name: str,
884
+ update: APIUpdate,
885
+ username: str = Depends(verify_token)
886
+ ):
887
+ """Update API configuration with race condition handling"""
888
+ result = ConfigProvider.update_api(
889
+ api_name,
890
+ update.model_dump(),
891
+ username,
892
+ expected_last_update=update.last_update_date
893
+ )
894
+
895
+ log_info(f"✅ API '{api_name}' updated by {username}")
896
+ return result
897
+
898
+ @router.delete("/apis/{api_name}")
899
+ @handle_exceptions
900
+ async def delete_api(api_name: str, username: str = Depends(verify_token)):
901
+ """Delete API (soft delete)"""
902
+ ConfigProvider.delete_api(api_name, username)
903
+
904
+ log_info(f"✅ API '{api_name}' deleted by {username}")
905
+ return {"success": True}
906
+
907
+ @router.post("/validate/regex")
908
+ @handle_exceptions
909
+ async def validate_regex(
910
+ request: dict = Body(...),
911
+ username: str = Depends(verify_token)
912
+ ):
913
+ """Validate regex pattern"""
914
+ pattern = request.get("pattern", "")
915
+ test_value = request.get("test_value", "")
916
+
917
+ import re
918
+ compiled_regex = re.compile(pattern)
919
+ matches = bool(compiled_regex.match(test_value))
920
+
921
+ return {
922
+ "valid": True,
923
+ "matches": matches,
924
+ "pattern": pattern,
925
+ "test_value": test_value
926
+ }
927
+
928
+ # ===================== Test Endpoints =====================
929
+ @router.post("/test/run-all")
930
+ @handle_exceptions
931
+ async def run_all_tests(
932
+ request: TestRequest,
933
+ username: str = Depends(verify_token)
934
+ ):
935
+ """Run all tests"""
936
+ log_info(f"🧪 Running {request.test_type} tests requested by {username}")
937
+
938
+ # TODO: Implement test runner
939
+ # For now, return mock results
940
+ return {
941
+ "test_run_id": "test_" + datetime.now().isoformat(),
942
+ "status": "running",
943
+ "total_tests": 60,
944
+ "completed": 0,
945
+ "passed": 0,
946
+ "failed": 0,
947
+ "message": "Test run started"
948
+ }
949
+
950
+ @router.get("/test/status/{test_run_id}")
951
+ @handle_exceptions
952
+ async def get_test_status(
953
+ test_run_id: str,
954
+ username: str = Depends(verify_token)
955
+ ):
956
+ """Get test run status"""
957
+ # TODO: Implement test status tracking
958
+ return {
959
+ "test_run_id": test_run_id,
960
+ "status": "completed",
961
+ "total_tests": 60,
962
+ "completed": 60,
963
+ "passed": 57,
964
+ "failed": 3,
965
+ "duration": 340.5,
966
+ "details": []
967
+ }
968
+
969
+ # ===================== Activity Log =====================
970
+ @router.get("/activity-log")
971
+ @handle_exceptions
972
+ async def get_activity_log(
973
+ limit: int = Query(100, ge=1, le=1000),
974
+ entity_type: Optional[str] = None,
975
+ username: str = Depends(verify_token)
976
+ ):
977
+ """Get activity log"""
978
+ cfg = ConfigProvider.get()
979
+ logs = cfg.activity_log
980
+
981
+ # Filter by entity type if specified
982
+ if entity_type:
983
+ logs = [l for l in logs if l.entity_type == entity_type]
984
+
985
+ # Return most recent entries
986
+ return logs[-limit:]
987
+
988
+ # ===================== Helper Functions =====================
989
+ async def notify_llm_startup(project, version):
990
+ """Notify LLM provider about project startup"""
991
+ from llm.llm_factory import LLMFactory
992
+
993
+ try:
994
+ llm_provider = LLMFactory.create_provider()
995
+
996
+ # Build project config for startup
997
+ project_config = {
998
+ "name": project.name,
999
+ "version_no": version.no,
1000
+ "repo_id": version.llm.repo_id,
1001
+ "generation_config": version.llm.generation_config,
1002
+ "use_fine_tune": version.llm.use_fine_tune,
1003
+ "fine_tune_zip": version.llm.fine_tune_zip
1004
+ }
1005
+
1006
+ success = await llm_provider.startup(project_config)
1007
+ if success:
1008
+ log_info(f"✅ LLM provider notified for project '{project.name}'")
1009
+ else:
1010
+ log_info(f"⚠️ LLM provider notification failed for project '{project.name}'")
1011
+
1012
+ except Exception as e:
1013
+ log_error("❌ Error notifying LLM provider", e)
1014
+ raise
1015
+
1016
+ # ===================== Cleanup Task =====================
1017
+ def cleanup_activity_log():
1018
+ """Cleanup old activity log entries"""
1019
+ while True:
1020
+ try:
1021
+ cfg = ConfigProvider.get()
1022
+
1023
+ # Keep only last 30 days
1024
+ cutoff = datetime.now() - timedelta(days=30)
1025
+ cutoff_str = cutoff.isoformat()
1026
+
1027
+ original_count = len(cfg.activity_log)
1028
+ cfg.activity_log = [
1029
+ log for log in cfg.activity_log
1030
+ if hasattr(log, 'timestamp') and str(log.timestamp) >= cutoff_str
1031
+ ]
1032
+
1033
+ if len(cfg.activity_log) < original_count:
1034
+ removed = original_count - len(cfg.activity_log)
1035
+ log_info(f"🧹 Cleaned up {removed} old activity log entries")
1036
+ # ConfigProvider.save(cfg, "system") kullanmalıyız
1037
+ ConfigProvider.save(cfg, "system")
1038
+
1039
+ except Exception as e:
1040
+ log_error("❌ Activity log cleanup error", e)
1041
+
1042
+ # Run every hour
1043
+ time.sleep(3600)
1044
+
1045
+ def start_cleanup_task():
1046
+ """Start the cleanup task in background"""
1047
+ thread = threading.Thread(target=cleanup_activity_log, daemon=True)
1048
+ thread.start()
1049
  log_info("🧹 Activity log cleanup task started")