ciyidogan commited on
Commit
b340d79
·
verified ·
1 Parent(s): 7798bfb

Update admin_routes.py

Browse files
Files changed (1) hide show
  1. admin_routes.py +282 -238
admin_routes.py CHANGED
@@ -1,6 +1,6 @@
1
- """Admin API endpoints for Flare
2
  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
3
- Provides authentication, project, version, and API management endpoints.
4
  """
5
 
6
  import os
@@ -27,8 +27,8 @@ from encryption_utils import encrypt, decrypt
27
  # ===================== JWT Config =====================
28
  def get_jwt_config():
29
  """Get JWT configuration based on environment"""
30
- # Check if running in HuggingFace Space
31
- if os.environ.get("SPACE_ID"):
32
  # Cloud mode - use secrets from environment
33
  jwt_secret = os.getenv("JWT_SECRET")
34
  if not jwt_secret:
@@ -73,6 +73,7 @@ class EnvironmentUpdate(BaseModel):
73
  llm_provider: ProviderSettingsUpdate
74
  tts_provider: ProviderSettingsUpdate
75
  stt_provider: ProviderSettingsUpdate
 
76
 
77
  class ProjectCreate(BaseModel):
78
  name: str
@@ -80,100 +81,101 @@ class ProjectCreate(BaseModel):
80
  icon: Optional[str] = "folder"
81
  description: Optional[str] = ""
82
  default_locale: str = "tr"
83
- supported_locales: List[str] = ["tr"]
84
-
 
 
85
  class ProjectUpdate(BaseModel):
86
- caption: Optional[str] = None
87
- icon: Optional[str] = None
88
- description: Optional[str] = None
89
- default_locale: Optional[str] = None
90
- supported_locales: Optional[List[str]] = None
91
-
 
 
 
92
  class VersionCreate(BaseModel):
 
 
 
 
 
93
  caption: Optional[str] = ""
94
- general_prompt: str
95
- llm: Optional[Dict[str, Any]] = None
96
- intents: List[Dict[str, Any]] = []
 
 
 
97
 
98
  class VersionUpdate(BaseModel):
99
- caption: Optional[str] = None
100
- general_prompt: Optional[str] = None
101
- llm: Optional[Dict[str, Any]] = None
102
- intents: Optional[List[Dict[str, Any]]] = None
 
103
 
104
  class APICreate(BaseModel):
105
  name: str
106
  url: str
107
- method: str = "GET"
108
- headers: Dict[str, Any] = {}
109
  body_template: Dict[str, Any] = {}
110
  timeout_seconds: int = 10
 
 
111
  auth: Optional[Dict[str, Any]] = None
112
  response_prompt: Optional[str] = None
 
113
 
114
  class APIUpdate(BaseModel):
115
- url: Optional[str] = None
116
- method: Optional[str] = None
117
- headers: Optional[Dict[str, Any]] = None
118
- body_template: Optional[Dict[str, Any]] = None
119
- timeout_seconds: Optional[int] = None
120
- auth: Optional[Dict[str, Any]] = None
121
- response_prompt: Optional[str] = None
 
 
 
 
 
 
 
122
 
123
  # ===================== Auth Helpers =====================
124
  def create_token(username: str) -> str:
125
- """Create JWT token"""
126
- jwt_config = get_jwt_config()
 
127
 
128
  payload = {
129
  "sub": username,
130
- "exp": datetime.now(timezone.utc) + timedelta(hours=jwt_config["expiration_hours"]),
131
  "iat": datetime.now(timezone.utc)
132
  }
133
 
134
- return jwt.encode(payload, jwt_config["secret"], algorithm=jwt_config["algorithm"])
135
 
136
  def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
137
  """Verify JWT token and return username"""
138
- jwt_config = get_jwt_config()
 
139
 
140
  try:
141
- payload = jwt.decode(
142
- credentials.credentials,
143
- jwt_config["secret"],
144
- algorithms=[jwt_config["algorithm"]]
145
- )
146
- username = payload.get("sub")
147
- if not username:
148
- raise HTTPException(status_code=401, detail="Invalid token")
149
- return username
150
  except jwt.ExpiredSignatureError:
151
  raise HTTPException(status_code=401, detail="Token expired")
152
  except jwt.InvalidTokenError:
153
  raise HTTPException(status_code=401, detail="Invalid token")
154
 
155
- def verify_password(plain_password: str, hashed_password: str, salt: str) -> bool:
156
- """Verify password against hash"""
157
- # For bcrypt hashes
158
- if hashed_password.startswith("$2b$"):
159
- return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
160
-
161
- # For SHA256 hashes (legacy)
162
- password_with_salt = plain_password + salt
163
- password_hash = hashlib.sha256(password_with_salt.encode()).hexdigest()
164
- return password_hash == hashed_password
165
-
166
- def hash_password(password: str) -> tuple[str, str]:
167
- """Hash password and return (hash, salt)"""
168
- # Use bcrypt for new passwords
169
- salt = bcrypt.gensalt()
170
- hashed = bcrypt.hashpw(password.encode('utf-8'), salt)
171
- return hashed.decode('utf-8'), "" # bcrypt includes salt in hash
172
-
173
- # ===================== Login Endpoints =====================
174
  @router.post("/login", response_model=LoginResponse)
