ciyidogan commited on
Commit
05f1c1c
·
verified ·
1 Parent(s): e1ef72d

Update admin_routes.py

Browse files
Files changed (1) hide show
  1. admin_routes.py +235 -664
admin_routes.py CHANGED
@@ -21,15 +21,14 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
21
  from pydantic import BaseModel, Field
22
 
23
  from utils import log
24
- from config_provider import ConfigProvider
25
  from encryption_utils import encrypt, decrypt
26
 
27
  # ===================== JWT Config =====================
28
  def get_jwt_config():
29
  """Get JWT configuration based on environment"""
30
- work_mode = os.getenv("WORK_MODE", "on-premise")
31
-
32
- if work_mode == "hfcloud":
33
  # Cloud mode - use secrets from environment
34
  jwt_secret = os.getenv("JWT_SECRET")
35
  if not jwt_secret:
@@ -63,264 +62,122 @@ class LoginResponse(BaseModel):
63
  class ChangePasswordRequest(BaseModel):
64
  current_password: str
65
  new_password: str
 
 
 
 
 
 
66
 
67
  class EnvironmentUpdate(BaseModel):
68
- work_mode: str
69
- cloud_token: Optional[str] = None
70
- spark_endpoint: str
71
- internal_prompt: Optional[str] = None
72
- tts_engine: str = "no_tts"
73
- tts_engine_api_key: Optional[str] = None
74
- tts_settings: Optional[Dict[str, Any]] = None
75
- stt_engine: str = "no_stt"
76
- stt_engine_api_key: Optional[str] = None
77
- stt_settings: Optional[Dict[str, Any]] = None
78
- parameter_collection_config: Optional[Dict[str, Any]] = None
79
 
80
  class ProjectCreate(BaseModel):
81
  name: str
82
  caption: Optional[str] = ""
83
  icon: Optional[str] = "folder"
84
  description: Optional[str] = ""
85
- default_language: str = "Türkçe" # Locale'in name alanı (Türkçe, English vb.)
86
- supported_languages: List[str] = Field(default_factory=lambda: ["tr-TR"]) # Locale kodları
87
- timezone: str = "Europe/Istanbul"
88
- region: str = "tr-TR"
89
-
90
  class ProjectUpdate(BaseModel):
91
- caption: str
92
- icon: Optional[str] = "folder"
93
- description: Optional[str] = ""
94
- default_language: str = "Türkçe" # Locale'in name alanı
95
- supported_languages: List[str] = Field(default_factory=lambda: ["tr-TR"]) # Locale kodları
96
- timezone: str = "Europe/Istanbul"
97
- region: str = "tr-TR"
98
- last_update_date: str
99
-
100
- class VersionCreate(BaseModel):
101
- caption: str
102
- source_version_id: int | None = None # None → boş template
103
 
104
- class IntentModel(BaseModel):
105
- name: str
106
  caption: Optional[str] = ""
107
- locale: str = "tr-TR"
108
- detection_prompt: str
109
- examples: List[str] = []
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
- class TTSRequest(BaseModel):
152
- text: str
153
- voice_id: Optional[str] = None
154
- model_id: Optional[str] = None
155
- output_format: Optional[str] = "mp3_44100_128"
 
 
 
 
 
156
 
157
- class Config:
158
- protected_namespaces = () # Pydantic uyarısını susturmak için
159
 
160
- # ===================== Helpers =====================
161
  def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
162
  """Verify JWT token and return username"""
163
  jwt_config = get_jwt_config()
164
 
165
  try:
166
  payload = jwt.decode(
167
- credentials.credentials,
168
- jwt_config["secret"],
169
  algorithms=[jwt_config["algorithm"]]
170
  )
171
  username = payload.get("sub")
172
- if username is None:
173
  raise HTTPException(status_code=401, detail="Invalid token")
174
  return username
175
  except jwt.ExpiredSignatureError:
176
  raise HTTPException(status_code=401, detail="Token expired")
177
- except jwt.InvalidTokenError: # Bu genel JWT hatalarını yakalar
178
  raise HTTPException(status_code=401, detail="Invalid token")
179
 
