ciyidogan commited on
Commit
2c3dbda
·
verified ·
1 Parent(s): e625432

Update admin_routes.py

Browse files
Files changed (1) hide show
  1. admin_routes.py +470 -459
admin_routes.py CHANGED
@@ -1,80 +1,36 @@
 
 
 
1
  """
2
- Flare Admin API Routes
3
- ~~~~~~~~~~~~~~~~~~~~~
4
- Admin UI için gerekli tüm endpoint'ler
5
- """
6
- import time
7
- import threading
8
  import os
9
- import json
10
  import hashlib
11
- import secrets
12
- import commentjson
13
- import bcrypt
14
- from datetime import datetime, timedelta
15
- from typing import Dict, List, Optional, Any
16
  from pathlib import Path
17
- from fastapi import APIRouter, HTTPException, Depends, Header
 
 
 
 
18
  from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
19
  from pydantic import BaseModel, Field
20
- import jwt
21
- from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
22
- from utils import log
23
- from config_provider import ConfigProvider
24
-
25
- # Activity log retention policy (keep last 30 days)
26
- ACTIVITY_LOG_RETENTION_DAYS = 30
27
- ACTIVITY_LOG_MAX_ENTRIES = 10000
28
 
29
- # Activity log cleanup fonksiyonunu thread-safe yap
30
- def cleanup_activity_log():
31
- """Cleanup old activity log entries - runs in background thread"""
32
- while True:
33
- try:
34
- config = load_config()
35
-
36
- if "activity_log" in config:
37
- # Calculate cutoff date
38
- cutoff_date = datetime.utcnow() - timedelta(days=ACTIVITY_LOG_RETENTION_DAYS)
39
-
40
- # Filter recent entries
41
- original_count = len(config["activity_log"])
42
- config["activity_log"] = [
43
- log_entry for log_entry in config["activity_log"]
44
- if datetime.fromisoformat(log_entry["timestamp"].replace("Z", "+00:00")) > cutoff_date
45
- ]
46
-
47
- # Also limit by max entries
48
- if len(config["activity_log"]) > ACTIVITY_LOG_MAX_ENTRIES:
49
- config["activity_log"] = config["activity_log"][-ACTIVITY_LOG_MAX_ENTRIES:]
50
-
51
- # Save if anything was removed
52
- removed_count = original_count - len(config["activity_log"])
53
- if removed_count > 0:
54
- save_config(config)
55
- log(f"🧹 Cleaned up {removed_count} old activity log entries")
56
-
57
- except Exception as e:
58
- log(f"❌ Activity log cleanup error: {e}")
59
-
60
- # Run cleanup once per day
61
- time.sleep(86400) # 24 hours
62
-
63
- # Start cleanup task when module loads
64
- def start_cleanup_task():
65
- thread = threading.Thread(target=cleanup_activity_log, daemon=True)
66
- thread.start()
67
 
68
- # ===================== Dynamic Config Loading =====================
69
  def get_jwt_config():
70
- """Get JWT configuration based on work_mode"""
71
- cfg = ConfigProvider.get()
72
 
73
- if cfg.global_config.is_cloud_mode():
74
- # Cloud mode - use HuggingFace Secrets
75
  jwt_secret = os.getenv("JWT_SECRET")
76
  if not jwt_secret:
77
- log(" JWT_SECRET not found in HuggingFace Secrets!")
78
  jwt_secret = "flare-admin-secret-key-change-in-production" # Fallback
79
  else:
80
  # On-premise mode - use .env file
@@ -113,6 +69,8 @@ class EnvironmentUpdate(BaseModel):
113
  class ProjectCreate(BaseModel):
114
  name: str
115
  caption: Optional[str] = ""
 
 
116
 
117
  class ProjectUpdate(BaseModel):
118
  caption: str
@@ -188,109 +146,112 @@ def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security))
188
  raise HTTPException(status_code=401, detail="Invalid token")
189
 
190
  def hash_password(password: str, salt: str = None) -> tuple[str, str]:
191
- """Hash password with bcrypt. Returns (hash, salt)"""
 
192
  if salt is None:
193
- # Generate new salt
194
- salt_bytes = bcrypt.gensalt()
195
- salt = salt_bytes.decode('utf-8')
196
- else:
197
- # Convert string salt to bytes
198
- salt_bytes = salt.encode('utf-8')
199
 
200
  # Hash the password
201
- hashed = bcrypt.hashpw(password.encode('utf-8'), salt_bytes).decode('utf-8')
202
 
203
- return hashed, salt
204
 
205
- def verify_password(password: str, stored_hash: str, salt: str = None) -> bool:
206
- """Verify password against hash - supports both bcrypt and SHA256"""
207
- # First try bcrypt
208
- if salt and len(stored_hash) == 60: # bcrypt hash length
209
- try:
210
- return bcrypt.checkpw(password.encode('utf-8'), stored_hash.encode('utf-8'))
211
- except:
212
- pass
213
-
214
- # Fallback to SHA256 for backward compatibility
215
- sha256_hash = hashlib.sha256(password.encode()).hexdigest()
216
- return sha256_hash == stored_hash
217
-
218
- def load_config() -> Dict[str, Any]:
219
  """Load service_config.jsonc"""
220
  config_path = Path("service_config.jsonc")
 
 
 
221
  with open(config_path, 'r', encoding='utf-8') as f:
222
- return commentjson.load(f)
223
-
224
- def save_config(config: Dict[str, Any]):
225
- """Save service_config.jsonc with pretty formatting"""
226
- config_path = Path("service_config.jsonc")
227
- with open(config_path, 'w', encoding='utf-8') as f:
228
- # Convert to JSON string with proper formatting
229
- json_str = json.dumps(config, indent=2, ensure_ascii=False)
230
- f.write(json_str)
231
-
232
- def get_timestamp() -> str:
233
- """Get current ISO timestamp"""
234
- return datetime.utcnow().isoformat() + "Z"
235
-
236
- def add_activity_log(config: Dict[str, Any], user: str, action: str, entity_type: str,
237
- entity_id: Any, entity_name: str, details: str = ""):
238
- """Add entry to activity log"""
 
 
 
 
 
 
239
  if "activity_log" not in config:
240
  config["activity_log"] = []
241
-
242
- log_entry = {
243
- "id": len(config["activity_log"]) + 1,
 
 
 
244
  "timestamp": get_timestamp(),
245
- "user": user,
246
  "action": action,
247
  "entity_type": entity_type,
248
  "entity_id": entity_id,
249
  "entity_name": entity_name,
250
  "details": details
251
- }
252
-
253
- config["activity_log"].append(log_entry)
254
-
255
- # Keep only last 100 entries
256
- if len(config["activity_log"]) > 100:
257
- config["activity_log"] = config["activity_log"][-100:]
258
 
259
  # ===================== Auth Endpoints =====================
260
  @router.post("/login", response_model=LoginResponse)
261
  async def login(request: LoginRequest):
262
- """User login"""
263
  config = load_config()
264
- jwt_config = get_jwt_config()
265
-
266
- # Find user
267
  users = config.get("config", {}).get("users", [])
 
 
268
  user = next((u for u in users if u["username"] == request.username), None)
269
-
270
  if not user:
271
  raise HTTPException(status_code=401, detail="Invalid credentials")
272
-
273
- # Verify password with bcrypt
274
- if user.get("salt"):
275
- # New bcrypt format
276
- if not verify_password(request.password, user["password_hash"], user["salt"]):
277
- raise HTTPException(status_code=401, detail="Invalid credentials")
278
- else:
279
- # Legacy SHA256 format
280
- if hashlib.sha256(request.password.encode()).hexdigest() != user["password_hash"]:
281
- raise HTTPException(status_code=401, detail="Invalid credentials")
282
-
283
- # Create JWT token
284
- expire = datetime.utcnow() + timedelta(hours=jwt_config["expiration_hours"])
285
  payload = {
286
  "sub": request.username,
287
- "exp": expire
288
  }
 
289
  token = jwt.encode(payload, jwt_config["secret"], algorithm=jwt_config["algorithm"])
290
-
291
  log(f"✅ User '{request.username}' logged in")
292
  return LoginResponse(token=token, username=request.username)
293
-
294
  @router.post("/change-password")