175
  async def login(request: LoginRequest):
176
- """User login"""
177
  cfg = ConfigProvider.get()
178
 
179
  # Find user
@@ -182,13 +184,13 @@ async def login(request: LoginRequest):
182
  raise HTTPException(status_code=401, detail="Invalid credentials")
183
 
184
  # Verify password
185
- if not verify_password(request.password, user.password_hash, user.salt):
186
  raise HTTPException(status_code=401, detail="Invalid credentials")
187
 
188
  # Create token
189
  token = create_token(request.username)
190
 
191
- log(f"✅ User '{request.username}' logged in")
192
  return LoginResponse(token=token, username=request.username)
193
 
194
  @router.post("/change-password")
@@ -205,18 +207,19 @@ async def change_password(
205
  raise HTTPException(status_code=404, detail="User not found")
206
 
207
  # Verify current password
208
- if not verify_password(request.current_password, user.password_hash, user.salt):
209
  raise HTTPException(status_code=401, detail="Current password is incorrect")
210
 
211
- # Hash new password
212
- new_hash, new_salt = hash_password(request.new_password)
 
213
 
214
  # Update user
215
- user.password_hash = new_hash
216
- user.salt = new_salt
217
 
218
- # Save config
219
- ConfigProvider._save()
220
 
221
  log(f"✅ Password changed for user '{username}'")
222
  return {"success": True}
@@ -252,15 +255,16 @@ async def get_locale_details(
252
  # ===================== Environment Endpoints =====================
253
  @router.get("/environment")
254
  async def get_environment(username: str = Depends(verify_token)):
255
- """Get environment configuration"""
256
  cfg = ConfigProvider.get()
257
  env_config = cfg.global_config
258
 
259
  return {
260
- "llm_provider": env_config.llm_provider.model_dump(),
261
- "tts_provider": env_config.tts_provider.model_dump(),
262
- "stt_provider": env_config.stt_provider.model_dump(),
263
- "providers": [p.model_dump() for p in env_config.providers]
 
264
  }
265
 
266
  @router.put("/environment")
@@ -268,7 +272,7 @@ async def update_environment(
268
  update: EnvironmentUpdate,
269
  username: str = Depends(verify_token)
270
  ):
271
- """Update environment configuration"""
272
  log(f"📝 Updating environment config by {username}")
273
 
274
  cfg = ConfigProvider.get()
@@ -278,36 +282,41 @@ async def update_environment(
278
  if not llm_provider_def:
279
  raise HTTPException(status_code=400, detail=f"Unknown LLM provider: {update.llm_provider.name}")
280
 
281
- # Validate requirements
282
  if llm_provider_def.requires_api_key and not update.llm_provider.api_key:
283
- raise HTTPException(status_code=400, detail=f"API key is required for {update.llm_provider.name}")
284
 
285
  if llm_provider_def.requires_endpoint and not update.llm_provider.endpoint:
286
- raise HTTPException(status_code=400, detail=f"Endpoint is required for {update.llm_provider.name}")
287
-
288
  # Validate TTS provider
289
  tts_provider_def = cfg.global_config.get_provider_config("tts", update.tts_provider.name)
290
- if not tts_provider_def and update.tts_provider.name != "no_tts":
291
  raise HTTPException(status_code=400, detail=f"Unknown TTS provider: {update.tts_provider.name}")
292
 
293
- if tts_provider_def and tts_provider_def.requires_api_key and not update.tts_provider.api_key:
294
- raise HTTPException(status_code=400, detail=f"API key is required for {update.tts_provider.name}")
295
 
296
  # Validate STT provider
297
  stt_provider_def = cfg.global_config.get_provider_config("stt", update.stt_provider.name)
298
- if not stt_provider_def and update.stt_provider.name != "no_stt":
299
  raise HTTPException(status_code=400, detail=f"Unknown STT provider: {update.stt_provider.name}")
300
 
301
- if stt_provider_def and stt_provider_def.requires_api_key and not update.stt_provider.api_key:
302
- raise HTTPException(status_code=400, detail=f"API key is required for {update.stt_provider.name}")
303
 
304
  # Update via ConfigProvider
305
  ConfigProvider.update_environment(update.model_dump(), username)
306
 
307
- log(f"✅ Environment updated by {username}")
308
  return {"success": True}
309
 
310
  # ===================== Project Endpoints =====================
 
 
 
 
 
 
311
  @router.get("/projects")
312
  async def list_projects(
313
  include_deleted: bool = False,
@@ -323,31 +332,49 @@ async def list_projects(
323
 
324
  return [p.model_dump() for p in projects]
325
 
 
 
 
 
 
 
 
 
 
 
 
 
326
  @router.post("/projects")
327
  async def create_project(
328
  project: ProjectCreate,
329
  username: str = Depends(verify_token)
330
  ):
331
- """Create new project"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  new_project = ConfigProvider.create_project(project.model_dump(), username)
333
 
334
  log(f"✅ Project '{project.name}' created by {username}")
335
  return new_project.model_dump()
336
 
337
- @router.get("/projects/{project_id}")
338
- async def get_project(
339
- project_id: int,
340
- username: str = Depends(verify_token)
341
- ):
342
- """Get project details"""
343
- cfg = ConfigProvider.get()
344
- project = next((p for p in cfg.projects if p.id == project_id), None)
345
-
346
- if not project:
347
- raise HTTPException(status_code=404, detail="Project not found")
348
-
349
- return project.model_dump()
350
-
351
  @router.put("/projects/{project_id}")
352
  async def update_project(
353
  project_id: int,
@@ -355,9 +382,22 @@ async def update_project(
355
  username: str = Depends(verify_token)
356
  ):
357
  """Update project"""
358
- updated_project = ConfigProvider.update_project(project_id, update.model_dump(exclude_unset=True), username)
 
 
 
 
 
 
 
 
 
 
359
 
360
- log(f"✅ Project {project_id} updated by {username}")
 
 
 
361
  return updated_project.model_dump()
362
 
363
  @router.delete("/projects/{project_id}")
@@ -365,16 +405,16 @@ async def delete_project(project_id: int, username: str = Depends(verify_token))
365
  """Delete project (soft delete)"""
366
  ConfigProvider.delete_project(project_id, username)
367
 
368
- log(f"✅ Project {project_id} deleted by {username}")
369
  return {"success": True}
370
 
371
  @router.patch("/projects/{project_id}/toggle")
372
  async def toggle_project(project_id: int, username: str = Depends(verify_token)):
373
  """Toggle project enabled status"""
374
- project = ConfigProvider.toggle_project(project_id, username)
375
 
376
- log(f"✅ Project {project_id} {'enabled' if project.enabled else 'disabled'} by {username}")
377
- return {"success": True, "enabled": project.enabled}
378
 
379
  # ===================== Version Endpoints =====================
380
  @router.get("/projects/{project_id}/versions")
@@ -384,9 +424,7 @@ async def list_versions(
384
  username: str = Depends(verify_token)
385
  ):
386
  """List project versions"""
387
- cfg = ConfigProvider.get()
388
- project = next((p for p in cfg.projects if p.id == project_id), None)
389
-
390
  if not project:
391
  raise HTTPException(status_code=404, detail="Project not found")
392
 
@@ -418,7 +456,7 @@ async def update_version(
418
  username: str = Depends(verify_token)
419
  ):
420
  """Update version"""
421
- updated_version = ConfigProvider.update_version(project_id, version_id, update.model_dump(exclude_unset=True), username)
422
 
423
  log(f"✅ Version {version_id} updated for project {project_id} by {username}")
424
  return updated_version.model_dump()
@@ -434,7 +472,16 @@ async def publish_version(
434
 
435
  log(f"✅ Version {version_id} published for project '{project.name}' by {username}")
436
 
437
- # TODO: Notify LLM provider if needed (only for Spark variants)
 
 
 
 
 
 
 
 
 
438
 
439
  return {"success": True}
440
 
@@ -450,72 +497,6 @@ async def delete_version(
450
  log(f"✅ Version {version_id} deleted for project {project_id} by {username}")
451
  return {"success": True}
452
 
453
- # ===================== Test Endpoints =====================
454
- @router.post("/test-connection")
455
- async def test_connection(
456
- request: dict = Body(...),
457
- username: str = Depends(verify_token)
458
- ):
459
- """Test connection to LLM endpoint"""
460
- endpoint = request.get("endpoint", "").rstrip("/")
461
- api_key = request.get("api_key", "")
462
- provider_name = request.get("provider", "spark")
463
-
464
- if not endpoint:
465
- raise HTTPException(status_code=400, detail="Endpoint is required")
466
-
467
- # For Spark variants
468
- if provider_name in ("spark", "spark_cloud", "spark_onpremise"):
469
- try:
470
- headers = {
471
- "Authorization": f"Bearer {api_key}",
472
- "Content-Type": "application/json"
473
- }
474
-
475
- async with httpx.AsyncClient(timeout=10) as client:
476
- response = await client.get(f"{endpoint}/health", headers=headers)
477
-
478
- if response.status_code == 200:
479
- return {"success": True, "message": "Connection successful"}
480
- else:
481
- return {"success": False, "message": f"Health check failed: {response.status_code}"}
482
-
483
- except Exception as e:
484
- log(f"❌ Connection test failed: {e}")
485
- return {"success": False, "message": str(e)}
486
-
487
- # For other providers
488
- return {"success": True, "message": "Provider doesn't require connection test"}
489
-
490
- @router.post("/validate/regex")
491
- async def validate_regex(
492
- request: dict = Body(...),
493
- username: str = Depends(verify_token)
494
- ):
495
- """Validate regex pattern"""
496
- pattern = request.get("pattern", "")
497
- test_value = request.get("test_value", "")
498
-
499
- try:
500
- import re
501
- compiled_regex = re.compile(pattern)
502
- matches = bool(compiled_regex.match(test_value))
503
-
504
- return {
505
- "valid": True,
506
- "matches": matches,
507
- "pattern": pattern,
508
- "test_value": test_value
509
- }
510
- except Exception as e:
511
- return {
512
- "valid": False,
513
- "matches": False,
514
- "error": str(e),
515
- "pattern": pattern,
516
- "test_value": test_value
517
- }
518
-
519
  # ===================== API Endpoints =====================
520
  @router.get("/apis")
521
  async def list_apis(
@@ -547,7 +528,7 @@ async def update_api(
547
  username: str = Depends(verify_token)
548
  ):
549
  """Update API"""
550
- updated_api = ConfigProvider.update_api(api_name, update.model_dump(exclude_unset=True), username)
551
 
552
  log(f"✅ API '{api_name}' updated by {username}")
553
  return updated_api.model_dump()
@@ -560,87 +541,150 @@ async def delete_api(api_name: str, username: str = Depends(verify_token)):
560
  log(f"✅ API '{api_name}' deleted by {username}")
561
  return {"success": True}
562
 
563
- @router.post("/apis/test")
564
- async def test_api(
565
  request: dict = Body(...),
566
  username: str = Depends(verify_token)
567
  ):
568
- """Test API endpoint"""
569
- from api_executor import test_api_call
570
-
571
- api_config = request.get("api_config")
572
- test_params = request.get("test_params", {})
573
-
574
- if not api_config:
575
- raise HTTPException(status_code=400, detail="API config is required")
576
 
577
  try:
578
- result = await test_api_call(api_config, test_params)
 
 
 
579
  return {
580
- "success": True,
581
- "status_code": result.get("status_code"),
582
- "response": result.get("response"),
583
- "headers": result.get("headers"),
584
- "duration_ms": result.get("duration_ms")
585
  }
586
  except Exception as e:
587
- log(f"❌ API test failed: {e}")
588
  return {
589
- "success": False,
590
- "error": str(e)
 
 
 
591
  }
592
 
593
- # ===================== Activity Log =====================
594
- @router.get("/activity-log")
595
- async def get_activity_log(
596
- limit: int = Query(50, ge=1, le=500),
597
- offset: int = Query(0, ge=0),
598
  username: str = Depends(verify_token)
599
  ):
600
- """Get activity log entries"""
601
- # TODO: Implement when activity logging is added
 
 
 
602
  return {
603
- "entries": [],
604
- "total": 0,
605
- "limit": limit,
606
- "offset": offset
 
 
 
607
  }
608
 
609
- # ===================== Health Check =====================
610
- @router.get("/health")
611
- async def health_check():
612
- """Health check endpoint"""
 
 
 
613
  return {
614
- "status": "ok",
615
- "timestamp": datetime.now(timezone.utc).isoformat(),
616
- "version": "1.0.0"
 
 
 
 
 
617
  }
618
 
619
- # ===================== Cleanup Task =====================
620
- _cleanup_thread = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
621
 
622
- def cleanup_activity_logs():
623
- """Cleanup old activity logs periodically"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
624
  while True:
625
  try:
626
- # Sleep for 24 hours
627
- time.sleep(86400)
 
 
 
628
 
629
- # TODO: Implement cleanup logic when activity logging is added
630
- log("🧹 Running activity log cleanup...")
 
 
 
631
 
 
 
 
 
 
632
  except Exception as e:
633
- log(f"❌ Cleanup error: {e}")
 
 
 
634
 
635
  def start_cleanup_task():
636
- """Start background cleanup task"""
637
- global _cleanup_thread
638
- if _cleanup_thread is None:
639
- _cleanup_thread = threading.Thread(target=cleanup_activity_logs, daemon=True)
640
- _cleanup_thread.start()
641
- log("🧹 Started cleanup task")
642
-
643
- # ===================== Helper Functions =====================
644
- def _get_iso_timestamp() -> str:
645
- """Get current timestamp in ISO format"""
646
- return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
 
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
 
27
  # ===================== JWT Config =====================
28
  def get_jwt_config():
29
  """Get JWT configuration based on environment"""
30
+ # Check if we're in HuggingFace Space
31
+ if os.getenv("SPACE_ID"):
32
  # Cloud mode - use secrets from environment
33
  jwt_secret = os.getenv("JWT_SECRET")
34
  if not jwt_secret:
 
73
  llm_provider: ProviderSettingsUpdate
74
  tts_provider: ProviderSettingsUpdate
75
  stt_provider: ProviderSettingsUpdate
76
+ parameter_collection_config: Optional[Dict[str, Any]] = None
77
 
78
  class ProjectCreate(BaseModel):
79
  name: str
 
81
  icon: Optional[str] = "folder"
82
  description: Optional[str] = ""
83
  default_locale: str = "tr"
84
+ supported_locales: List[str] = Field(default_factory=lambda: ["tr"])
85
+ timezone: str = "Europe/Istanbul"
86
+ region: str = "tr-TR"
87
+
88
  class ProjectUpdate(BaseModel):
89
+ caption: str
90
+ icon: Optional[str] = "folder"
91
+ description: Optional[str] = ""
92
+ default_locale: str = "tr"
93
+ supported_locales: List[str] = Field(default_factory=lambda: ["tr"])
94
+ timezone: str = "Europe/Istanbul"
95
+ region: str = "tr-TR"
96
+ last_update_date: str
97
+
98
  class VersionCreate(BaseModel):
99
+ caption: str
100
+ source_version_id: int | None = None
101
+
102
+ class IntentModel(BaseModel):
103
+ name: str
104
  caption: Optional[str] = ""
105
+ detection_prompt: str
106
+ examples: List[Dict[str, str]] = [] # LocalizedExample format
107
+ parameters: List[Dict[str, Any]] = []
108
+ action: str
109
+ fallback_timeout_prompt: Optional[str] = None
110
+ fallback_error_prompt: Optional[str] = None
111
 
112
  class VersionUpdate(BaseModel):
113
+ caption: str
114
+ general_prompt: str
115
+ llm: Dict[str, Any]
116
+ intents: List[IntentModel]
117
+ last_update_date: str
118
 
119
  class APICreate(BaseModel):
120
  name: str
121
  url: str
122
+ method: str = "POST"
123
+ headers: Dict[str, str] = {}
124
  body_template: Dict[str, Any] = {}
125
  timeout_seconds: int = 10
126
+ retry: Dict[str, Any] = Field(default_factory=lambda: {"retry_count": 3, "backoff_seconds": 2, "strategy": "static"})
127
+ proxy: Optional[str] = None
128
  auth: Optional[Dict[str, Any]] = None
129
  response_prompt: Optional[str] = None
130
+ response_mappings: List[Dict[str, Any]] = []
131
 
132
  class APIUpdate(BaseModel):
133
+ url: str
134
+ method: str
135
+ headers: Dict[str, str]
136
+ body_template: Dict[str, Any]
137
+ timeout_seconds: int
138
+ retry: Dict[str, Any]
139
+ proxy: Optional[str]
140
+ auth: Optional[Dict[str, Any]]
141
+ response_prompt: Optional[str]
142
+ response_mappings: List[Dict[str, Any]] = []
143
+ last_update_date: str
144
+
145
+ class TestRequest(BaseModel):
146
+ test_type: str # "all", "ui", "backend", "integration", "spark"
147
 
148
  # ===================== Auth Helpers =====================
149
  def create_token(username: str) -> str:
150
+ """Create JWT token for user"""
151
+ config = get_jwt_config()
152
+ expiry = datetime.now(timezone.utc) + timedelta(hours=config["expiration_hours"])
153
 
154
  payload = {
155
  "sub": username,
156
+ "exp": expiry,
157
  "iat": datetime.now(timezone.utc)
158
  }
159
 
160
+ return jwt.encode(payload, config["secret"], algorithm=config["algorithm"])
161
 
162
  def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
163
  """Verify JWT token and return username"""
164
+ token = credentials.credentials
165
+ config = get_jwt_config()
166
 
167
  try:
168
+ payload = jwt.decode(token, config["secret"], algorithms=[config["algorithm"]])
169
+ return payload["sub"]
 
 
 
 
 
 
 
170
  except jwt.ExpiredSignatureError:
171
  raise HTTPException(status_code=401, detail="Token expired")
172
  except jwt.InvalidTokenError:
173
  raise HTTPException(status_code=401, detail="Invalid token")
174
 
175
+ # ===================== Auth Endpoints =====================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  @router.post("/login", response_model=LoginResponse)
177
  async def login(request: LoginRequest):
178
+ """User login endpoint"""
179
  cfg = ConfigProvider.get()
180
 
181
  # Find user
 
184
  raise HTTPException(status_code=401, detail="Invalid credentials")
185
 
186
  # Verify password
187
+ if not bcrypt.checkpw(request.password.encode('utf-8'), user.password_hash.encode('utf-8')):
188
  raise HTTPException(status_code=401, detail="Invalid credentials")
189
 
190
  # Create token
191
  token = create_token(request.username)
192
 
193
+ log(f"✅ User '{request.username}' logged in successfully")
194
  return LoginResponse(token=token, username=request.username)
195
 
196
  @router.post("/change-password")
 
207
  raise HTTPException(status_code=404, detail="User not found")
208
 
209
  # Verify current password
210
+ if not bcrypt.checkpw(request.current_password.encode('utf-8'), user.password_hash.encode('utf-8')):
211
  raise HTTPException(status_code=401, detail="Current password is incorrect")
212
 
213
+ # Generate new password hash
214
+ salt = bcrypt.gensalt()
215
+ new_hash = bcrypt.hashpw(request.new_password.encode('utf-8'), salt)
216
 
217
  # Update user
218
+ user.password_hash = new_hash.decode('utf-8')
219
+ user.salt = salt.decode('utf-8')
220
 
221
+ # Save configuration
222
+ cfg.save()
223
 
224
  log(f"✅ Password changed for user '{username}'")
225
  return {"success": True}
 
255
  # ===================== Environment Endpoints =====================
256
  @router.get("/environment")
257
  async def get_environment(username: str = Depends(verify_token)):
258
+ """Get environment configuration with provider info"""
259
  cfg = ConfigProvider.get()
260
  env_config = cfg.global_config
261
 
262
  return {
263
+ "llm_provider": env_config.llm_provider.model_dump() if env_config.llm_provider else None,
264
+ "tts_provider": env_config.tts_provider.model_dump() if env_config.tts_provider else None,
265
+ "stt_provider": env_config.stt_provider.model_dump() if env_config.stt_provider else None,
266
+ "providers": [p.model_dump() for p in env_config.providers],
267
+ "parameter_collection_config": env_config.parameter_collection_config.model_dump()
268
  }
269
 
270
  @router.put("/environment")
 
272
  update: EnvironmentUpdate,
273
  username: str = Depends(verify_token)
274
  ):
275
+ """Update environment configuration with provider validation"""
276
  log(f"📝 Updating environment config by {username}")
277
 
278
  cfg = ConfigProvider.get()
 
282
  if not llm_provider_def:
283
  raise HTTPException(status_code=400, detail=f"Unknown LLM provider: {update.llm_provider.name}")
284
 
 
285
  if llm_provider_def.requires_api_key and not update.llm_provider.api_key:
286
+ raise HTTPException(status_code=400, detail=f"{llm_provider_def.display_name} requires API key")
287
 
288
  if llm_provider_def.requires_endpoint and not update.llm_provider.endpoint:
289
+ raise HTTPException(status_code=400, detail=f"{llm_provider_def.display_name} requires endpoint")
290
+
291
  # Validate TTS provider
292
  tts_provider_def = cfg.global_config.get_provider_config("tts", update.tts_provider.name)
293
+ if not tts_provider_def:
294
  raise HTTPException(status_code=400, detail=f"Unknown TTS provider: {update.tts_provider.name}")
295
 
296
+ if tts_provider_def.requires_api_key and not update.tts_provider.api_key:
297
+ raise HTTPException(status_code=400, detail=f"{tts_provider_def.display_name} requires API key")
298
 
299
  # Validate STT provider
300
  stt_provider_def = cfg.global_config.get_provider_config("stt", update.stt_provider.name)
301
+ if not stt_provider_def:
302
  raise HTTPException(status_code=400, detail=f"Unknown STT provider: {update.stt_provider.name}")
303
 
304
+ if stt_provider_def.requires_api_key and not update.stt_provider.api_key:
305
+ raise HTTPException(status_code=400, detail=f"{stt_provider_def.display_name} requires API key")
306
 
307
  # Update via ConfigProvider
308
  ConfigProvider.update_environment(update.model_dump(), username)
309
 
310
+ log(f"✅ Environment updated to LLM: {update.llm_provider.name}, TTS: {update.tts_provider.name}, STT: {update.stt_provider.name} by {username}")
311
  return {"success": True}
312
 
313
  # ===================== Project Endpoints =====================
314
+ @router.get("/projects/names")
315
+ def list_enabled_projects():
316
+ """Get list of enabled project names for chat"""
317
+ cfg = ConfigProvider.get()
318
+ return [p.name for p in cfg.projects if p.enabled and not getattr(p, 'deleted', False)]
319
+
320
  @router.get("/projects")
321
  async def list_projects(
322
  include_deleted: bool = False,
 
332
 
333
  return [p.model_dump() for p in projects]
334
 
335
+ @router.get("/projects/{project_id}")
336
+ async def get_project(
337
+ project_id: int,
338
+ username: str = Depends(verify_token)
339
+ ):
340
+ """Get single project by ID"""
341
+ project = ConfigProvider.get_project(project_id)
342
+ if not project or getattr(project, 'deleted', False):
343
+ raise HTTPException(status_code=404, detail="Project not found")
344
+
345
+ return project.model_dump()
346
+
347
  @router.post("/projects")
348
  async def create_project(
349
  project: ProjectCreate,
350
  username: str = Depends(verify_token)
351
  ):
352
+ """Create new project with initial version"""
353
+ # Validate supported locales
354
+ from locale_manager import LocaleManager
355
+
356
+ invalid_locales = LocaleManager.validate_project_languages(project.supported_locales)
357
+ if invalid_locales:
358
+ available_locales = LocaleManager.get_available_locales_with_names()
359
+ available_codes = [locale['code'] for locale in available_locales]
360
+ raise HTTPException(
361
+ status_code=400,
362
+ detail=f"Unsupported locales: {', '.join(invalid_locales)}. Available locales: {', '.join(available_codes)}"
363
+ )
364
+
365
+ # Check if default locale is in supported locales
366
+ if project.default_locale not in project.supported_locales:
367
+ raise HTTPException(
368
+ status_code=400,
369
+ detail="Default locale must be one of the supported locales"
370
+ )
371
+
372
+ # Create project via ConfigProvider
373
  new_project = ConfigProvider.create_project(project.model_dump(), username)
374
 
375
  log(f"✅ Project '{project.name}' created by {username}")
376
  return new_project.model_dump()
377
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
378
  @router.put("/projects/{project_id}")
379
  async def update_project(
380
  project_id: int,
 
382
  username: str = Depends(verify_token)
383
  ):
384
  """Update project"""
385
+ # Validate supported locales
386
+ from locale_manager import LocaleManager
387
+
388
+ invalid_locales = LocaleManager.validate_project_languages(update.supported_locales)
389
+ if invalid_locales:
390
+ available_locales = LocaleManager.get_available_locales_with_names()
391
+ available_codes = [locale['code'] for locale in available_locales]
392
+ raise HTTPException(
393
+ status_code=400,
394
+ detail=f"Unsupported locales: {', '.join(invalid_locales)}. Available locales: {', '.join(available_codes)}"
395
+ )
396
 
397
+ # Update via ConfigProvider
398
+ updated_project = ConfigProvider.update_project(project_id, update.model_dump(), username)
399
+
400
+ log(f"✅ Project '{updated_project.name}' updated by {username}")
401
  return updated_project.model_dump()
402
 
403
  @router.delete("/projects/{project_id}")
 
405
  """Delete project (soft delete)"""
406
  ConfigProvider.delete_project(project_id, username)
407
 
408
+ log(f"✅ Project deleted by {username}")
409
  return {"success": True}
410
 
411
  @router.patch("/projects/{project_id}/toggle")
412
  async def toggle_project(project_id: int, username: str = Depends(verify_token)):
413
  """Toggle project enabled status"""
414
+ enabled = ConfigProvider.toggle_project(project_id, username)
415
 
416
+ log(f"✅ Project {'enabled' if enabled else 'disabled'} by {username}")
417
+ return {"enabled": enabled}
418
 
419
  # ===================== Version Endpoints =====================
420
  @router.get("/projects/{project_id}/versions")
 
424
  username: str = Depends(verify_token)
425
  ):
426
  """List project versions"""
427
+ project = ConfigProvider.get_project(project_id)
 
 
428
  if not project:
429
  raise HTTPException(status_code=404, detail="Project not found")
430
 
 
456
  username: str = Depends(verify_token)
457
  ):
458
  """Update version"""
459
+ updated_version = ConfigProvider.update_version(project_id, version_id, update.model_dump(), username)
460
 
461
  log(f"✅ Version {version_id} updated for project {project_id} by {username}")
462
  return updated_version.model_dump()
 
472
 
473
  log(f"✅ Version {version_id} published for project '{project.name}' by {username}")
474
 
475
+ # Notify LLM provider if project is enabled and provider requires repo info
476
+ cfg = ConfigProvider.get()
477
+ llm_provider_def = cfg.global_config.get_provider_config("llm", cfg.global_config.llm_provider.name)
478
+
479
+ if project.enabled and llm_provider_def and llm_provider_def.requires_repo_info:
480
+ try:
481
+ await notify_llm_startup(project, version)
482
+ except Exception as e:
483
+ log(f"⚠️ Failed to notify LLM provider: {e}")
484
+ # Don't fail the publish
485
 
486
  return {"success": True}
487
 
 
497
  log(f"✅ Version {version_id} deleted for project {project_id} by {username}")
498
  return {"success": True}
499
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
500
  # ===================== API Endpoints =====================
501
  @router.get("/apis")
502
  async def list_apis(
 
528
  username: str = Depends(verify_token)
529
  ):
530
  """Update API"""
531
+ updated_api = ConfigProvider.update_api(api_name, update.model_dump(), username)
532
 
533
  log(f"✅ API '{api_name}' updated by {username}")
534
  return updated_api.model_dump()
 
541
  log(f"✅ API '{api_name}' deleted by {username}")
542
  return {"success": True}
543
 
544
+ @router.post("/validate/regex")
545
+ async def validate_regex(
546
  request: dict = Body(...),
547
  username: str = Depends(verify_token)
548
  ):
549
+ """Validate regex pattern"""
550
+ pattern = request.get("pattern", "")
551
+ test_value = request.get("test_value", "")
 
 
 
 
 
552
 
553
  try:
554
+ import re
555
+ compiled_regex = re.compile(pattern)
556
+ matches = bool(compiled_regex.match(test_value))
557
+
558
  return {
559
+ "valid": True,
560
+ "matches": matches,
561
+ "pattern": pattern,
562
+ "test_value": test_value
 
563
  }
564
  except Exception as e:
 
565
  return {
566
+ "valid": False,
567
+ "matches": False,
568
+ "error": str(e),
569
+ "pattern": pattern,
570
+ "test_value": test_value
571
  }
572
 
573
+ # ===================== Test Endpoints =====================
574
+ @router.post("/test/run-all")
575
+ async def run_all_tests(
576
+ request: TestRequest,
 
577
  username: str = Depends(verify_token)
578
  ):
579
+ """Run all tests"""
580
+ log(f"🧪 Running {request.test_type} tests requested by {username}")
581
+
582
+ # TODO: Implement test runner
583
+ # For now, return mock results
584
  return {
585
+ "test_run_id": "test_" + datetime.now().isoformat(),
586
+ "status": "running",
587
+ "total_tests": 60,
588
+ "completed": 0,
589
+ "passed": 0,
590
+ "failed": 0,
591
+ "message": "Test run started"
592
  }
593
 
594
+ @router.get("/test/status/{test_run_id}")
595
+ async def get_test_status(
596
+ test_run_id: str,
597
+ username: str = Depends(verify_token)
598
+ ):
599
+ """Get test run status"""
600
+ # TODO: Implement test status tracking
601
  return {
602
+ "test_run_id": test_run_id,
603
+ "status": "completed",
604
+ "total_tests": 60,
605
+ "completed": 60,
606
+ "passed": 57,
607
+ "failed": 3,
608
+ "duration": 340.5,
609
+ "details": []
610
  }
611
 
612
+ # ===================== Activity Log =====================
613
+ @router.get("/activity-log")
614
+ async def get_activity_log(
615
+ limit: int = Query(100, ge=1, le=1000),
616
+ entity_type: Optional[str] = None,
617
+ username: str = Depends(verify_token)
618
+ ):
619
+ """Get activity log"""
620
+ cfg = ConfigProvider.get()
621
+ logs = cfg.activity_log
622
+
623
+ # Filter by entity type if specified
624
+ if entity_type:
625
+ logs = [l for l in logs if l.entity_type == entity_type]
626
+
627
+ # Return most recent entries
628
+ return logs[-limit:]
629
 
630
+ # ===================== Helper Functions =====================
631
+ async def notify_llm_startup(project, version):
632
+ """Notify LLM provider about project startup"""
633
+ from llm_factory import LLMFactory
634
+
635
+ try:
636
+ llm_provider = LLMFactory.create_provider()
637
+
638
+ # Build project config for startup
639
+ project_config = {
640
+ "name": project.name,
641
+ "version_id": version.id,
642
+ "repo_id": version.llm.repo_id,
643
+ "generation_config": version.llm.generation_config,
644
+ "use_fine_tune": version.llm.use_fine_tune,
645
+ "fine_tune_zip": version.llm.fine_tune_zip
646
+ }
647
+
648
+ success = await llm_provider.startup(project_config)
649
+ if success:
650
+ log(f"✅ LLM provider notified for project '{project.name}'")
651
+ else:
652
+ log(f"⚠️ LLM provider notification failed for project '{project.name}'")
653
+
654
+ except Exception as e:
655
+ log(f"❌ Error notifying LLM provider: {e}")
656
+ raise
657
+
658
+ # ===================== Cleanup Task =====================
659
+ def cleanup_activity_log():
660
+ """Cleanup old activity log entries"""
661
  while True:
662
  try:
663
+ cfg = ConfigProvider.get()
664
+
665
+ # Keep only last 30 days
666
+ cutoff = datetime.now() - timedelta(days=30)
667
+ cutoff_str = cutoff.isoformat() + "Z"
668
 
669
+ original_count = len(cfg.activity_log)
670
+ cfg.activity_log = [
671
+ log for log in cfg.activity_log
672
+ if log.timestamp >= cutoff_str
673
+ ]
674
 
675
+ if len(cfg.activity_log) < original_count:
676
+ removed = original_count - len(cfg.activity_log)
677
+ log(f"🧹 Cleaned up {removed} old activity log entries")
678
+ cfg.save()
679
+
680
  except Exception as e:
681
+ log(f"❌ Activity log cleanup error: {e}")
682
+
683
+ # Run every hour
684
+ time.sleep(3600)
685
 
686
  def start_cleanup_task():
687
+ """Start the cleanup task in background"""
688
+ thread = threading.Thread(target=cleanup_activity_log, daemon=True)
689
+ thread.start()
690
+ log("🧹 Activity log cleanup task started")