180
- # Utility function to get username for Depends
181
- get_username = verify_token
182
-
183
- def hash_password(password: str, salt: str = None) -> tuple[str, str]:
184
- """Hash password with bcrypt.
185
- Returns (hashed_password, salt)"""
186
- if salt is None:
187
- salt = bcrypt.gensalt().decode('utf-8')
188
-
189
- # Ensure salt is bytes
190
- salt_bytes = salt.encode('utf-8') if isinstance(salt, str) else salt
191
-
192
- # Hash the password
193
- hashed = bcrypt.hashpw(password.encode('utf-8'), salt_bytes)
194
-
195
- return hashed.decode('utf-8'), salt
196
-
197
- def verify_password(password: str, hashed: str, salt: str = None) -> bool:
198
  """Verify password against hash"""
199
- try:
200
- # For bcrypt hashes (they contain salt)
201
- if hashed.startswith('$2b$') or hashed.startswith('$2a$'):
202
- return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8'))
203
-
204
- # For legacy SHA256 hashes
205
- return hashlib.sha256(password.encode()).hexdigest() == hashed
206
- except Exception as e:
207
- log(f"Password verification error: {e}")
208
- return False
209
-
210
- def get_timestamp():
211
- """Get current timestamp in ISO format with milliseconds"""
212
- return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
213
-
214
- async def _spark_project_control(action: str, project_name: str, username: str):
215
- """Common function for Spark project control"""
216
- if not project_name:
217
- raise HTTPException(status_code=400, detail="project_name is required")
218
-
219
- cfg = ConfigProvider.get()
220
- spark_endpoint = str(cfg.global_config.spark_endpoint).rstrip("/")
221
- spark_token = _get_spark_token()
222
-
223
- if not spark_endpoint:
224
- raise HTTPException(status_code=400, detail="Spark endpoint not configured")
225
-
226
- if not spark_token:
227
- raise HTTPException(status_code=400, detail="Spark token not configured")
228
-
229
- headers = {
230
- "Authorization": f"Bearer {spark_token}",
231
- "Content-Type": "application/json"
232
- }
233
-
234
- try:
235
- async with httpx.AsyncClient(timeout=30) as client:
236
- # Hepsi POST request olarak gönderiliyor
237
- response = await client.post(
238
- f"{spark_endpoint}/project/{action}",
239
- json={"project_name": project_name},
240
- headers=headers
241
- )
242
-
243
- response.raise_for_status()
244
- return response.json()
245
-
246
- except httpx.HTTPStatusError as e:
247
- error_detail = e.response.json() if e.response.text else {"error": str(e)}
248
- raise HTTPException(status_code=e.response.status_code, detail=error_detail)
249
- except Exception as e:
250
- log(f"❌ Spark {action} failed: {e}")
251
- raise HTTPException(status_code=500, detail=str(e))
252
-
253
- def _get_spark_token() -> Optional[str]:
254
- """Get Spark token based on work_mode"""
255
- cfg = ConfigProvider.get()
256
- work_mode = cfg.global_config.work_mode
257
-
258
- if work_mode in ("hfcloud", "cloud"):
259
- # Cloud mode - use HuggingFace Secrets
260
- token = os.getenv("SPARK_TOKEN")
261
- if not token:
262
- log("❌ SPARK_TOKEN not found in HuggingFace Secrets!")
263
- return token
264
- else:
265
- # On-premise mode - use .env file
266
- from dotenv import load_dotenv
267
- load_dotenv()
268
- return os.getenv("SPARK_TOKEN")
269
-
270
- async def notify_spark_manual(project: dict, version: dict, global_config: dict):
271
- """Manual Spark notification (similar to notify_spark but returns response)"""
272
- import httpx
273
-
274
- spark_endpoint = global_config.get("spark_endpoint", "").rstrip("/")
275
- spark_token = _get_spark_token()
276
-
277
- if not spark_endpoint:
278
- raise ValueError("Spark endpoint not configured")
279
-
280
- if not spark_token:
281
- raise ValueError("Spark token not configured")
282
-
283
- work_mode = global_config.get("work_mode", "hfcloud")
284
- cloud_token = global_config.get("cloud_token", "")
285
-
286
- # Decrypt token if needed
287
- if cloud_token and cloud_token.startswith("enc:"):
288
- cloud_token = decrypt(cloud_token)
289
-
290
- payload = {
291
- "work_mode": work_mode,
292
- "cloud_token": cloud_token,
293
- "project_name": project["name"],
294
- "project_version": version["id"],
295
- "repo_id": version["llm"]["repo_id"],
296
- "generation_config": version["llm"]["generation_config"],
297
- "use_fine_tune": version["llm"]["use_fine_tune"],
298
- "fine_tune_zip": version["llm"]["fine_tune_zip"] if version["llm"]["use_fine_tune"] else None
299
- }
300
-
301
- headers = {
302
- "Authorization": f"Bearer {spark_token}",
303
- "Content-Type": "application/json"
304
- }
305
-
306
- log(f"🚀 Manually notifying Spark about {project['name']} v{version['id']}")
307
-
308
- async with httpx.AsyncClient(timeout=30) as client:
309
- response = await client.post(spark_endpoint + "/startup", json=payload, headers=headers)
310
- response.raise_for_status()
311
- result = response.json()
312
- log(f"✅ Spark manual notification successful: {result.get('message', 'OK')}")
313
- return result
314
-
315
- # ===================== Auth Endpoints =====================
316
  @router.post("/login", response_model=LoginResponse)