295
  async def change_password(
296
  request: ChangePasswordRequest,
@@ -298,74 +259,74 @@ async def change_password(
298
  ):
299
  """Change user password"""
300
  config = load_config()
301
-
302
- # Find user
303
  users = config.get("config", {}).get("users", [])
304
- user_index = next((i for i, u in enumerate(users) if u["username"] == username), None)
305
 
306
- if user_index is None:
 
 
307
  raise HTTPException(status_code=404, detail="User not found")
308
 
309
- user = users[user_index]
310
-
311
  # Verify current password
312
- if not verify_password(request.current_password, user["password_hash"], user.get("salt", "")):
313
  raise HTTPException(status_code=401, detail="Current password is incorrect")
314
 
315
- # Generate new hash with new salt
316
  new_hash, new_salt = hash_password(request.new_password)
317
-
318
- # Update user
319
- users[user_index]["password_hash"] = new_hash
320
- users[user_index]["salt"] = new_salt
321
 
322
  # Save config
323
  save_config(config)
324
 
325
- # Add activity log
326
- add_activity_log(config, username, "CHANGE_PASSWORD", "user", username, username, "Password changed")
327
- save_config(config)
328
-
329
  log(f"✅ Password changed for user '{username}'")
330
- return {"success": True, "message": "Password changed successfully"}
331
-
332
  # ===================== Environment Endpoints =====================
333
  @router.get("/environment")
334
  async def get_environment(username: str = Depends(verify_token)):
335
  """Get environment configuration"""
336
  config = load_config()
337
  env_config = config.get("config", {})
338
-
339
  return {
340
- "work_mode": env_config.get("work_mode", "hfcloud"),
341
  "cloud_token": env_config.get("cloud_token", ""),
342
- "spark_endpoint": env_config.get("spark_endpoint", "")
343
  }
344
 
345
  @router.put("/environment")
346
- async def update_environment(env: EnvironmentUpdate, username: str = Depends(verify_token)):
 
 
 
347
  """Update environment configuration"""
348
  config = load_config()
349
-
350
  # Update config
351
- config["config"]["work_mode"] = env.work_mode
352
- config["config"]["cloud_token"] = env.cloud_token if env.work_mode != "on-premise" else ""
353
- config["config"]["spark_endpoint"] = env.spark_endpoint
354
  config["config"]["last_update_date"] = get_timestamp()
355
  config["config"]["last_update_user"] = username
356
-
357
- # Save
358
- save_config(config)
359
-
360
  # Add activity log
361
- add_activity_log(config, username, "UPDATE_ENVIRONMENT", "config", 0, "environment",
362
- f"Work mode: {env.work_mode}")
 
 
363
  save_config(config)
364
-
365
- log(f"✅ Environment updated by {username}")
366
  return {"success": True}
367
 
368
  # ===================== Project Endpoints =====================
 
 
 
 
 
 
 
369
  @router.get("/projects")
370
  async def list_projects(
371
  include_deleted: bool = False,
@@ -374,18 +335,13 @@ async def list_projects(
374
  """List all projects"""
375
  config = load_config()
376
  projects = config.get("projects", [])
377
-
378
  # Filter deleted if needed
379
  if not include_deleted:
380
  projects = [p for p in projects if not p.get("deleted", False)]
381
-
382
  return projects
383
 
384
- @router.get("/projects/names")
385
- def list_enabled_projects():
386
- cfg = ConfigProvider.get()
387
- return [p.name for p in cfg.projects if p.enabled]
388
-
389
  @router.get("/projects/{project_id}")
390
  async def get_project(
391
  project_id: int,
@@ -395,13 +351,13 @@ async def get_project(
395
  try:
396
  config = load_config()
397
  projects = config.get("projects", [])
398
-
399
  project = next((p for p in projects if p.get("id") == project_id), None)
400
  if not project or project.get("deleted", False):
401
  raise HTTPException(status_code=404, detail="Project not found")
402
-
403
  return project
404
-
405
  except HTTPException:
406
  raise
407
  except Exception as e:
@@ -415,11 +371,11 @@ async def create_project(
415
  username: str = Depends(verify_token)
416
  ):
417
  cfg = load_config()
418
-
419
- # 1️⃣ Yeni proje IDsi
420
  project_id = cfg["config"].get("project_id_counter", 0) + 1
421
  cfg["config"]["project_id_counter"] = project_id
422
-
423
  # 2️⃣ Proje gövdesi
424
  new_project = {
425
  "id": project_id,
@@ -433,10 +389,10 @@ async def create_project(
433
  "created_by": username,
434
  "last_update_date": datetime.utcnow().isoformat(),
435
  "last_update_user": username,
436
-
437
  # *** Versiyon sayaçları ***
438
  "version_id_counter": 1,
439
-
440
  # *** İlk versiyon (no = 1) ***
441
  "versions": [{
442
  "id": 1,
@@ -468,14 +424,14 @@ async def create_project(
468
  "published_by": None
469
  }]
470
  }
471
-
472
  cfg.setdefault("projects", []).append(new_project)
473
  save_config(cfg)
474
-
475
  add_activity_log(cfg, username, "CREATE_PROJECT",
476
  "project", project_id, new_project["name"])
477
  save_config(cfg)
478
-
479
  return new_project # 201 CREATED
480
 
481
  @router.put("/projects/{project_id}")
@@ -486,27 +442,27 @@ async def update_project(
486
  ):
487
  """Update project"""
488
  config = load_config()
489
-
490
  # Find project
491
  project = next((p for p in config.get("projects", []) if p["id"] == project_id), None)
492
  if not project:
493
  raise HTTPException(status_code=404, detail="Project not found")
494
-
495
  # Check race condition
496
  if project.get("last_update_date") != update.last_update_date:
497
  raise HTTPException(status_code=409, detail="Project was modified by another user")
498
-
499
  # Update
500
  project["caption"] = update.caption
501
  project["last_update_date"] = get_timestamp()
502
  project["last_update_user"] = username
503
-
504
  # Add activity log
505
  add_activity_log(config, username, "UPDATE_PROJECT", "project", project_id, project["name"])
506
-
507
  # Save
508
  save_config(config)
509
-
510
  log(f"✅ Project '{project['name']}' updated by {username}")
511
  return project
512
 
@@ -514,101 +470,165 @@ async def update_project(
514
  async def delete_project(project_id: int, username: str = Depends(verify_token)):
515
  """Delete project (soft delete)"""
516
  config = load_config()
517
-
518
  # Find project
519
  project = next((p for p in config.get("projects", []) if p["id"] == project_id), None)
520
  if not project:
521
  raise HTTPException(status_code=404, detail="Project not found")
522
-
523
  # Soft delete
524
  project["deleted"] = True
525
  project["last_update_date"] = get_timestamp()
526
  project["last_update_user"] = username
527
-
528
  # Add activity log
529
  add_activity_log(config, username, "DELETE_PROJECT", "project", project_id, project["name"])
530
-
531
  # Save
532
  save_config(config)
533
-
534
  log(f"✅ Project '{project['name']}' deleted by {username}")
535
  return {"success": True}
536
 
537
  @router.patch("/projects/{project_id}/toggle")
538
  async def toggle_project(project_id: int, username: str = Depends(verify_token)):
539
- """Enable/disable project"""
540
  config = load_config()
541
-
542
  # Find project
543
  project = next((p for p in config.get("projects", []) if p["id"] == project_id), None)
544
  if not project:
545
  raise HTTPException(status_code=404, detail="Project not found")
546
-
547
  # Toggle
548
- project["enabled"] = not project.get("enabled", True)
549
  project["last_update_date"] = get_timestamp()
550
  project["last_update_user"] = username
551
-
552
  # Add activity log
553
  action = "ENABLE_PROJECT" if project["enabled"] else "DISABLE_PROJECT"
554
  add_activity_log(config, username, action, "project", project_id, project["name"])
555
-
556
  # Save
557
  save_config(config)
558
-
559
  log(f"✅ Project '{project['name']}' {'enabled' if project['enabled'] else 'disabled'} by {username}")
560
  return {"enabled": project["enabled"]}
561
 
562
  # ===================== Version Endpoints =====================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
563
  @router.post("/projects/{project_id}/versions")
564
  async def create_version(
565
  project_id: int,
566
- version: VersionCreate,
567
  username: str = Depends(verify_token)
568
  ):
569
- """Create new version from existing version"""
570
  config = load_config()
571
-
572
  # Find project
573
  project = next((p for p in config.get("projects", []) if p["id"] == project_id), None)
574
  if not project:
575
  raise HTTPException(status_code=404, detail="Project not found")
576
-
577
- # Check if there's unpublished version
578
- unpublished = [v for v in project.get("versions", []) if not v.get("published", False)]
579
- if unpublished:
580
- raise HTTPException(status_code=400, detail="There is already an unpublished version")
581
-
582
- # Find source version
583
- source = next((v for v in project.get("versions", []) if v["id"] == version.source_version_id), None)
584
- if not source:
585
- raise HTTPException(status_code=404, detail="Source version not found")
586
-
587
- # Create new version
588
- project["version_id_counter"] = project.get("version_id_counter", 0) + 1
589
- new_version = source.copy()
590
- new_version["id"] = project["version_id_counter"]
591
- new_version["caption"] = version.caption
592
- new_version["published"] = False
593
- new_version["created_date"] = get_timestamp()
594
- new_version["created_by"] = username
595
- new_version["last_update_date"] = get_timestamp()
596
- new_version["last_update_user"] = username
597
- new_version["publish_date"] = None
598
- new_version["published_by"] = None
599
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
600
  project["versions"].append(new_version)
 
 
601
  project["last_update_date"] = get_timestamp()
602
  project["last_update_user"] = username
603
-
604
  # Add activity log
605
- add_activity_log(config, username, "CREATE_VERSION", "version", new_version["id"],
606
- f"{project['name']} v{new_version['id']}")
607
-
608
  # Save
609
  save_config(config)
610
-
611
- log(f"✅ Version {new_version['id']} created for project '{project['name']}' by {username}")
612
  return new_version
613
 
614
  @router.put("/projects/{project_id}/versions/{version_id}")
@@ -616,51 +636,48 @@ async def update_version(
616
  project_id: int,
617
  version_id: int,
618
  update: VersionUpdate,
619
- force: bool = False, # Add force parameter
620
  username: str = Depends(verify_token)
621
  ):
622
  """Update version"""
623
  config = load_config()
624
-
625
  # Find project and version
626
  project = next((p for p in config.get("projects", []) if p["id"] == project_id), None)
627
  if not project:
628
  raise HTTPException(status_code=404, detail="Project not found")
629
-
630
  version = next((v for v in project.get("versions", []) if v["id"] == version_id), None)
631
  if not version:
632
  raise HTTPException(status_code=404, detail="Version not found")
633
-
634
- # Check if published
 
 
 
 
635
  if version.get("published", False):
636
- raise HTTPException(status_code=400, detail="Cannot update published version")
637
-
638
- # Check race condition (skip if force=True)
639
- if not force and version.get("last_update_date") != update.last_update_date:
640
- raise HTTPException(
641
- status_code=409,
642
- detail="Version was modified by another user. Please reload or force save."
643
- )
644
-
645
- # Update
646
  version["caption"] = update.caption
647
  version["general_prompt"] = update.general_prompt
648
  version["llm"] = update.llm
649
  version["intents"] = [intent.dict() for intent in update.intents]
650
  version["last_update_date"] = get_timestamp()
651
  version["last_update_user"] = username
652
-
 
653
  project["last_update_date"] = get_timestamp()
654
  project["last_update_user"] = username
655
-
656
  # Add activity log
657
  add_activity_log(config, username, "UPDATE_VERSION", "version", version_id,
658
- f"{project['name']} v{version_id}")
659
-
660
  # Save
661
  save_config(config)
662
-
663
- log(f"✅ Version {version_id} updated for project '{project['name']}' by {username}")
664
  return version
665
 
666
  @router.post("/projects/{project_id}/versions/{version_id}/publish")
@@ -671,40 +688,39 @@ async def publish_version(
671
  ):
672
  """Publish version"""
673
  config = load_config()
674
-
675
  # Find project and version
676
  project = next((p for p in config.get("projects", []) if p["id"] == project_id), None)
677
  if not project:
678
  raise HTTPException(status_code=404, detail="Project not found")
679
-
680
  version = next((v for v in project.get("versions", []) if v["id"] == version_id), None)
681
  if not version:
682
  raise HTTPException(status_code=404, detail="Version not found")
683
-
684
  # Unpublish all other versions
685
  for v in project.get("versions", []):
686
  if v["id"] != version_id:
687
  v["published"] = False
688
-
689
  # Publish this version
690
  version["published"] = True
691
  version["publish_date"] = get_timestamp()
692
  version["published_by"] = username
693
  version["last_update_date"] = get_timestamp()
694
  version["last_update_user"] = username
695
-
 
696
  project["last_update_date"] = get_timestamp()
697
  project["last_update_user"] = username
698
-
699
  # Add activity log
700
  add_activity_log(config, username, "PUBLISH_VERSION", "version", version_id,
701
- f"{project['name']} v{version_id}")
702
-
703
  # Save
704
  save_config(config)
705
-
706
- # TODO: Notify Spark about new version
707
-
708
  log(f"✅ Version {version_id} published for project '{project['name']}' by {username}")
709
  return {"success": True}
710
 
@@ -716,35 +732,35 @@ async def delete_version(
716
  ):
717
  """Delete version (soft delete)"""
718
  config = load_config()
719
-
720
  # Find project and version
721
  project = next((p for p in config.get("projects", []) if p["id"] == project_id), None)
722
  if not project:
723
  raise HTTPException(status_code=404, detail="Project not found")
724
-
725
  version = next((v for v in project.get("versions", []) if v["id"] == version_id), None)
726
  if not version:
727
  raise HTTPException(status_code=404, detail="Version not found")
728
-
729
  # Cannot delete published version
730
  if version.get("published", False):
731
  raise HTTPException(status_code=400, detail="Cannot delete published version")
732
-
733
  # Soft delete
734
  version["deleted"] = True
735
  version["last_update_date"] = get_timestamp()
736
  version["last_update_user"] = username
737
-
738
  project["last_update_date"] = get_timestamp()
739
  project["last_update_user"] = username
740
-
741
  # Add activity log
742
  add_activity_log(config, username, "DELETE_VERSION", "version", version_id,
743
  f"{project['name']} v{version_id}")
744
-
745
  # Save
746
  save_config(config)
747
-
748
  log(f"✅ Version {version_id} deleted for project '{project['name']}' by {username}")
749
  return {"success": True}
750
 
@@ -757,23 +773,23 @@ async def list_apis(
757
  """List all APIs"""
758
  config = load_config()
759
  apis = config.get("apis", [])
760
-
761
  # Filter deleted if needed
762
  if not include_deleted:
763
  apis = [a for a in apis if not a.get("deleted", False)]
764
-
765
  return apis
766
 
767
  @router.post("/apis")
768
  async def create_api(api: APICreate, username: str = Depends(verify_token)):
769
  """Create new API"""
770
  config = load_config()
771
-
772
  # Check duplicate name
773
  existing = [a for a in config.get("apis", []) if a["name"] == api.name]
774
  if existing:
775
  raise HTTPException(status_code=400, detail="API name already exists")
776
-
777
  # Create API
778
  new_api = api.dict()
779
  new_api["deleted"] = False
@@ -781,17 +797,17 @@ async def create_api(api: APICreate, username: str = Depends(verify_token)):
781
  new_api["created_by"] = username
782
  new_api["last_update_date"] = get_timestamp()
783
  new_api["last_update_user"] = username
784
-
785
  if "apis" not in config:
786
  config["apis"] = []
787
  config["apis"].append(new_api)
788
-
789
  # Add activity log
790
  add_activity_log(config, username, "CREATE_API", "api", api.name, api.name)
791
-
792
  # Save
793
  save_config(config)
794
-
795
  log(f"✅ API '{api.name}' created by {username}")
796
  return new_api
797
 
@@ -803,16 +819,16 @@ async def update_api(
803
  ):
804
  """Update API"""
805
  config = load_config()
806
-
807
  # Find API
808
  api = next((a for a in config.get("apis", []) if a["name"] == api_name), None)
809
  if not api:
810
  raise HTTPException(status_code=404, detail="API not found")
811
-
812
  # Check race condition
813
  if api.get("last_update_date") != update.last_update_date:
814
  raise HTTPException(status_code=409, detail="API was modified by another user")
815
-
816
  # Check if API is in use
817
  for project in config.get("projects", []):
818
  for version in project.get("versions", []):
@@ -820,20 +836,20 @@ async def update_api(
820
  if intent.get("action") == api_name and version.get("published", False):
821
  raise HTTPException(status_code=400,
822
  detail=f"API is used in published version of project '{project['name']}'")
823
-
824
  # Update
825
  update_dict = update.dict()
826
  del update_dict["last_update_date"]
827
  api.update(update_dict)
828
  api["last_update_date"] = get_timestamp()
829
  api["last_update_user"] = username
830
-
831
  # Add activity log
832
  add_activity_log(config, username, "UPDATE_API", "api", api_name, api_name)
833
-
834
  # Save
835
  save_config(config)
836
-
837
  log(f"✅ API '{api_name}' updated by {username}")
838
  return api
839
 
@@ -841,12 +857,12 @@ async def update_api(
841
  async def delete_api(api_name: str, username: str = Depends(verify_token)):
842
  """Delete API (soft delete)"""
843
  config = load_config()
844
-
845
  # Find API
846
  api = next((a for a in config.get("apis", []) if a["name"] == api_name), None)
847
  if not api:
848
  raise HTTPException(status_code=404, detail="API not found")
849
-
850
  # Check if API is in use
851
  for project in config.get("projects", []):
852
  for version in project.get("versions", []):
@@ -854,18 +870,18 @@ async def delete_api(api_name: str, username: str = Depends(verify_token)):
854
  if intent.get("action") == api_name:
855
  raise HTTPException(status_code=400,
856
  detail=f"API is used in project '{project['name']}'")
857
-
858
  # Soft delete
859
  api["deleted"] = True
860
  api["last_update_date"] = get_timestamp()
861
  api["last_update_user"] = username
862
-
863
  # Add activity log
864
  add_activity_log(config, username, "DELETE_API", "api", api_name, api_name)
865
-
866
  # Save
867
  save_config(config)
868
-
869
  log(f"✅ API '{api_name}' deleted by {username}")
870
  return {"success": True}
871
 
@@ -874,7 +890,7 @@ async def delete_api(api_name: str, username: str = Depends(verify_token)):
874
  async def test_api(api: APICreate, username: str = Depends(verify_token)):
875
  """Test API endpoint"""
876
  import requests
877
-
878
  try:
879
  # Prepare request
880
  headers = api.headers.copy()
@@ -882,7 +898,7 @@ async def test_api(api: APICreate, username: str = Depends(verify_token)):
882
  # Add sample auth token for testing
883
  if api.auth and api.auth.get("enabled"):
884
  headers["Authorization"] = "Bearer test_token_12345"
885
-
886
  # Make request
887
  response = requests.request(
888
  method=api.method,
@@ -893,192 +909,187 @@ async def test_api(api: APICreate, username: str = Depends(verify_token)):
893
  timeout=api.timeout_seconds,
894
  proxies={"http": api.proxy, "https": api.proxy} if api.proxy else None
895
  )
896
-
897
  return {
898
  "success": True,
899
  "status_code": response.status_code,
900
- "response_time_ms": int(response.elapsed.total_seconds() * 1000),
901
- "headers": dict(response.headers),
902
- "body": response.text[:1000], # First 1000 chars
903
- "request_headers": headers, # Debug için
904
- "request_body": api.body_template # Debug için
905
- }
906
- except requests.exceptions.Timeout:
907
- return {
908
- "success": False,
909
- "error": f"Request timed out after {api.timeout_seconds} seconds"
910
  }
911
  except Exception as e:
912
  return {
913
  "success": False,
914
- "error": str(e),
915
- "error_type": type(e).__name__
916
  }
917
 
918
- @router.get("/activity-log")
919
- async def get_activity_log(
920
- page: int = 1,
921
- limit: int = 50,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
922
  username: str = Depends(verify_token)
923
  ):
924
- """Get activity log with pagination"""
925
  config = load_config()
926
- logs = config.get("activity_log", [])
927
 
928
- # Sort by timestamp descending (newest first)
929
- logs = sorted(logs, key=lambda x: x['timestamp'], reverse=True)
 
930
 
931
- # Calculate pagination
932
- total = len(logs)
933
- start = (page - 1) * limit
934
- end = start + limit
 
935
 
936
- # Get page of logs
937
- page_logs = logs[start:end]
 
938
 
939
- return {
940
- "items": page_logs,
941
- "total": total,
942
- "page": page,
943
- "limit": limit,
944
- "pages": (total + limit - 1) // limit # Ceiling division
945
- }
946
-
947
- @router.post("/test/run-all")
948
- async def run_all_tests(test: TestRequest, username: str = Depends(verify_token)):
949
- """Run all tests"""
950
- # This is a placeholder - in real implementation, this would run actual tests
951
- log(f"🧪 Running {test.test_type} tests requested by {username}")
952
-
953
- # Simulate test results
954
- results = {
955
- "test_type": test.test_type,
956
- "start_time": get_timestamp(),
957
- "tests": [
958
- {"name": "Login with valid credentials", "status": "PASS", "duration_ms": 120},
959
- {"name": "Create new project", "status": "PASS", "duration_ms": 340},
960
- {"name": "Delete API in use", "status": "PASS", "duration_ms": 45},
961
- {"name": "Race condition detection", "status": "PASS", "duration_ms": 567},
962
- {"name": "Invalid token handling", "status": "PASS", "duration_ms": 23}
963
- ],
964
- "summary": {
965
- "total": 5,
966
- "passed": 5,
967
- "failed": 0,
968
- "duration_ms": 1095
969
- }
970
  }
971
-
972
- return results
973
-
974
- @router.post("/validate/regex")
975
- async def validate_regex(pattern: str, test_value: str, username: str = Depends(verify_token)):
976
- """Validate regex pattern"""
977
- import re
978
-
979
- try:
980
- compiled = re.compile(pattern)
981
- match = compiled.fullmatch(test_value)
982
- return {
983
- "valid": True,
984
- "matches": match is not None
985
- }
986
- except re.error as e:
987
- return {
988
- "valid": False,
989
- "error": str(e)
 
 
990
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
991
 
992
- # ===================== Export/Import =====================
993
  @router.get("/projects/{project_id}/export")
994
- async def export_project(project_id: int, username: str = Depends(verify_token)):
 
 
 
995
  """Export project as JSON"""
996
  config = load_config()
997
-
998
  # Find project
999
  project = next((p for p in config.get("projects", []) if p["id"] == project_id), None)
1000
  if not project:
1001
  raise HTTPException(status_code=404, detail="Project not found")
1002
-
1003
- # Collect all related APIs
1004
- api_names = set()
1005
- for version in project.get("versions", []):
1006
- for intent in version.get("intents", []):
1007
- api_names.add(intent.get("action"))
1008
-
1009
- apis = [a for a in config.get("apis", []) if a["name"] in api_names]
1010
-
1011
- # Create export
1012
  export_data = {
1013
- "export_date": get_timestamp(),
1014
- "exported_by": username,
1015
- "project": project,
1016
- "apis": apis
 
1017
  }
1018
-
1019
- log(f"📤 Project '{project['name']}' exported by {username}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1020
  return export_data
1021
 
1022
- @router.post("/projects/import")
1023
- async def import_project(data: Dict[str, Any], username: str = Depends(verify_token)):
1024
- """Import project from JSON"""
 
 
 
 
1025
  config = load_config()
 
 
 
 
1026
 
1027
- # Extract project and APIs
1028
- project_data = data.get("project", {})
1029
- apis_data = data.get("apis", [])
1030
-
1031
- # Check duplicate project name
1032
- existing = [p for p in config.get("projects", []) if p["name"] == project_data.get("name")]
1033
- if existing:
1034
- # Generate new name
1035
- project_data["name"] = f"{project_data['name']}_imported_{int(datetime.now().timestamp())}"
1036
-
1037
- # Generate new IDs
1038
- config["config"]["project_id_counter"] = config["config"].get("project_id_counter", 0) + 1
1039
- project_data["id"] = config["config"]["project_id_counter"]
1040
-
1041
- # Reset version IDs
1042
- version_counter = 0
1043
- for version in project_data.get("versions", []):
1044
- version_counter += 1
1045
- version["id"] = version_counter
1046
- version["published"] = False # Imported versions are unpublished
1047
-
1048
- project_data["version_id_counter"] = version_counter
1049
- project_data["created_date"] = get_timestamp()
1050
- project_data["created_by"] = username
1051
- project_data["last_update_date"] = get_timestamp()
1052
- project_data["last_update_user"] = username
1053
-
1054
- # Import APIs
1055
- imported_apis = []
1056
- for api_data in apis_data:
1057
- # Check if API already exists
1058
- existing_api = next((a for a in config.get("apis", []) if a["name"] == api_data.get("name")), None)
1059
- if not existing_api:
1060
- api_data["created_date"] = get_timestamp()
1061
- api_data["created_by"] = username
1062
- api_data["last_update_date"] = get_timestamp()
1063
- api_data["last_update_user"] = username
1064
- api_data["deleted"] = False
1065
- config["apis"].append(api_data)
1066
- imported_apis.append(api_data["name"])
1067
-
1068
- # Add project
1069
- config["projects"].append(project_data)
1070
-
1071
- # Add activity log
1072
- add_activity_log(config, username, "IMPORT_PROJECT", "project", project_data["id"],
1073
- project_data["name"], f"Imported with {len(imported_apis)} APIs")
1074
-
1075
- # Save
1076
- save_config(config)
1077
 
1078
- log(f"📥 Project '{project_data['name']}' imported by {username}")
1079
- return {
1080
- "success": True,
1081
- "project_name": project_data["name"],
1082
- "project_id": project_data["id"],
1083
- "imported_apis": imported_apis
1084
- }
 
1
+ """Admin API endpoints for Flare
2
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
3
+ Provides authentication, project, version, and API management endpoints.
4
  """
5
+
 
 
 
 
 
6
  import os
7
+ import sys
8
  import hashlib
9
+ import json
10
+ import jwt
11
+ from datetime import datetime, timedelta, timezone
12
+ from typing import Optional, List, Dict, Any
 
13
  from pathlib import Path
14
+ import threading
15
+ import time
16
+ import bcrypt
17
+
18
+ from fastapi import APIRouter, HTTPException, Depends, Body, Query
19
  from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
20
  from pydantic import BaseModel, Field
 
 
 
 
 
 
 
 
21
 
22
+ from utils import log
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
+ # ===================== JWT Config =====================
25
  def get_jwt_config():
26
+ """Get JWT configuration based on environment"""
27
+ work_mode = os.getenv("WORK_MODE", "on-premise")
28
 
29
+ if work_mode == "hfcloud":
30
+ # Cloud mode - use secrets from environment
31
  jwt_secret = os.getenv("JWT_SECRET")
32
  if not jwt_secret:
33
+ log("⚠️ WARNING: JWT_SECRET not found in environment, using fallback")
34
  jwt_secret = "flare-admin-secret-key-change-in-production" # Fallback
35
  else:
36
  # On-premise mode - use .env file
 
69
  class ProjectCreate(BaseModel):
70
  name: str
71
  caption: Optional[str] = ""
72
+ icon: Optional[str] = "folder"
73
+ description: Optional[str] = ""
74
 
75
  class ProjectUpdate(BaseModel):
76
  caption: str
 
146
  raise HTTPException(status_code=401, detail="Invalid token")
147
 
148
  def hash_password(password: str, salt: str = None) -> tuple[str, str]:
149
+ """Hash password with bcrypt.
150
+ Returns (hashed_password, salt)"""
151
  if salt is None:
152
+ salt = bcrypt.gensalt().decode('utf-8')
153
+
154
+ # Ensure salt is bytes
155
+ salt_bytes = salt.encode('utf-8') if isinstance(salt, str) else salt
 
 
156
 
157
  # Hash the password
158
+ hashed = bcrypt.hashpw(password.encode('utf-8'), salt_bytes)
159
 
160
+ return hashed.decode('utf-8'), salt
161
 
162
+ def verify_password(password: str, hashed: str, salt: str = None) -> bool:
163
+ """Verify password against hash"""
164
+ try:
165
+ # For bcrypt hashes (they contain salt)
166
+ if hashed.startswith('$2b$') or hashed.startswith('$2a$'):
167
+ return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8'))
168
+
169
+ # For legacy SHA256 hashes
170
+ return hashlib.sha256(password.encode()).hexdigest() == hashed
171
+ except Exception as e:
172
+ log(f"Password verification error: {e}")
173
+ return False
174
+
175
+ def load_config():
176
  """Load service_config.jsonc"""
177
  config_path = Path("service_config.jsonc")
178
+ if not config_path.exists():
179
+ return {"config": {}, "projects": [], "apis": []}
180
+
181
  with open(config_path, 'r', encoding='utf-8') as f:
182
+ content = f.read()
183
+ # Remove comments for JSON parsing
184
+ lines = []
185
+ for line in content.split('\n'):
186
+ stripped = line.strip()
187
+ if not stripped.startswith('//'):
188
+ lines.append(line)
189
+ clean_content = '\n'.join(lines)
190
+ return json.loads(clean_content)
191
+
192
+ def save_config(config: dict):
193
+ """Save config back to service_config.jsonc"""
194
+ with open("service_config.jsonc", 'w', encoding='utf-8') as f:
195
+ json.dump(config, f, indent=2, ensure_ascii=False)
196
+
197
+ def get_timestamp():
198
+ """Get current timestamp in ISO format with milliseconds"""
199
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
200
+
201
+ def add_activity_log(config: dict, username: str, action: str,
202
+ entity_type: str, entity_id: Any, entity_name: str,
203
+ details: str = ""):
204
+ """Add activity log entry"""
205
  if "activity_log" not in config:
206
  config["activity_log"] = []
207
+
208
+ # Get next ID
209
+ log_id = max([log.get("id", 0) for log in config["activity_log"]], default=0) + 1
210
+
211
+ config["activity_log"].append({
212
+ "id": log_id,
213
  "timestamp": get_timestamp(),
214
+ "user": username,
215
  "action": action,
216
  "entity_type": entity_type,
217
  "entity_id": entity_id,
218
  "entity_name": entity_name,
219
  "details": details
220
+ })
221
+
222
+ # Keep only last 1000 entries
223
+ if len(config["activity_log"]) > 1000:
224
+ config["activity_log"] = config["activity_log"][-1000:]
 
 
225
 
226
  # ===================== Auth Endpoints =====================
227
  @router.post("/login", response_model=LoginResponse)
228
  async def login(request: LoginRequest):
229
+ """Authenticate user and return JWT token"""
230
  config = load_config()
 
 
 
231
  users = config.get("config", {}).get("users", [])
232
+
233
+ # Find user
234
  user = next((u for u in users if u["username"] == request.username), None)
 
235
  if not user:
236
  raise HTTPException(status_code=401, detail="Invalid credentials")
237
+
238
+ # Verify password
239
+ if not verify_password(request.password, user["password_hash"], user.get("salt")):
240
+ raise HTTPException(status_code=401, detail="Invalid credentials")
241
+
242
+ # Generate JWT token
243
+ jwt_config = get_jwt_config()
244
+
 
 
 
 
 
245
  payload = {
246
  "sub": request.username,
247
+ "exp": datetime.now(timezone.utc) + timedelta(hours=jwt_config["expiration_hours"])
248
  }
249
+
250
  token = jwt.encode(payload, jwt_config["secret"], algorithm=jwt_config["algorithm"])
251
+
252
  log(f"✅ User '{request.username}' logged in")
253
  return LoginResponse(token=token, username=request.username)
254
+
255
  @router.post("/change-password")
256
  async def change_password(
257
  request: ChangePasswordRequest,
 
259
  ):
260
  """Change user password"""
261
  config = load_config()
 
 
262
  users = config.get("config", {}).get("users", [])
 
263
 
264
+ # Find user
265
+ user = next((u for u in users if u["username"] == username), None)
266
+ if not user:
267
  raise HTTPException(status_code=404, detail="User not found")
268
 
 
 
269
  # Verify current password
270
+ if not verify_password(request.current_password, user["password_hash"], user.get("salt")):
271
  raise HTTPException(status_code=401, detail="Current password is incorrect")
272
 
273
+ # Hash new password
274
  new_hash, new_salt = hash_password(request.new_password)
275
+ user["password_hash"] = new_hash
276
+ user["salt"] = new_salt
 
 
277
 
278
  # Save config
279
  save_config(config)
280
 
 
 
 
 
281
  log(f"✅ Password changed for user '{username}'")
282
+ return {"success": True}
283
+
284
  # ===================== Environment Endpoints =====================
285
  @router.get("/environment")
286
  async def get_environment(username: str = Depends(verify_token)):
287
  """Get environment configuration"""
288
  config = load_config()
289
  env_config = config.get("config", {})
290
+
291
  return {
292
+ "work_mode": env_config.get("work_mode", "on-premise"),
293
  "cloud_token": env_config.get("cloud_token", ""),
294
+ "spark_endpoint": env_config.get("spark_endpoint", "http://localhost:7861")
295
  }
296
 
297
  @router.put("/environment")
298
+ async def update_environment(
299
+ update: EnvironmentUpdate,
300
+ username: str = Depends(verify_token)
301
+ ):
302
  """Update environment configuration"""
303
  config = load_config()
304
+
305
  # Update config
306
+ config["config"]["work_mode"] = update.work_mode
307
+ config["config"]["cloud_token"] = update.cloud_token or ""
308
+ config["config"]["spark_endpoint"] = update.spark_endpoint
309
  config["config"]["last_update_date"] = get_timestamp()
310
  config["config"]["last_update_user"] = username
311
+
 
 
 
312
  # Add activity log
313
+ add_activity_log(config, username, "UPDATE_ENVIRONMENT", "config", "environment",
314
+ "environment", f"Changed to {update.work_mode}")
315
+
316
+ # Save
317
  save_config(config)
318
+
319
+ log(f"✅ Environment updated to {update.work_mode} by {username}")
320
  return {"success": True}
321
 
322
  # ===================== Project Endpoints =====================
323
+ @router.get("/projects/names")
324
+ def list_enabled_projects():
325
+ """Get list of enabled project names for chat"""
326
+ cfg = load_config()
327
+ projects = cfg.get("projects", [])
328
+ return [p["name"] for p in projects if p.get("enabled", False) and not p.get("deleted", False)]
329
+
330
  @router.get("/projects")
331
  async def list_projects(
332
  include_deleted: bool = False,
 
335
  """List all projects"""
336
  config = load_config()
337
  projects = config.get("projects", [])
338
+
339
  # Filter deleted if needed
340
  if not include_deleted:
341
  projects = [p for p in projects if not p.get("deleted", False)]
342
+
343
  return projects
344
 
 
 
 
 
 
345
  @router.get("/projects/{project_id}")
346
  async def get_project(
347
  project_id: int,
 
351
  try:
352
  config = load_config()
353
  projects = config.get("projects", [])
354
+
355
  project = next((p for p in projects if p.get("id") == project_id), None)
356
  if not project or project.get("deleted", False):
357
  raise HTTPException(status_code=404, detail="Project not found")
358
+
359
  return project
360
+
361
  except HTTPException:
362
  raise
363
  except Exception as e:
 
371
  username: str = Depends(verify_token)
372
  ):
373
  cfg = load_config()
374
+
375
+ # 1️⃣ Yeni proje ID'si
376
  project_id = cfg["config"].get("project_id_counter", 0) + 1
377
  cfg["config"]["project_id_counter"] = project_id
378
+
379
  # 2️⃣ Proje gövdesi
380
  new_project = {
381
  "id": project_id,
 
389
  "created_by": username,
390
  "last_update_date": datetime.utcnow().isoformat(),
391
  "last_update_user": username,
392
+
393
  # *** Versiyon sayaçları ***
394
  "version_id_counter": 1,
395
+
396
  # *** İlk versiyon (no = 1) ***
397
  "versions": [{
398
  "id": 1,
 
424
  "published_by": None
425
  }]
426
  }
427
+
428
  cfg.setdefault("projects", []).append(new_project)
429
  save_config(cfg)
430
+
431
  add_activity_log(cfg, username, "CREATE_PROJECT",
432
  "project", project_id, new_project["name"])
433
  save_config(cfg)
434
+
435
  return new_project # 201 CREATED
436
 
437
  @router.put("/projects/{project_id}")
 
442
  ):
443
  """Update project"""
444
  config = load_config()
445
+
446
  # Find project
447
  project = next((p for p in config.get("projects", []) if p["id"] == project_id), None)
448
  if not project:
449
  raise HTTPException(status_code=404, detail="Project not found")
450
+
451
  # Check race condition
452
  if project.get("last_update_date") != update.last_update_date:
453
  raise HTTPException(status_code=409, detail="Project was modified by another user")
454
+
455
  # Update
456
  project["caption"] = update.caption
457
  project["last_update_date"] = get_timestamp()
458
  project["last_update_user"] = username
459
+
460
  # Add activity log
461
  add_activity_log(config, username, "UPDATE_PROJECT", "project", project_id, project["name"])
462
+
463
  # Save
464
  save_config(config)
465
+
466
  log(f"✅ Project '{project['name']}' updated by {username}")
467
  return project
468
 
 
470
  async def delete_project(project_id: int, username: str = Depends(verify_token)):
471
  """Delete project (soft delete)"""
472
  config = load_config()
473
+
474
  # Find project
475
  project = next((p for p in config.get("projects", []) if p["id"] == project_id), None)
476
  if not project:
477
  raise HTTPException(status_code=404, detail="Project not found")
478
+
479
  # Soft delete
480
  project["deleted"] = True
481
  project["last_update_date"] = get_timestamp()
482
  project["last_update_user"] = username
483
+
484
  # Add activity log
485
  add_activity_log(config, username, "DELETE_PROJECT", "project", project_id, project["name"])
486
+
487
  # Save
488
  save_config(config)
489
+
490
  log(f"✅ Project '{project['name']}' deleted by {username}")
491
  return {"success": True}
492
 
493
  @router.patch("/projects/{project_id}/toggle")
494
  async def toggle_project(project_id: int, username: str = Depends(verify_token)):
495
+ """Toggle project enabled status"""
496
  config = load_config()
497
+
498
  # Find project
499
  project = next((p for p in config.get("projects", []) if p["id"] == project_id), None)
500
  if not project:
501
  raise HTTPException(status_code=404, detail="Project not found")
502
+
503
  # Toggle
504
+ project["enabled"] = not project.get("enabled", False)
505
  project["last_update_date"] = get_timestamp()
506
  project["last_update_user"] = username
507
+
508
  # Add activity log
509
  action = "ENABLE_PROJECT" if project["enabled"] else "DISABLE_PROJECT"
510
  add_activity_log(config, username, action, "project", project_id, project["name"])
511
+
512
  # Save
513
  save_config(config)
514
+
515
  log(f"✅ Project '{project['name']}' {'enabled' if project['enabled'] else 'disabled'} by {username}")
516
  return {"enabled": project["enabled"]}
517
 
518
  # ===================== Version Endpoints =====================
519
+ @router.get("/projects/{project_id}/versions")
520
+ async def list_versions(
521
+ project_id: int,
522
+ include_deleted: bool = False,
523
+ username: str = Depends(verify_token)
524
+ ):
525
+ """List project versions"""
526
+ config = load_config()
527
+
528
+ # Find project
529
+ project = next((p for p in config.get("projects", []) if p["id"] == project_id), None)
530
+ if not project:
531
+ raise HTTPException(status_code=404, detail="Project not found")
532
+
533
+ versions = project.get("versions", [])
534
+
535
+ # Filter deleted if needed
536
+ if not include_deleted:
537
+ versions = [v for v in versions if not v.get("deleted", False)]
538
+
539
+ return versions
540
+
541
  @router.post("/projects/{project_id}/versions")
542
  async def create_version(
543
  project_id: int,
544
+ version_data: VersionCreate,
545
  username: str = Depends(verify_token)
546
  ):
547
+ """Create new version"""
548
  config = load_config()
549
+
550
  # Find project
551
  project = next((p for p in config.get("projects", []) if p["id"] == project_id), None)
552
  if not project:
553
  raise HTTPException(status_code=404, detail="Project not found")
554
+
555
+ # Get next version ID
556
+ version_id = project.get("version_id_counter", 0) + 1
557
+ project["version_id_counter"] = version_id
558
+
559
+ # Get next version number
560
+ existing_versions = [v for v in project.get("versions", []) if not v.get("deleted", False)]
561
+ version_no = max([v.get("no", 0) for v in existing_versions], default=0) + 1
562
+
563
+ # Create base version
564
+ new_version = {
565
+ "id": version_id,
566
+ "no": version_no,
567
+ "caption": version_data.caption,
568
+ "description": f"Version {version_no}",
569
+ "published": False,
570
+ "deleted": False,
571
+ "created_date": get_timestamp(),
572
+ "created_by": username,
573
+ "last_update_date": get_timestamp(),
574
+ "last_update_user": username,
575
+ "publish_date": None,
576
+ "published_by": None
577
+ }
578
+
579
+ # Copy from source version if specified
580
+ if version_data.source_version_id:
581
+ source_version = next(
582
+ (v for v in project.get("versions", []) if v["id"] == version_data.source_version_id),
583
+ None
584
+ )
585
+ if source_version:
586
+ # Copy configuration from source
587
+ new_version.update({
588
+ "general_prompt": source_version.get("general_prompt", ""),
589
+ "default_api": source_version.get("default_api", ""),
590
+ "llm": source_version.get("llm", {}).copy(),
591
+ "intents": [intent.copy() for intent in source_version.get("intents", [])],
592
+ "parameters": [param.copy() for param in source_version.get("parameters", [])]
593
+ })
594
+ else:
595
+ # Empty template
596
+ new_version.update({
597
+ "general_prompt": "",
598
+ "default_api": "",
599
+ "llm": {
600
+ "repo_id": "",
601
+ "generation_config": {
602
+ "max_new_tokens": 512,
603
+ "temperature": 0.7,
604
+ "top_p": 0.95,
605
+ "top_k": 50,
606
+ "repetition_penalty": 1.1
607
+ },
608
+ "use_fine_tune": False,
609
+ "fine_tune_zip": ""
610
+ },
611
+ "intents": [],
612
+ "parameters": []
613
+ })
614
+
615
+ # Add to project
616
+ if "versions" not in project:
617
+ project["versions"] = []
618
  project["versions"].append(new_version)
619
+
620
+ # Update project timestamp
621
  project["last_update_date"] = get_timestamp()
622
  project["last_update_user"] = username
623
+
624
  # Add activity log
625
+ add_activity_log(config, username, "CREATE_VERSION", "version", version_id,
626
+ f"{project['name']} v{version_no}")
627
+
628
  # Save
629
  save_config(config)
630
+
631
+ log(f"✅ Version {version_no} created for project '{project['name']}' by {username}")
632
  return new_version
633
 
634
  @router.put("/projects/{project_id}/versions/{version_id}")
 
636
  project_id: int,
637
  version_id: int,
638
  update: VersionUpdate,
 
639
  username: str = Depends(verify_token)
640
  ):
641
  """Update version"""
642
  config = load_config()
643
+
644
  # Find project and version
645
  project = next((p for p in config.get("projects", []) if p["id"] == project_id), None)
646
  if not project:
647
  raise HTTPException(status_code=404, detail="Project not found")
648
+
649
  version = next((v for v in project.get("versions", []) if v["id"] == version_id), None)
650
  if not version:
651
  raise HTTPException(status_code=404, detail="Version not found")
652
+
653
+ # Check race condition
654
+ if version.get("last_update_date") != update.last_update_date:
655
+ raise HTTPException(status_code=409, detail="Version was modified by another user")
656
+
657
+ # Cannot update published version
658
  if version.get("published", False):
659
+ raise HTTPException(status_code=400, detail="Cannot modify published version")
660
+
661
+ # Update version
 
 
 
 
 
 
 
662
  version["caption"] = update.caption
663
  version["general_prompt"] = update.general_prompt
664
  version["llm"] = update.llm
665
  version["intents"] = [intent.dict() for intent in update.intents]
666
  version["last_update_date"] = get_timestamp()
667
  version["last_update_user"] = username
668
+
669
+ # Update project timestamp
670
  project["last_update_date"] = get_timestamp()
671
  project["last_update_user"] = username
672
+
673
  # Add activity log
674
  add_activity_log(config, username, "UPDATE_VERSION", "version", version_id,
675
+ f"{project['name']} v{version['no']}")
676
+
677
  # Save
678
  save_config(config)
679
+
680
+ log(f"✅ Version {version['no']} updated for project '{project['name']}' by {username}")
681
  return version
682
 
683
  @router.post("/projects/{project_id}/versions/{version_id}/publish")
 
688
  ):
689
  """Publish version"""
690
  config = load_config()
691
+
692
  # Find project and version
693
  project = next((p for p in config.get("projects", []) if p["id"] == project_id), None)
694
  if not project:
695
  raise HTTPException(status_code=404, detail="Project not found")
696
+
697
  version = next((v for v in project.get("versions", []) if v["id"] == version_id), None)
698
  if not version:
699
  raise HTTPException(status_code=404, detail="Version not found")
700
+
701
  # Unpublish all other versions
702
  for v in project.get("versions", []):
703
  if v["id"] != version_id:
704
  v["published"] = False
705
+
706
  # Publish this version
707
  version["published"] = True
708
  version["publish_date"] = get_timestamp()
709
  version["published_by"] = username
710
  version["last_update_date"] = get_timestamp()
711
  version["last_update_user"] = username
712
+
713
+ # Update project timestamp
714
  project["last_update_date"] = get_timestamp()
715
  project["last_update_user"] = username
716
+
717
  # Add activity log
718
  add_activity_log(config, username, "PUBLISH_VERSION", "version", version_id,
719
+ f"{project['name']} v{version['no']}")
720
+
721
  # Save
722
  save_config(config)
723
+
 
 
724
  log(f"✅ Version {version_id} published for project '{project['name']}' by {username}")
725
  return {"success": True}
726
 
 
732
  ):
733
  """Delete version (soft delete)"""
734
  config = load_config()
735
+
736
  # Find project and version
737
  project = next((p for p in config.get("projects", []) if p["id"] == project_id), None)
738
  if not project:
739
  raise HTTPException(status_code=404, detail="Project not found")
740
+
741
  version = next((v for v in project.get("versions", []) if v["id"] == version_id), None)
742
  if not version:
743
  raise HTTPException(status_code=404, detail="Version not found")
744
+
745
  # Cannot delete published version
746
  if version.get("published", False):
747
  raise HTTPException(status_code=400, detail="Cannot delete published version")
748
+
749
  # Soft delete
750
  version["deleted"] = True
751
  version["last_update_date"] = get_timestamp()
752
  version["last_update_user"] = username
753
+
754
  project["last_update_date"] = get_timestamp()
755
  project["last_update_user"] = username
756
+
757
  # Add activity log
758
  add_activity_log(config, username, "DELETE_VERSION", "version", version_id,
759
  f"{project['name']} v{version_id}")
760
+
761
  # Save
762
  save_config(config)
763
+
764
  log(f"✅ Version {version_id} deleted for project '{project['name']}' by {username}")
765
  return {"success": True}
766
 
 
773
  """List all APIs"""
774
  config = load_config()
775
  apis = config.get("apis", [])
776
+
777
  # Filter deleted if needed
778
  if not include_deleted:
779
  apis = [a for a in apis if not a.get("deleted", False)]
780
+
781
  return apis
782
 
783
  @router.post("/apis")
784
  async def create_api(api: APICreate, username: str = Depends(verify_token)):
785
  """Create new API"""
786
  config = load_config()
787
+
788
  # Check duplicate name
789
  existing = [a for a in config.get("apis", []) if a["name"] == api.name]
790
  if existing:
791
  raise HTTPException(status_code=400, detail="API name already exists")
792
+
793
  # Create API
794
  new_api = api.dict()
795
  new_api["deleted"] = False
 
797
  new_api["created_by"] = username
798
  new_api["last_update_date"] = get_timestamp()
799
  new_api["last_update_user"] = username
800
+
801
  if "apis" not in config:
802
  config["apis"] = []
803
  config["apis"].append(new_api)
804
+
805
  # Add activity log
806
  add_activity_log(config, username, "CREATE_API", "api", api.name, api.name)
807
+
808
  # Save
809
  save_config(config)
810
+
811
  log(f"✅ API '{api.name}' created by {username}")
812
  return new_api
813
 
 
819
  ):
820
  """Update API"""
821
  config = load_config()
822
+
823
  # Find API
824
  api = next((a for a in config.get("apis", []) if a["name"] == api_name), None)
825
  if not api:
826
  raise HTTPException(status_code=404, detail="API not found")
827
+
828
  # Check race condition
829
  if api.get("last_update_date") != update.last_update_date:
830
  raise HTTPException(status_code=409, detail="API was modified by another user")
831
+
832
  # Check if API is in use
833
  for project in config.get("projects", []):
834
  for version in project.get("versions", []):
 
836
  if intent.get("action") == api_name and version.get("published", False):
837
  raise HTTPException(status_code=400,
838
  detail=f"API is used in published version of project '{project['name']}'")
839
+
840
  # Update
841
  update_dict = update.dict()
842
  del update_dict["last_update_date"]
843
  api.update(update_dict)
844
  api["last_update_date"] = get_timestamp()
845
  api["last_update_user"] = username
846
+
847
  # Add activity log
848
  add_activity_log(config, username, "UPDATE_API", "api", api_name, api_name)
849
+
850
  # Save
851
  save_config(config)
852
+
853
  log(f"✅ API '{api_name}' updated by {username}")
854
  return api
855
 
 
857
  async def delete_api(api_name: str, username: str = Depends(verify_token)):
858
  """Delete API (soft delete)"""
859
  config = load_config()
860
+
861
  # Find API
862
  api = next((a for a in config.get("apis", []) if a["name"] == api_name), None)
863
  if not api:
864
  raise HTTPException(status_code=404, detail="API not found")
865
+
866
  # Check if API is in use
867
  for project in config.get("projects", []):
868
  for version in project.get("versions", []):
 
870
  if intent.get("action") == api_name:
871
  raise HTTPException(status_code=400,
872
  detail=f"API is used in project '{project['name']}'")
873
+
874
  # Soft delete
875
  api["deleted"] = True
876
  api["last_update_date"] = get_timestamp()
877
  api["last_update_user"] = username
878
+
879
  # Add activity log
880
  add_activity_log(config, username, "DELETE_API", "api", api_name, api_name)
881
+
882
  # Save
883
  save_config(config)
884
+
885
  log(f"✅ API '{api_name}' deleted by {username}")
886
  return {"success": True}
887
 
 
890
  async def test_api(api: APICreate, username: str = Depends(verify_token)):
891
  """Test API endpoint"""
892
  import requests
893
+
894
  try:
895
  # Prepare request
896
  headers = api.headers.copy()
 
898
  # Add sample auth token for testing
899
  if api.auth and api.auth.get("enabled"):
900
  headers["Authorization"] = "Bearer test_token_12345"
901
+
902
  # Make request
903
  response = requests.request(
904
  method=api.method,
 
909
  timeout=api.timeout_seconds,
910
  proxies={"http": api.proxy, "https": api.proxy} if api.proxy else None
911
  )
912
+
913
  return {
914
  "success": True,
915
  "status_code": response.status_code,
916
+ "response": response.text[:500], # First 500 chars
917
+ "headers": dict(response.headers)
 
 
 
 
 
 
 
 
918
  }
919
  except Exception as e:
920
  return {
921
  "success": False,
922
+ "error": str(e)
 
923
  }
924
 
925
+ @router.post("/test/run-all")
926
+ async def run_all_tests(
927
+ request: TestRequest,
928
+ username: str = Depends(verify_token)
929
+ ):
930
+ """Run all tests"""
931
+ # TODO: Implement test runner
932
+ return {
933
+ "status": "completed",
934
+ "total": 10,
935
+ "passed": 8,
936
+ "failed": 2,
937
+ "details": []
938
+ }
939
+
940
+ # ===================== Import/Export Endpoints =====================
941
+ @router.post("/projects/import")
942
+ async def import_project(
943
+ project_data: dict = Body(...),
944
  username: str = Depends(verify_token)
945
  ):
946
+ """Import project from JSON"""
947
  config = load_config()
 
948
 
949
+ # Validate structure
950
+ if "name" not in project_data:
951
+ raise HTTPException(status_code=400, detail="Invalid project data")
952
 
953
+ # Check duplicate name
954
+ existing = [p for p in config.get("projects", [])
955
+ if p["name"] == project_data["name"] and not p.get("deleted", False)]
956
+ if existing:
957
+ raise HTTPException(status_code=400, detail="Project name already exists")
958
 
959
+ # Get new project ID
960
+ project_id = config["config"].get("project_id_counter", 0) + 1
961
+ config["config"]["project_id_counter"] = project_id
962
 
963
+ # Create new project
964
+ new_project = {
965
+ "id": project_id,
966
+ "name": project_data["name"],
967
+ "caption": project_data.get("caption", ""),
968
+ "icon": project_data.get("icon", "folder"),
969
+ "description": project_data.get("description", ""),
970
+ "enabled": False,
971
+ "deleted": False,
972
+ "created_date": get_timestamp(),
973
+ "created_by": username,
974
+ "last_update_date": get_timestamp(),
975
+ "last_update_user": username,
976
+ "version_id_counter": 1,
977
+ "versions": []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
978
  }
979
+
980
+ # Import versions
981
+ for idx, version_data in enumerate(project_data.get("versions", [])):
982
+ new_version = {
983
+ "id": idx + 1,
984
+ "no": idx + 1,
985
+ "caption": version_data.get("caption", f"Version {idx + 1}"),
986
+ "description": version_data.get("description", ""),
987
+ "published": False,
988
+ "deleted": False,
989
+ "created_date": get_timestamp(),
990
+ "created_by": username,
991
+ "last_update_date": get_timestamp(),
992
+ "last_update_user": username,
993
+ "publish_date": None,
994
+ "published_by": None,
995
+ "general_prompt": version_data.get("general_prompt", ""),
996
+ "default_api": version_data.get("default_api", ""),
997
+ "llm": version_data.get("llm", {}),
998
+ "intents": version_data.get("intents", []),
999
+ "parameters": version_data.get("parameters", [])
1000
  }
1001
+ new_project["versions"].append(new_version)
1002
+ new_project["version_id_counter"] = idx + 1
1003
+
1004
+ # Add to config
1005
+ if "projects" not in config:
1006
+ config["projects"] = []
1007
+ config["projects"].append(new_project)
1008
+
1009
+ # Add activity log
1010
+ add_activity_log(config, username, "IMPORT_PROJECT", "project", project_id, new_project["name"])
1011
+
1012
+ # Save
1013
+ save_config(config)
1014
+
1015
+ log(f"✅ Project '{new_project['name']}' imported by {username}")
1016
+ return new_project
1017
 
 
1018
  @router.get("/projects/{project_id}/export")
1019
+ async def export_project(
1020
+ project_id: int,
1021
+ username: str = Depends(verify_token)
1022
+ ):
1023
  """Export project as JSON"""
1024
  config = load_config()
1025
+
1026
  # Find project
1027
  project = next((p for p in config.get("projects", []) if p["id"] == project_id), None)
1028
  if not project:
1029
  raise HTTPException(status_code=404, detail="Project not found")
1030
+
1031
+ # Create export data
 
 
 
 
 
 
 
 
1032
  export_data = {
1033
+ "name": project["name"],
1034
+ "caption": project.get("caption", ""),
1035
+ "icon": project.get("icon", "folder"),
1036
+ "description": project.get("description", ""),
1037
+ "versions": []
1038
  }
1039
+
1040
+ # Export versions
1041
+ for version in project.get("versions", []):
1042
+ if not version.get("deleted", False):
1043
+ export_version = {
1044
+ "caption": version.get("caption", ""),
1045
+ "description": version.get("description", ""),
1046
+ "general_prompt": version.get("general_prompt", ""),
1047
+ "default_api": version.get("default_api", ""),
1048
+ "llm": version.get("llm", {}),
1049
+ "intents": version.get("intents", []),
1050
+ "parameters": version.get("parameters", [])
1051
+ }
1052
+ export_data["versions"].append(export_version)
1053
+
1054
+ # Add activity log
1055
+ add_activity_log(config, username, "EXPORT_PROJECT", "project", project_id, project["name"])
1056
+ save_config(config)
1057
+
1058
+ log(f"✅ Project '{project['name']}' exported by {username}")
1059
  return export_data
1060
 
1061
+ # ===================== Activity Log Endpoints =====================
1062
+ @router.get("/activity-log")
1063
+ async def get_activity_log(
1064
+ limit: int = Query(100, ge=1, le=1000),
1065
+ username: str = Depends(verify_token)
1066
+ ):
1067
+ """Get activity log"""
1068
  config = load_config()
1069
+ logs = config.get("activity_log", [])
1070
+
1071
+ # Return latest entries
1072
+ return logs[-limit:]
1073
 
1074
+ # ===================== Cleanup Task =====================
1075
+ def cleanup_old_logs():
1076
+ """Cleanup old activity logs (runs in background)"""
1077
+ while True:
1078
+ try:
1079
+ config = load_config()
1080
+ if "activity_log" in config and len(config["activity_log"]) > 5000:
1081
+ # Keep only last 1000 entries
1082
+ config["activity_log"] = config["activity_log"][-1000:]
1083
+ save_config(config)
1084
+ log("🧹 Cleaned up old activity logs")
1085
+ except Exception as e:
1086
+ log(f"Error in cleanup task: {e}")
1087
+
1088
+ # Run every hour
1089
+ time.sleep(3600)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1090
 
1091
+ def start_cleanup_task():
1092
+ """Start cleanup task in background"""
1093
+ cleanup_thread = threading.Thread(target=cleanup_old_logs, daemon=True)
1094
+ cleanup_thread.start()
1095
+ log("🧹 Started activity log cleanup task")