317
  async def login(request: LoginRequest):
318
- """Authenticate user and return JWT token"""
319
  cfg = ConfigProvider.get()
320
- users = cfg.global_config.users
321
 
322
  # Find user
323
- user = next((u for u in users if u.username == request.username), None)
324
  if not user:
325
  raise HTTPException(status_code=401, detail="Invalid credentials")
326
 
@@ -328,15 +185,8 @@ async def login(request: LoginRequest):
328
  if not verify_password(request.password, user.password_hash, user.salt):
329
  raise HTTPException(status_code=401, detail="Invalid credentials")
330
 
331
- # Generate JWT token
332
- jwt_config = get_jwt_config()
333
-
334
- payload = {
335
- "sub": request.username,
336
- "exp": datetime.now(timezone.utc) + timedelta(hours=jwt_config["expiration_hours"])
337
- }
338
-
339
- token = jwt.encode(payload, jwt_config["secret"], algorithm=jwt_config["algorithm"])
340
 
341
  log(f"✅ User '{request.username}' logged in")
342
  return LoginResponse(token=token, username=request.username)
@@ -348,10 +198,9 @@ async def change_password(
348
  ):
349
  """Change user password"""
350
  cfg = ConfigProvider.get()
351
- users = cfg.global_config.users
352
 
353
  # Find user
354
- user = next((u for u in users if u.username == username), None)
355
  if not user:
356
  raise HTTPException(status_code=404, detail="User not found")
357
 
@@ -362,8 +211,12 @@ async def change_password(
362
  # Hash new password
363
  new_hash, new_salt = hash_password(request.new_password)
364
 
365
- # Update user via ConfigProvider
366
- ConfigProvider.update_user_password(username, new_hash, new_salt)
 
 
 
 
367
 
368
  log(f"✅ Password changed for user '{username}'")
369
  return {"success": True}
@@ -404,17 +257,10 @@ async def get_environment(username: str = Depends(verify_token)):
404
  env_config = cfg.global_config
405
 
406
  return {
407
- "work_mode": env_config.work_mode,
408
- "cloud_token": env_config.cloud_token or "",
409
- "spark_endpoint": str(env_config.spark_endpoint),
410
- "internal_prompt": env_config.internal_prompt or "",
411
- "tts_engine": env_config.tts_engine,
412
- "tts_engine_api_key": env_config.tts_engine_api_key or "",
413
- "tts_settings": env_config.get_tts_settings(),
414
- "stt_engine": env_config.stt_engine,
415
- "stt_engine_api_key": env_config.stt_engine_api_key or "",
416
- "stt_settings": env_config.get_stt_settings(),
417
- "parameter_collection_config": env_config.parameter_collection_config.model_dump()
418
  }
419
 
420
  @router.put("/environment")
@@ -425,46 +271,43 @@ async def update_environment(
425
  """Update environment configuration"""
426
  log(f"📝 Updating environment config by {username}")
427
 
428
- # Token validation based on mode
429
- if update.work_mode in ("gpt4o", "gpt4o-mini"):
430
- if not update.cloud_token:
431
- raise HTTPException(status_code=400, detail="OpenAI API key is required for GPT modes")
432
- if not update.cloud_token.startswith("sk-") and not update.cloud_token.startswith("enc:"):
433
- raise HTTPException(status_code=400, detail="Invalid OpenAI API key format")
434
- elif update.work_mode in ("hfcloud", "cloud"):
435
- if not update.cloud_token:
436
- raise HTTPException(status_code=400, detail="Cloud token is required for cloud modes")
437
-
438
- # TTS/STT validation
439
- if update.tts_engine not in ("no_tts", "elevenlabs", "blaze"):
440
- raise HTTPException(status_code=400, detail="Invalid TTS engine")
441
 
442
- if update.stt_engine not in ("no_stt", "google", "azure", "amazon", "gpt4o_realtime", "flicker"):
443
- raise HTTPException(status_code=400, detail="Invalid STT engine")
 
 
444
 
445
- if update.tts_engine != "no_tts" and not update.tts_engine_api_key:
446
- raise HTTPException(status_code=400, detail=f"{update.tts_engine} API key is required")
 
447
 
448
- if update.stt_engine != "no_stt" and not update.stt_engine_api_key:
449
- raise HTTPException(status_code=400, detail=f"{update.stt_engine} API key or credentials required")
 
 
 
 
 
450
 
451
- # Spark endpoint validation
452
- if update.work_mode not in ("gpt4o", "gpt4o-mini") and not update.spark_endpoint:
453
- raise HTTPException(status_code=400, detail="Spark endpoint is required for non-GPT modes")
 
 
 
 
 
 
 
454
 
455
  # Update via ConfigProvider
456
  ConfigProvider.update_environment(update.model_dump(), username)
457
 
458
- log(f"✅ Environment updated to {update.work_mode} with TTS: {update.tts_engine}, STT: {update.stt_engine} by {username}")
459
  return {"success": True}
460
 
461
  # ===================== Project Endpoints =====================
462
- @router.get("/projects/names")
463
- def list_enabled_projects():
464
- """Get list of enabled project names for chat"""
465
- cfg = ConfigProvider.get()
466
- return [p.name for p in cfg.projects if p.enabled and not getattr(p, 'deleted', False)]
467
-
468
  @router.get("/projects")
469
  async def list_projects(
470
  include_deleted: bool = False,
@@ -480,49 +323,31 @@ async def list_projects(
480
 
481
  return [p.model_dump() for p in projects]
482
 
483
- @router.get("/projects/{project_id}")
484
- async def get_project(
485
- project_id: int,
486
- username: str = Depends(verify_token)
487
- ):
488
- """Get single project by ID"""
489
- project = ConfigProvider.get_project(project_id)
490
- if not project or getattr(project, 'deleted', False):
491
- raise HTTPException(status_code=404, detail="Project not found")
492
-
493
- return project.model_dump()
494
-
495
  @router.post("/projects")
496
  async def create_project(
497
  project: ProjectCreate,
498
  username: str = Depends(verify_token)
499
  ):
500
- """Create new project with initial version"""
501
- # Validate supported languages
502
- from locale_manager import LocaleManager
503
-
504
- invalid_languages = LocaleManager.validate_project_languages(project.supported_languages)
505
- if invalid_languages:
506
- available_locales = LocaleManager.get_available_locales_with_names()
507
- available_codes = [locale['code'] for locale in available_locales]
508
- raise HTTPException(
509
- status_code=400,
510
- detail=f"Unsupported languages: {', '.join(invalid_languages)}. Available languages: {', '.join(available_codes)}"
511
- )
512
-
513
- # Check if default language is in supported languages
514
- if not project.supported_languages:
515
- raise HTTPException(
516
- status_code=400,
517
- detail="At least one supported language must be selected"
518
- )
519
-
520
- # Create project via ConfigProvider
521
  new_project = ConfigProvider.create_project(project.model_dump(), username)
522
 
523
  log(f"✅ Project '{project.name}' created by {username}")
524
  return new_project.model_dump()
525
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
526
  @router.put("/projects/{project_id}")
527
  async def update_project(
528
  project_id: int,
@@ -530,10 +355,9 @@ async def update_project(
530
  username: str = Depends(verify_token)
531
  ):
532
  """Update project"""
533
- # Update via ConfigProvider
534
- updated_project = ConfigProvider.update_project(project_id, update.model_dump(), username)
535
 
536
- log(f"✅ Project '{updated_project.name}' updated by {username}")
537
  return updated_project.model_dump()
538
 
539
  @router.delete("/projects/{project_id}")
@@ -541,16 +365,16 @@ async def delete_project(project_id: int, username: str = Depends(verify_token))
541
  """Delete project (soft delete)"""
542
  ConfigProvider.delete_project(project_id, username)
543
 
544
- log(f"✅ Project deleted by {username}")
545
  return {"success": True}
546
 
547
  @router.patch("/projects/{project_id}/toggle")
548
  async def toggle_project(project_id: int, username: str = Depends(verify_token)):
549
  """Toggle project enabled status"""
550
- enabled = ConfigProvider.toggle_project(project_id, username)
551
 
552
- log(f"✅ Project {'enabled' if enabled else 'disabled'} by {username}")
553
- return {"enabled": enabled}
554
 
555
  # ===================== Version Endpoints =====================
556
  @router.get("/projects/{project_id}/versions")
@@ -560,7 +384,9 @@ async def list_versions(
560
  username: str = Depends(verify_token)
561
  ):
562
  """List project versions"""
563
- project = ConfigProvider.get_project(project_id)
 
 
564
  if not project:
565
  raise HTTPException(status_code=404, detail="Project not found")
566
 
@@ -592,7 +418,7 @@ async def update_version(
592
  username: str = Depends(verify_token)
593
  ):
594
  """Update version"""
595
- updated_version = ConfigProvider.update_version(project_id, version_id, update.model_dump(), username)
596
 
597
  log(f"✅ Version {version_id} updated for project {project_id} by {username}")
598
  return updated_version.model_dump()
@@ -608,18 +434,7 @@ async def publish_version(
608
 
609
  log(f"✅ Version {version_id} published for project '{project.name}' by {username}")
610
 
611
- # Notify Spark if project is enabled
612
- if project.enabled:
613
- try:
614
- cfg = ConfigProvider.get()
615
- await notify_spark_manual(
616
- project.model_dump(),
617
- version.model_dump(),
618
- cfg.global_config.model_dump()
619
- )
620
- except Exception as e:
621
- log(f"⚠️ Failed to notify Spark: {e}")
622
- # Don't fail the publish
623
 
624
  return {"success": True}
625
 
@@ -635,6 +450,43 @@ async def delete_version(
635
  log(f"✅ Version {version_id} deleted for project {project_id} by {username}")
636
  return {"success": True}
637
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
638
  @router.post("/validate/regex")
639
  async def validate_regex(
640
  request: dict = Body(...),
@@ -695,7 +547,7 @@ async def update_api(
695
  username: str = Depends(verify_token)
696
  ):
697
  """Update API"""
698
- updated_api = ConfigProvider.update_api(api_name, update.model_dump(), username)
699
 
700
  log(f"✅ API '{api_name}' updated by {username}")
701
  return updated_api.model_dump()
@@ -707,369 +559,88 @@ async def delete_api(api_name: str, username: str = Depends(verify_token)):
707
 
708
  log(f"✅ API '{api_name}' deleted by {username}")
709
  return {"success": True}
710
-
711
- # ===================== Spark Integration Endpoints =====================
712
- @router.post("/spark/startup")
713
- async def spark_startup(request: dict = Body(...), username: str = Depends(verify_token)):
714
- """Trigger Spark startup for a project"""
715
- project_name = request.get("project_name")
716
- if not project_name:
717
- raise HTTPException(status_code=400, detail="project_name is required")
718
-
719
- project = ConfigProvider.get_project_by_name(project_name)
720
- if not project:
721
- raise HTTPException(status_code=404, detail=f"Project not found: {project_name}")
722
-
723
- # Find published version
724
- version = next((v for v in project.versions if v.published), None)
725
- if not version:
726
- raise HTTPException(status_code=400, detail=f"No published version found for project: {project_name}")
727
-
728
- # Notify Spark
729
- try:
730
- cfg = ConfigProvider.get()
731
- result = await notify_spark_manual(
732
- project.model_dump(),
733
- version.model_dump(),
734
- cfg.global_config.model_dump()
735
- )
736
- return {"message": result.get("message", "Spark startup initiated")}
737
- except Exception as e:
738
- log(f"❌ Spark startup failed: {e}")
739
- raise HTTPException(status_code=500, detail=str(e))
740
 
741
- @router.get("/spark/projects")
742
- async def spark_get_projects(username: str = Depends(verify_token)):
743
- """Get Spark project list"""
744
- cfg = ConfigProvider.get()
745
- spark_endpoint = str(cfg.global_config.spark_endpoint).rstrip("/")
746
- spark_token = _get_spark_token()
747
-
748
- if not spark_endpoint:
749
- raise HTTPException(status_code=400, detail="Spark endpoint not configured")
750
-
751
- if not spark_token:
752
- raise HTTPException(status_code=400, detail="Spark token not configured")
753
 
754
- headers = {
755
- "Authorization": f"Bearer {spark_token}"
756
- }
757
 
758
- try:
759
- async with httpx.AsyncClient(timeout=30) as client:
760
- response = await client.get(spark_endpoint + "/project/list", headers=headers)
761
- response.raise_for_status()
762
- return response.json()
763
- except Exception as e:
764
- log(f"❌ Failed to get Spark projects: {e}")
765
- raise HTTPException(status_code=500, detail=str(e))
766
-
767
- @router.post("/spark/project/enable")
768
- async def spark_enable_project(request: dict = Body(...), username: str = Depends(verify_token)):
769
- """Enable project in Spark"""
770
- return await _spark_project_control("enable", request.get("project_name"), username)
771
-
772
- @router.post("/spark/project/disable")
773
- async def spark_disable_project(request: dict = Body(...), username: str = Depends(verify_token)):
774
- """Disable project in Spark"""
775
- return await _spark_project_control("disable", request.get("project_name"), username)
776
-
777
- @router.delete("/spark/project/{project_name}")
778
- async def spark_delete_project(project_name: str, username: str = Depends(verify_token)):
779
- """Delete project from Spark"""
780
- return await _spark_project_control("delete", project_name, username)
781
-
782
- # ===================== Test Endpoints =====================
783
- @router.post("/apis/test")
784
- async def test_api(api_data: dict = Body(...), username: str = Depends(verify_token)):
785
- """Test API endpoint with auth support"""
786
- import requests
787
- import time
788
 
789
  try:
790
- # Extract test request data if provided
791
- test_request = api_data.pop("test_request", None)
792
-
793
- # Parse the APICreate model
794
- api = APICreate(**api_data)
795
-
796
- # Prepare headers
797
- headers = api.headers.copy()
798
-
799
- # Handle authentication if enabled
800
- auth_token = None
801
- if api.auth and api.auth.get("enabled"):
802
- auth_config = api.auth
803
- try:
804
- log(f"🔑 Fetching auth token for test...")
805
-
806
- # Make auth request
807
- auth_response = requests.post(
808
- auth_config["token_endpoint"],
809
- json=auth_config.get("token_request_body", {}),
810
- timeout=10
811
- )
812
- auth_response.raise_for_status()
813
-
814
- # Extract token from response
815
- auth_json = auth_response.json()
816
- token_path = auth_config.get("response_token_path", "token").split(".")
817
-
818
- auth_token = auth_json
819
- for path_part in token_path:
820
- auth_token = auth_token.get(path_part)
821
- if auth_token is None:
822
- raise ValueError(f"Token not found at path: {auth_config.get('response_token_path')}")
823
-
824
- # Add token to headers
825
- headers["Authorization"] = f"Bearer {auth_token}"
826
- log(f"✅ Auth token obtained: {auth_token[:20]}...")
827
-
828
- except Exception as e:
829
- log(f"❌ Auth failed during test: {e}")
830
- return {
831
- "success": False,
832
- "error": f"Authentication failed: {str(e)}"
833
- }
834
-
835
- # Use test_request if provided, otherwise use body_template
836
- request_body = test_request if test_request is not None else api.body_template
837
-
838
- # Make the actual API request
839
- start_time = time.time()
840
-
841
- # Determine how to send the body based on method
842
- if api.method in ["POST", "PUT", "PATCH"]:
843
- response = requests.request(
844
- method=api.method,
845
- url=api.url,
846
- headers=headers,
847
- json=request_body,
848
- timeout=api.timeout_seconds,
849
- proxies={"http": api.proxy, "https": api.proxy} if api.proxy else None
850
- )
851
- elif api.method == "GET":
852
- response = requests.request(
853
- method=api.method,
854
- url=api.url,
855
- headers=headers,
856
- params=request_body if isinstance(request_body, dict) else None,
857
- timeout=api.timeout_seconds,
858
- proxies={"http": api.proxy, "https": api.proxy} if api.proxy else None
859
- )
860
- else: # DELETE, HEAD, etc.
861
- response = requests.request(
862
- method=api.method,
863
- url=api.url,
864
- headers=headers,
865
- timeout=api.timeout_seconds,
866
- proxies={"http": api.proxy, "https": api.proxy} if api.proxy else None
867
- )
868
-
869
- response_time = int((time.time() - start_time) * 1000)
870
-
871
- # Prepare response body
872
- try:
873
- response_body = response.json()
874
- except:
875
- response_body = response.text
876
-
877
- # Check if request was successful (2xx status codes)
878
- is_success = 200 <= response.status_code < 300
879
-
880
- # Extract values if response mappings are defined
881
- extracted_values = []
882
- if api.response_mappings and isinstance(response_body, dict):
883
- from jsonpath_ng import parse
884
- for mapping in api.response_mappings:
885
- try:
886
- jsonpath_expr = parse(mapping['json_path'])
887
- matches = jsonpath_expr.find(response_body)
888
- value = matches[0].value if matches else None
889
- extracted_values.append({
890
- "variable_name": mapping['variable_name'],
891
- "value": value,
892
- "type": mapping['type'],
893
- "caption": mapping.get('caption', '')
894
- })
895
- except Exception as e:
896
- log(f"Failed to extract {mapping['variable_name']}: {e}")
897
- extracted_values.append({
898
- "variable_name": mapping['variable_name'],
899
- "value": None,
900
- "error": str(e),
901
- "type": mapping['type'],
902
- "caption": mapping.get('caption', '')
903
- })
904
-
905
- result = {
906
- "success": is_success,
907
- "status_code": response.status_code,
908
- "response_time": response_time,
909
- "response_body": response_body,
910
- "response_headers": dict(response.headers),
911
- "request_body": request_body,
912
- "request_headers": headers
913
- }
914
-
915
- # Add extracted values if any
916
- if extracted_values:
917
- result["extracted_values"] = extracted_values
918
-
919
- # Add error info for non-2xx responses
920
- if not is_success:
921
- result["error"] = f"HTTP {response.status_code}: {response.reason}"
922
-
923
- log(f"📋 Test result: {response.status_code} in {response_time}ms")
924
- return result
925
-
926
- except requests.exceptions.Timeout:
927
- return {
928
- "success": False,
929
- "error": f"Request timed out after {api.timeout_seconds} seconds"
930
- }
931
- except requests.exceptions.ConnectionError as e:
932
  return {
933
- "success": False,
934
- "error": f"Connection error: {str(e)}"
 
 
 
935
  }
936
  except Exception as e:
937
- log(f"❌ Test API error: {e}")
938
  return {
939
  "success": False,
940
  "error": str(e)
941
  }
942
 
943
- @router.post("/test/run-all")
944
- async def run_all_tests(
945
- request: TestRequest,
 
 
946
  username: str = Depends(verify_token)
947
  ):
948
- """Run all tests"""
949
- # TODO: Implement test runner
950
  return {
951
- "status": "completed",
952
- "total": 10,
953
- "passed": 8,
954
- "failed": 2,
955
- "details": []
956
  }
957
 
958
- # ===================== Import/Export Endpoints =====================
959
- @router.post("/projects/import")
960
- async def import_project(
961
- project_data: dict = Body(...),
962
- username: str = Depends(verify_token)
963
- ):
964
- """Import project from JSON"""
965
- imported_project = ConfigProvider.import_project(project_data, username)
966
-
967
- log(f"✅ Project '{imported_project.name}' imported by {username}")
968
- return imported_project.model_dump()
969
-
970
- @router.get("/projects/{project_id}/export")
971
- async def export_project(
972
- project_id: int,
973
- username: str = Depends(verify_token)
974
- ):
975
- """Export project as JSON"""
976
- export_data = ConfigProvider.export_project(project_id, username)
977
-
978
- log(f"✅ Project exported by {username}")
979
- return export_data
980
-
981
- # ===================== TTS Endpoints =====================
982
- @router.post("/tts/generate")
983
- async def generate_tts(
984
- request: TTSRequest,
985
- username: str = Depends(verify_token)
986
- ):
987
- """Generate TTS audio from text"""
988
- try:
989
- # ConfigProvider'dan güncel config'i al (reload yerine get kullan)
990
- cfg = ConfigProvider.get()
991
-
992
- tts_engine = cfg.global_config.tts_engine
993
- log(f"🔧 TTS Engine: {tts_engine}")
994
-
995
- if tts_engine == "no_tts":
996
- raise HTTPException(status_code=400, detail="TTS is not configured")
997
-
998
- # Get decrypted API key
999
- api_key = cfg.global_config.get_tts_api_key()
1000
-
1001
- if not api_key:
1002
- log("❌ TTS API key not found in config")
1003
- raise HTTPException(status_code=400, detail="TTS API key not configured")
1004
-
1005
- # Import here to avoid circular dependency
1006
- from tts_interface import create_tts_provider
1007
-
1008
- tts_provider = create_tts_provider(tts_engine, api_key)
1009
- if not tts_provider:
1010
- raise HTTPException(status_code=500, detail="Failed to create TTS provider")
1011
-
1012
- log(f"🎤 Generating TTS for {len(request.text)} characters using {tts_engine}")
1013
- log(f"📝 Voice: {request.voice_id}, Model: {request.model_id}, Format: {request.output_format}")
1014
-
1015
- # Generate audio
1016
- audio_data = await tts_provider.synthesize(
1017
- text=request.text,
1018
- voice_id=request.voice_id,
1019
- model_id=request.model_id,
1020
- output_format=request.output_format
1021
- )
1022
-
1023
- # Return audio data
1024
- from fastapi.responses import Response
1025
-
1026
- content_type = "audio/mpeg" if request.output_format and request.output_format.startswith("mp3") else "audio/wav"
1027
-
1028
- return Response(
1029
- content=audio_data,
1030
- media_type=content_type,
1031
- headers={
1032
- "Content-Disposition": f"attachment; filename=tts_output.{request.output_format.split('_')[0] if request.output_format else 'mp3'}"
1033
- }
1034
- )
1035
-
1036
- except HTTPException:
1037
- raise
1038
- except Exception as e:
1039
- log(f"❌ TTS generation error: {str(e)}")
1040
- import traceback
1041
- log(traceback.format_exc())
1042
- raise HTTPException(status_code=500, detail=f"TTS generation failed: {str(e)}")
1043
-
1044
-
1045
- # ===================== Activity Log Endpoints =====================
1046
- @router.get("/activity-log")
1047
- async def get_activity_log(
1048
- limit: int = Query(100, ge=1, le=1000),
1049
- username: str = Depends(verify_token)
1050
- ):
1051
- """Get activity log"""
1052
- cfg = ConfigProvider.get()
1053
- logs = cfg.activity_log
1054
-
1055
- # Return latest entries
1056
- return [log.model_dump() for log in logs[-limit:]]
1057
 
1058
  # ===================== Cleanup Task =====================
1059
- def cleanup_old_logs():
1060
- """Cleanup old activity logs (runs in background)"""
 
 
1061
  while True:
1062
  try:
1063
- ConfigProvider.cleanup_activity_logs()
1064
- log("🧹 Cleaned up old activity logs")
 
 
 
 
1065
  except Exception as e:
1066
- log(f"Error in cleanup task: {e}")
1067
-
1068
- # Run every hour
1069
- time.sleep(3600)
1070
 
1071
  def start_cleanup_task():
1072
- """Start cleanup task in background"""
1073
- cleanup_thread = threading.Thread(target=cleanup_old_logs, daemon=True)
1074
- cleanup_thread.start()
1075
- log("🧹 Started activity log cleanup task")
 
 
 
 
 
 
 
 
21
  from pydantic import BaseModel, Field
22
 
23
  from utils import log
24
+ from config_provider import ConfigProvider, ProviderSettings
25
  from encryption_utils import encrypt, decrypt
26
 
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:
 
62
  class ChangePasswordRequest(BaseModel):
63
  current_password: str
64
  new_password: str
65
+
66
+ class ProviderSettingsUpdate(BaseModel):
67
+ name: str
68
+ api_key: Optional[str] = None
69
+ endpoint: Optional[str] = None
70
+ settings: Dict[str, Any] = Field(default_factory=dict)
71
 
72
  class EnvironmentUpdate(BaseModel):
73
+ llm_provider: ProviderSettingsUpdate
74
+ tts_provider: ProviderSettingsUpdate
75
+ stt_provider: ProviderSettingsUpdate
 
 
 
 
 
 
 
 
76
 
77
  class ProjectCreate(BaseModel):
78
  name: str
79
  caption: Optional[str] = ""
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
180
+ user = next((u for u in cfg.global_config.users if u.username == request.username), None)
181
  if not user:
182
  raise HTTPException(status_code=401, detail="Invalid credentials")
183
 
 
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)
 
198
  ):
199
  """Change user password"""
200
  cfg = ConfigProvider.get()
 
201
 
202
  # Find user
203
+ user = next((u for u in cfg.global_config.users if u.username == username), None)
204
  if not user:
205
  raise HTTPException(status_code=404, detail="User not found")
206
 
 
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}
 
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")
 
271
  """Update environment configuration"""
272
  log(f"📝 Updating environment config by {username}")
273
 
274
+ cfg = ConfigProvider.get()
 
 
 
 
 
 
 
 
 
 
 
 
275
 
276
+ # Validate LLM provider
277
+ llm_provider_def = cfg.global_config.get_provider_config("llm", update.llm_provider.name)
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
 
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
  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
  """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
  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
  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
 
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
  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(...),
 
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()
 
559
 
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"