ciyidogan commited on
Commit
90e33ec
·
verified ·
1 Parent(s): 5b0c107

Update admin_routes.py

Browse files
Files changed (1) hide show
  1. admin_routes.py +884 -852
admin_routes.py CHANGED
@@ -1,853 +1,885 @@
1
- """
2
- Flare Admin API Routes
3
- ~~~~~~~~~~~~~~~~~~~~~
4
- Admin UI için gerekli tüm endpoint'ler
5
- """
6
-
7
- import json
8
- import hashlib
9
- import secrets
10
- import commentjson
11
- from datetime import datetime, timedelta
12
- from typing import Dict, List, Optional, Any
13
- from pathlib import Path
14
-
15
- from fastapi import APIRouter, HTTPException, Depends, Header
16
- from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
17
- from pydantic import BaseModel, Field
18
- import jwt
19
-
20
- from utils import log
21
- from config_provider import ConfigProvider
22
-
23
- # ===================== Constants & Config =====================
24
- JWT_SECRET = "flare-admin-secret-key-change-in-production"
25
- JWT_ALGORITHM = "HS256"
26
- JWT_EXPIRATION_HOURS = 24
27
-
28
- router = APIRouter(prefix="/api")
29
- security = HTTPBearer()
30
-
31
- # ===================== Models =====================
32
- class LoginRequest(BaseModel):
33
- username: str
34
- password: str
35
-
36
- class LoginResponse(BaseModel):
37
- token: str
38
- username: str
39
-
40
- class EnvironmentUpdate(BaseModel):
41
- work_mode: str
42
- cloud_token: Optional[str] = None
43
- spark_endpoint: str
44
-
45
- class ProjectCreate(BaseModel):
46
- name: str
47
- caption: Optional[str] = ""
48
-
49
- class ProjectUpdate(BaseModel):
50
- caption: str
51
- last_update_date: str
52
-
53
- class VersionCreate(BaseModel):
54
- source_version_id: int
55
- caption: str
56
-
57
- class IntentModel(BaseModel):
58
- name: str
59
- caption: Optional[str] = ""
60
- locale: str = "tr-TR"
61
- detection_prompt: str
62
- examples: List[str] = []
63
- parameters: List[Dict[str, Any]] = []
64
- action: str
65
- fallback_timeout_prompt: Optional[str] = None
66
- fallback_error_prompt: Optional[str] = None
67
-
68
- class VersionUpdate(BaseModel):
69
- caption: str
70
- general_prompt: str
71
- llm: Dict[str, Any]
72
- intents: List[IntentModel]
73
- last_update_date: str
74
-
75
- class APICreate(BaseModel):
76
- name: str
77
- url: str
78
- method: str = "POST"
79
- headers: Dict[str, str] = {}
80
- body_template: Dict[str, Any] = {}
81
- timeout_seconds: int = 10
82
- retry: Dict[str, Any] = Field(default_factory=lambda: {"retry_count": 3, "backoff_seconds": 2, "strategy": "static"})
83
- proxy: Optional[str] = None
84
- auth: Optional[Dict[str, Any]] = None
85
- response_prompt: Optional[str] = None
86
-
87
- class APIUpdate(BaseModel):
88
- url: str
89
- method: str
90
- headers: Dict[str, str]
91
- body_template: Dict[str, Any]
92
- timeout_seconds: int
93
- retry: Dict[str, Any]
94
- proxy: Optional[str]
95
- auth: Optional[Dict[str, Any]]
96
- response_prompt: Optional[str]
97
- last_update_date: str
98
-
99
- class TestRequest(BaseModel):
100
- test_type: str # "all", "ui", "backend", "integration", "spark"
101
-
102
- # ===================== Helpers =====================
103
- def hash_password(password: str) -> str:
104
- """Simple SHA256 hash for demo. Use bcrypt in production!"""
105
- return hashlib.sha256(password.encode()).hexdigest()
106
-
107
- def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
108
- """Verify JWT token and return username"""
109
- try:
110
- payload = jwt.decode(credentials.credentials, JWT_SECRET, algorithms=[JWT_ALGORITHM])
111
- username = payload.get("sub")
112
- if username is None:
113
- raise HTTPException(status_code=401, detail="Invalid token")
114
- return username
115
- except jwt.ExpiredSignatureError:
116
- raise HTTPException(status_code=401, detail="Token expired")
117
- except jwt.JWTError:
118
- raise HTTPException(status_code=401, detail="Invalid token")
119
-
120
- def load_config() -> Dict[str, Any]:
121
- """Load service_config.jsonc"""
122
- config_path = Path("service_config.jsonc")
123
- with open(config_path, 'r', encoding='utf-8') as f:
124
- return commentjson.load(f)
125
-
126
- def save_config(config: Dict[str, Any]):
127
- """Save service_config.jsonc with pretty formatting"""
128
- config_path = Path("service_config.jsonc")
129
- with open(config_path, 'w', encoding='utf-8') as f:
130
- # Convert to JSON string with proper formatting
131
- json_str = json.dumps(config, indent=2, ensure_ascii=False)
132
- f.write(json_str)
133
-
134
- def get_timestamp() -> str:
135
- """Get current ISO timestamp"""
136
- return datetime.utcnow().isoformat() + "Z"
137
-
138
- def add_activity_log(config: Dict[str, Any], user: str, action: str, entity_type: str,
139
- entity_id: Any, entity_name: str, details: str = ""):
140
- """Add entry to activity log"""
141
- if "activity_log" not in config:
142
- config["activity_log"] = []
143
-
144
- log_entry = {
145
- "id": len(config["activity_log"]) + 1,
146
- "timestamp": get_timestamp(),
147
- "user": user,
148
- "action": action,
149
- "entity_type": entity_type,
150
- "entity_id": entity_id,
151
- "entity_name": entity_name,
152
- "details": details
153
- }
154
-
155
- config["activity_log"].append(log_entry)
156
-
157
- # Keep only last 100 entries
158
- if len(config["activity_log"]) > 100:
159
- config["activity_log"] = config["activity_log"][-100:]
160
-
161
- # ===================== Auth Endpoints =====================
162
- @router.post("/login", response_model=LoginResponse)
163
- async def login(request: LoginRequest):
164
- """User login"""
165
- config = load_config()
166
-
167
- # Find user
168
- users = config.get("config", {}).get("users", [])
169
- user = next((u for u in users if u["username"] == request.username), None)
170
-
171
- if not user:
172
- raise HTTPException(status_code=401, detail="Invalid credentials")
173
-
174
- # Verify password (simple hash for demo)
175
- if hash_password(request.password) != user["password_hash"]:
176
- raise HTTPException(status_code=401, detail="Invalid credentials")
177
-
178
- # Create JWT token
179
- expire = datetime.utcnow() + timedelta(hours=JWT_EXPIRATION_HOURS)
180
- payload = {
181
- "sub": request.username,
182
- "exp": expire
183
- }
184
- token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
185
-
186
- log(f"✅ User '{request.username}' logged in")
187
- return LoginResponse(token=token, username=request.username)
188
-
189
- # ===================== Environment Endpoints =====================
190
- @router.get("/environment")
191
- async def get_environment(username: str = Depends(verify_token)):
192
- """Get environment configuration"""
193
- config = load_config()
194
- env_config = config.get("config", {})
195
-
196
- return {
197
- "work_mode": env_config.get("work_mode", "hfcloud"),
198
- "cloud_token": env_config.get("cloud_token", ""),
199
- "spark_endpoint": env_config.get("spark_endpoint", "")
200
- }
201
-
202
- @router.put("/environment")
203
- async def update_environment(env: EnvironmentUpdate, username: str = Depends(verify_token)):
204
- """Update environment configuration"""
205
- config = load_config()
206
-
207
- # Update config
208
- config["config"]["work_mode"] = env.work_mode
209
- config["config"]["cloud_token"] = env.cloud_token if env.work_mode != "on-premise" else ""
210
- config["config"]["spark_endpoint"] = env.spark_endpoint
211
- config["config"]["last_update_date"] = get_timestamp()
212
- config["config"]["last_update_user"] = username
213
-
214
- # Save
215
- save_config(config)
216
-
217
- # Add activity log
218
- add_activity_log(config, username, "UPDATE_ENVIRONMENT", "config", 0, "environment",
219
- f"Work mode: {env.work_mode}")
220
- save_config(config)
221
-
222
- log(f"✅ Environment updated by {username}")
223
- return {"success": True}
224
-
225
- # ===================== Project Endpoints =====================
226
- @router.get("/projects")
227
- async def list_projects(
228
- include_deleted: bool = False,
229
- username: str = Depends(verify_token)
230
- ):
231
- """List all projects"""
232
- config = load_config()
233
- projects = config.get("projects", [])
234
-
235
- # Filter deleted if needed
236
- if not include_deleted:
237
- projects = [p for p in projects if not p.get("deleted", False)]
238
-
239
- return projects
240
-
241
- @router.post("/projects")
242
- async def create_project(project: ProjectCreate, username: str = Depends(verify_token)):
243
- """Create new project"""
244
- config = load_config()
245
-
246
- # Check duplicate name
247
- existing = [p for p in config.get("projects", []) if p["name"] == project.name]
248
- if existing:
249
- raise HTTPException(status_code=400, detail="Project name already exists")
250
-
251
- # Get next ID
252
- config["config"]["project_id_counter"] = config["config"].get("project_id_counter", 0) + 1
253
- project_id = config["config"]["project_id_counter"]
254
-
255
- # Create project
256
- new_project = {
257
- "id": project_id,
258
- "name": project.name,
259
- "caption": project.caption,
260
- "enabled": True,
261
- "last_version_number": 0,
262
- "version_id_counter": 0,
263
- "versions": [],
264
- "deleted": False,
265
- "created_date": get_timestamp(),
266
- "created_by": username,
267
- "last_update_date": get_timestamp(),
268
- "last_update_user": username
269
- }
270
-
271
- if "projects" not in config:
272
- config["projects"] = []
273
- config["projects"].append(new_project)
274
-
275
- # Add activity log
276
- add_activity_log(config, username, "CREATE_PROJECT", "project", project_id, project.name)
277
-
278
- # Save
279
- save_config(config)
280
-
281
- log(f"Project '{project.name}' created by {username}")
282
- return new_project
283
-
284
- @router.put("/projects/{project_id}")
285
- async def update_project(
286
- project_id: int,
287
- update: ProjectUpdate,
288
- username: str = Depends(verify_token)
289
- ):
290
- """Update project"""
291
- config = load_config()
292
-
293
- # Find project
294
- project = next((p for p in config.get("projects", []) if p["id"] == project_id), None)
295
- if not project:
296
- raise HTTPException(status_code=404, detail="Project not found")
297
-
298
- # Check race condition
299
- if project.get("last_update_date") != update.last_update_date:
300
- raise HTTPException(status_code=409, detail="Project was modified by another user")
301
-
302
- # Update
303
- project["caption"] = update.caption
304
- project["last_update_date"] = get_timestamp()
305
- project["last_update_user"] = username
306
-
307
- # Add activity log
308
- add_activity_log(config, username, "UPDATE_PROJECT", "project", project_id, project["name"])
309
-
310
- # Save
311
- save_config(config)
312
-
313
- log(f"✅ Project '{project['name']}' updated by {username}")
314
- return project
315
-
316
- @router.delete("/projects/{project_id}")
317
- async def delete_project(project_id: int, username: str = Depends(verify_token)):
318
- """Delete project (soft delete)"""
319
- config = load_config()
320
-
321
- # Find project
322
- project = next((p for p in config.get("projects", []) if p["id"] == project_id), None)
323
- if not project:
324
- raise HTTPException(status_code=404, detail="Project not found")
325
-
326
- # Soft delete
327
- project["deleted"] = True
328
- project["last_update_date"] = get_timestamp()
329
- project["last_update_user"] = username
330
-
331
- # Add activity log
332
- add_activity_log(config, username, "DELETE_PROJECT", "project", project_id, project["name"])
333
-
334
- # Save
335
- save_config(config)
336
-
337
- log(f"✅ Project '{project['name']}' deleted by {username}")
338
- return {"success": True}
339
-
340
- @router.patch("/projects/{project_id}/toggle")
341
- async def toggle_project(project_id: int, username: str = Depends(verify_token)):
342
- """Enable/disable project"""
343
- config = load_config()
344
-
345
- # Find project
346
- project = next((p for p in config.get("projects", []) if p["id"] == project_id), None)
347
- if not project:
348
- raise HTTPException(status_code=404, detail="Project not found")
349
-
350
- # Toggle
351
- project["enabled"] = not project.get("enabled", True)
352
- project["last_update_date"] = get_timestamp()
353
- project["last_update_user"] = username
354
-
355
- # Add activity log
356
- action = "ENABLE_PROJECT" if project["enabled"] else "DISABLE_PROJECT"
357
- add_activity_log(config, username, action, "project", project_id, project["name"])
358
-
359
- # Save
360
- save_config(config)
361
-
362
- log(f"✅ Project '{project['name']}' {'enabled' if project['enabled'] else 'disabled'} by {username}")
363
- return {"enabled": project["enabled"]}
364
-
365
- # ===================== Version Endpoints =====================
366
- @router.post("/projects/{project_id}/versions")
367
- async def create_version(
368
- project_id: int,
369
- version: VersionCreate,
370
- username: str = Depends(verify_token)
371
- ):
372
- """Create new version from existing version"""
373
- config = load_config()
374
-
375
- # Find project
376
- project = next((p for p in config.get("projects", []) if p["id"] == project_id), None)
377
- if not project:
378
- raise HTTPException(status_code=404, detail="Project not found")
379
-
380
- # Check if there's unpublished version
381
- unpublished = [v for v in project.get("versions", []) if not v.get("published", False)]
382
- if unpublished:
383
- raise HTTPException(status_code=400, detail="There is already an unpublished version")
384
-
385
- # Find source version
386
- source = next((v for v in project.get("versions", []) if v["id"] == version.source_version_id), None)
387
- if not source:
388
- raise HTTPException(status_code=404, detail="Source version not found")
389
-
390
- # Create new version
391
- project["version_id_counter"] = project.get("version_id_counter", 0) + 1
392
- new_version = source.copy()
393
- new_version["id"] = project["version_id_counter"]
394
- new_version["caption"] = version.caption
395
- new_version["published"] = False
396
- new_version["created_date"] = get_timestamp()
397
- new_version["created_by"] = username
398
- new_version["last_update_date"] = get_timestamp()
399
- new_version["last_update_user"] = username
400
- new_version["publish_date"] = None
401
- new_version["published_by"] = None
402
-
403
- project["versions"].append(new_version)
404
- project["last_update_date"] = get_timestamp()
405
- project["last_update_user"] = username
406
-
407
- # Add activity log
408
- add_activity_log(config, username, "CREATE_VERSION", "version", new_version["id"],
409
- f"{project['name']} v{new_version['id']}")
410
-
411
- # Save
412
- save_config(config)
413
-
414
- log(f"✅ Version {new_version['id']} created for project '{project['name']}' by {username}")
415
- return new_version
416
-
417
- @router.put("/projects/{project_id}/versions/{version_id}")
418
- async def update_version(
419
- project_id: int,
420
- version_id: int,
421
- update: VersionUpdate,
422
- username: str = Depends(verify_token)
423
- ):
424
- """Update version"""
425
- config = load_config()
426
-
427
- # Find project and version
428
- project = next((p for p in config.get("projects", []) if p["id"] == project_id), None)
429
- if not project:
430
- raise HTTPException(status_code=404, detail="Project not found")
431
-
432
- version = next((v for v in project.get("versions", []) if v["id"] == version_id), None)
433
- if not version:
434
- raise HTTPException(status_code=404, detail="Version not found")
435
-
436
- # Check if published
437
- if version.get("published", False):
438
- raise HTTPException(status_code=400, detail="Cannot update published version")
439
-
440
- # Check race condition
441
- if version.get("last_update_date") != update.last_update_date:
442
- raise HTTPException(status_code=409, detail="Version was modified by another user")
443
-
444
- # Update
445
- version["caption"] = update.caption
446
- version["general_prompt"] = update.general_prompt
447
- version["llm"] = update.llm
448
- version["intents"] = [intent.dict() for intent in update.intents]
449
- version["last_update_date"] = get_timestamp()
450
- version["last_update_user"] = username
451
-
452
- project["last_update_date"] = get_timestamp()
453
- project["last_update_user"] = username
454
-
455
- # Add activity log
456
- add_activity_log(config, username, "UPDATE_VERSION", "version", version_id,
457
- f"{project['name']} v{version_id}")
458
-
459
- # Save
460
- save_config(config)
461
-
462
- log(f" Version {version_id} updated for project '{project['name']}' by {username}")
463
- return version
464
-
465
- @router.post("/projects/{project_id}/versions/{version_id}/publish")
466
- async def publish_version(
467
- project_id: int,
468
- version_id: int,
469
- username: str = Depends(verify_token)
470
- ):
471
- """Publish version"""
472
- config = load_config()
473
-
474
- # Find project and version
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
- version = next((v for v in project.get("versions", []) if v["id"] == version_id), None)
480
- if not version:
481
- raise HTTPException(status_code=404, detail="Version not found")
482
-
483
- # Unpublish all other versions
484
- for v in project.get("versions", []):
485
- if v["id"] != version_id:
486
- v["published"] = False
487
-
488
- # Publish this version
489
- version["published"] = True
490
- version["publish_date"] = get_timestamp()
491
- version["published_by"] = username
492
- version["last_update_date"] = get_timestamp()
493
- version["last_update_user"] = username
494
-
495
- project["last_update_date"] = get_timestamp()
496
- project["last_update_user"] = username
497
-
498
- # Add activity log
499
- add_activity_log(config, username, "PUBLISH_VERSION", "version", version_id,
500
- f"{project['name']} v{version_id}")
501
-
502
- # Save
503
- save_config(config)
504
-
505
- # TODO: Notify Spark about new version
506
-
507
- log(f"✅ Version {version_id} published for project '{project['name']}' by {username}")
508
- return {"success": True}
509
-
510
- @router.delete("/projects/{project_id}/versions/{version_id}")
511
- async def delete_version(
512
- project_id: int,
513
- version_id: int,
514
- username: str = Depends(verify_token)
515
- ):
516
- """Delete version (soft delete)"""
517
- config = load_config()
518
-
519
- # Find project and version
520
- project = next((p for p in config.get("projects", []) if p["id"] == project_id), None)
521
- if not project:
522
- raise HTTPException(status_code=404, detail="Project not found")
523
-
524
- version = next((v for v in project.get("versions", []) if v["id"] == version_id), None)
525
- if not version:
526
- raise HTTPException(status_code=404, detail="Version not found")
527
-
528
- # Cannot delete published version
529
- if version.get("published", False):
530
- raise HTTPException(status_code=400, detail="Cannot delete published version")
531
-
532
- # Soft delete
533
- version["deleted"] = True
534
- version["last_update_date"] = get_timestamp()
535
- version["last_update_user"] = username
536
-
537
- project["last_update_date"] = get_timestamp()
538
- project["last_update_user"] = username
539
-
540
- # Add activity log
541
- add_activity_log(config, username, "DELETE_VERSION", "version", version_id,
542
- f"{project['name']} v{version_id}")
543
-
544
- # Save
545
- save_config(config)
546
-
547
- log(f"✅ Version {version_id} deleted for project '{project['name']}' by {username}")
548
- return {"success": True}
549
-
550
- # ===================== API Endpoints =====================
551
- @router.get("/apis")
552
- async def list_apis(
553
- include_deleted: bool = False,
554
- username: str = Depends(verify_token)
555
- ):
556
- """List all APIs"""
557
- config = load_config()
558
- apis = config.get("apis", [])
559
-
560
- # Filter deleted if needed
561
- if not include_deleted:
562
- apis = [a for a in apis if not a.get("deleted", False)]
563
-
564
- return apis
565
-
566
- @router.post("/apis")
567
- async def create_api(api: APICreate, username: str = Depends(verify_token)):
568
- """Create new API"""
569
- config = load_config()
570
-
571
- # Check duplicate name
572
- existing = [a for a in config.get("apis", []) if a["name"] == api.name]
573
- if existing:
574
- raise HTTPException(status_code=400, detail="API name already exists")
575
-
576
- # Create API
577
- new_api = api.dict()
578
- new_api["deleted"] = False
579
- new_api["created_date"] = get_timestamp()
580
- new_api["created_by"] = username
581
- new_api["last_update_date"] = get_timestamp()
582
- new_api["last_update_user"] = username
583
-
584
- if "apis" not in config:
585
- config["apis"] = []
586
- config["apis"].append(new_api)
587
-
588
- # Add activity log
589
- add_activity_log(config, username, "CREATE_API", "api", api.name, api.name)
590
-
591
- # Save
592
- save_config(config)
593
-
594
- log(f"✅ API '{api.name}' created by {username}")
595
- return new_api
596
-
597
- @router.put("/apis/{api_name}")
598
- async def update_api(
599
- api_name: str,
600
- update: APIUpdate,
601
- username: str = Depends(verify_token)
602
- ):
603
- """Update API"""
604
- config = load_config()
605
-
606
- # Find API
607
- api = next((a for a in config.get("apis", []) if a["name"] == api_name), None)
608
- if not api:
609
- raise HTTPException(status_code=404, detail="API not found")
610
-
611
- # Check race condition
612
- if api.get("last_update_date") != update.last_update_date:
613
- raise HTTPException(status_code=409, detail="API was modified by another user")
614
-
615
- # Check if API is in use
616
- for project in config.get("projects", []):
617
- for version in project.get("versions", []):
618
- for intent in version.get("intents", []):
619
- if intent.get("action") == api_name and version.get("published", False):
620
- raise HTTPException(status_code=400,
621
- detail=f"API is used in published version of project '{project['name']}'")
622
-
623
- # Update
624
- update_dict = update.dict()
625
- del update_dict["last_update_date"]
626
- api.update(update_dict)
627
- api["last_update_date"] = get_timestamp()
628
- api["last_update_user"] = username
629
-
630
- # Add activity log
631
- add_activity_log(config, username, "UPDATE_API", "api", api_name, api_name)
632
-
633
- # Save
634
- save_config(config)
635
-
636
- log(f"✅ API '{api_name}' updated by {username}")
637
- return api
638
-
639
- @router.delete("/apis/{api_name}")
640
- async def delete_api(api_name: str, username: str = Depends(verify_token)):
641
- """Delete API (soft delete)"""
642
- config = load_config()
643
-
644
- # Find API
645
- api = next((a for a in config.get("apis", []) if a["name"] == api_name), None)
646
- if not api:
647
- raise HTTPException(status_code=404, detail="API not found")
648
-
649
- # Check if API is in use
650
- for project in config.get("projects", []):
651
- for version in project.get("versions", []):
652
- for intent in version.get("intents", []):
653
- if intent.get("action") == api_name:
654
- raise HTTPException(status_code=400,
655
- detail=f"API is used in project '{project['name']}'")
656
-
657
- # Soft delete
658
- api["deleted"] = True
659
- api["last_update_date"] = get_timestamp()
660
- api["last_update_user"] = username
661
-
662
- # Add activity log
663
- add_activity_log(config, username, "DELETE_API", "api", api_name, api_name)
664
-
665
- # Save
666
- save_config(config)
667
-
668
- log(f"✅ API '{api_name}' deleted by {username}")
669
- return {"success": True}
670
-
671
- # ===================== Test Endpoints =====================
672
- @router.post("/apis/test")
673
- async def test_api(api: APICreate, username: str = Depends(verify_token)):
674
- """Test API endpoint"""
675
- import requests
676
-
677
- try:
678
- # Prepare request
679
- headers = api.headers.copy()
680
-
681
- # Make request
682
- response = requests.request(
683
- method=api.method,
684
- url=api.url,
685
- headers=headers,
686
- json=api.body_template if api.method in ["POST", "PUT", "PATCH"] else None,
687
- params=api.body_template if api.method == "GET" else None,
688
- timeout=api.timeout_seconds
689
- )
690
-
691
- return {
692
- "success": True,
693
- "status_code": response.status_code,
694
- "response_time_ms": int(response.elapsed.total_seconds() * 1000),
695
- "headers": dict(response.headers),
696
- "body": response.text[:1000] # First 1000 chars
697
- }
698
- except Exception as e:
699
- return {
700
- "success": False,
701
- "error": str(e)
702
- }
703
-
704
- @router.get("/activity-log")
705
- async def get_activity_log(
706
- limit: int = 50,
707
- username: str = Depends(verify_token)
708
- ):
709
- """Get activity log"""
710
- config = load_config()
711
- logs = config.get("activity_log", [])
712
-
713
- # Return last N entries
714
- return logs[-limit:]
715
-
716
- @router.post("/test/run-all")
717
- async def run_all_tests(test: TestRequest, username: str = Depends(verify_token)):
718
- """Run all tests"""
719
- # This is a placeholder - in real implementation, this would run actual tests
720
- log(f"🧪 Running {test.test_type} tests requested by {username}")
721
-
722
- # Simulate test results
723
- results = {
724
- "test_type": test.test_type,
725
- "start_time": get_timestamp(),
726
- "tests": [
727
- {"name": "Login with valid credentials", "status": "PASS", "duration_ms": 120},
728
- {"name": "Create new project", "status": "PASS", "duration_ms": 340},
729
- {"name": "Delete API in use", "status": "PASS", "duration_ms": 45},
730
- {"name": "Race condition detection", "status": "PASS", "duration_ms": 567},
731
- {"name": "Invalid token handling", "status": "PASS", "duration_ms": 23}
732
- ],
733
- "summary": {
734
- "total": 5,
735
- "passed": 5,
736
- "failed": 0,
737
- "duration_ms": 1095
738
- }
739
- }
740
-
741
- return results
742
-
743
- @router.post("/validate/regex")
744
- async def validate_regex(pattern: str, test_value: str, username: str = Depends(verify_token)):
745
- """Validate regex pattern"""
746
- import re
747
-
748
- try:
749
- compiled = re.compile(pattern)
750
- match = compiled.fullmatch(test_value)
751
- return {
752
- "valid": True,
753
- "matches": match is not None
754
- }
755
- except re.error as e:
756
- return {
757
- "valid": False,
758
- "error": str(e)
759
- }
760
-
761
- # ===================== Export/Import =====================
762
- @router.get("/projects/{project_id}/export")
763
- async def export_project(project_id: int, username: str = Depends(verify_token)):
764
- """Export project as JSON"""
765
- config = load_config()
766
-
767
- # Find project
768
- project = next((p for p in config.get("projects", []) if p["id"] == project_id), None)
769
- if not project:
770
- raise HTTPException(status_code=404, detail="Project not found")
771
-
772
- # Collect all related APIs
773
- api_names = set()
774
- for version in project.get("versions", []):
775
- for intent in version.get("intents", []):
776
- api_names.add(intent.get("action"))
777
-
778
- apis = [a for a in config.get("apis", []) if a["name"] in api_names]
779
-
780
- # Create export
781
- export_data = {
782
- "export_date": get_timestamp(),
783
- "exported_by": username,
784
- "project": project,
785
- "apis": apis
786
- }
787
-
788
- log(f"📤 Project '{project['name']}' exported by {username}")
789
- return export_data
790
-
791
- @router.post("/projects/import")
792
- async def import_project(data: Dict[str, Any], username: str = Depends(verify_token)):
793
- """Import project from JSON"""
794
- config = load_config()
795
-
796
- # Extract project and APIs
797
- project_data = data.get("project", {})
798
- apis_data = data.get("apis", [])
799
-
800
- # Check duplicate project name
801
- existing = [p for p in config.get("projects", []) if p["name"] == project_data.get("name")]
802
- if existing:
803
- # Generate new name
804
- project_data["name"] = f"{project_data['name']}_imported_{int(datetime.now().timestamp())}"
805
-
806
- # Generate new IDs
807
- config["config"]["project_id_counter"] = config["config"].get("project_id_counter", 0) + 1
808
- project_data["id"] = config["config"]["project_id_counter"]
809
-
810
- # Reset version IDs
811
- version_counter = 0
812
- for version in project_data.get("versions", []):
813
- version_counter += 1
814
- version["id"] = version_counter
815
- version["published"] = False # Imported versions are unpublished
816
-
817
- project_data["version_id_counter"] = version_counter
818
- project_data["created_date"] = get_timestamp()
819
- project_data["created_by"] = username
820
- project_data["last_update_date"] = get_timestamp()
821
- project_data["last_update_user"] = username
822
-
823
- # Import APIs
824
- imported_apis = []
825
- for api_data in apis_data:
826
- # Check if API already exists
827
- existing_api = next((a for a in config.get("apis", []) if a["name"] == api_data.get("name")), None)
828
- if not existing_api:
829
- api_data["created_date"] = get_timestamp()
830
- api_data["created_by"] = username
831
- api_data["last_update_date"] = get_timestamp()
832
- api_data["last_update_user"] = username
833
- api_data["deleted"] = False
834
- config["apis"].append(api_data)
835
- imported_apis.append(api_data["name"])
836
-
837
- # Add project
838
- config["projects"].append(project_data)
839
-
840
- # Add activity log
841
- add_activity_log(config, username, "IMPORT_PROJECT", "project", project_data["id"],
842
- project_data["name"], f"Imported with {len(imported_apis)} APIs")
843
-
844
- # Save
845
- save_config(config)
846
-
847
- log(f"📥 Project '{project_data['name']}' imported by {username}")
848
- return {
849
- "success": True,
850
- "project_name": project_data["name"],
851
- "project_id": project_data["id"],
852
- "imported_apis": imported_apis
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
853
  }
 
1
+ """
2
+ Flare Admin API Routes
3
+ ~~~~~~~~~~~~~~~~~~~~~
4
+ Admin UI için gerekli tüm endpoint'ler
5
+ """
6
+
7
+ import json
8
+ import hashlib
9
+ import secrets
10
+ import commentjson
11
+ from datetime import datetime, timedelta
12
+ from typing import Dict, List, Optional, Any
13
+ from pathlib import Path
14
+
15
+ from fastapi import APIRouter, HTTPException, Depends, Header
16
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
17
+ from pydantic import BaseModel, Field
18
+ import jwt
19
+
20
+ from utils import log
21
+ from config_provider import ConfigProvider
22
+
23
+ # ===================== Constants & Config =====================
24
+ JWT_SECRET = "flare-admin-secret-key-change-in-production"
25
+ JWT_ALGORITHM = "HS256"
26
+ JWT_EXPIRATION_HOURS = 24
27
+
28
+ router = APIRouter(prefix="/api")
29
+ security = HTTPBearer()
30
+
31
+ # ===================== Models =====================
32
+ class LoginRequest(BaseModel):
33
+ username: str
34
+ password: str
35
+
36
+ class LoginResponse(BaseModel):
37
+ token: str
38
+ username: str
39
+
40
+ class EnvironmentUpdate(BaseModel):
41
+ work_mode: str
42
+ cloud_token: Optional[str] = None
43
+ spark_endpoint: str
44
+
45
+ class ProjectCreate(BaseModel):
46
+ name: str
47
+ caption: Optional[str] = ""
48
+
49
+ class ProjectUpdate(BaseModel):
50
+ caption: str
51
+ last_update_date: str
52
+
53
+ class VersionCreate(BaseModel):
54
+ source_version_id: int
55
+ caption: str
56
+
57
+ class IntentModel(BaseModel):
58
+ name: str
59
+ caption: Optional[str] = ""
60
+ locale: str = "tr-TR"
61
+ detection_prompt: str
62
+ examples: List[str] = []
63
+ parameters: List[Dict[str, Any]] = []
64
+ action: str
65
+ fallback_timeout_prompt: Optional[str] = None
66
+ fallback_error_prompt: Optional[str] = None
67
+
68
+ class VersionUpdate(BaseModel):
69
+ caption: str
70
+ general_prompt: str
71
+ llm: Dict[str, Any]
72
+ intents: List[IntentModel]
73
+ last_update_date: str
74
+
75
+ class APICreate(BaseModel):
76
+ name: str
77
+ url: str
78
+ method: str = "POST"
79
+ headers: Dict[str, str] = {}
80
+ body_template: Dict[str, Any] = {}
81
+ timeout_seconds: int = 10
82
+ retry: Dict[str, Any] = Field(default_factory=lambda: {"retry_count": 3, "backoff_seconds": 2, "strategy": "static"})
83
+ proxy: Optional[str] = None
84
+ auth: Optional[Dict[str, Any]] = None
85
+ response_prompt: Optional[str] = None
86
+
87
+ class APIUpdate(BaseModel):
88
+ url: str
89
+ method: str
90
+ headers: Dict[str, str]
91
+ body_template: Dict[str, Any]
92
+ timeout_seconds: int
93
+ retry: Dict[str, Any]
94
+ proxy: Optional[str]
95
+ auth: Optional[Dict[str, Any]]
96
+ response_prompt: Optional[str]
97
+ last_update_date: str
98
+
99
+ class TestRequest(BaseModel):
100
+ test_type: str # "all", "ui", "backend", "integration", "spark"
101
+
102
+ # ===================== Helpers =====================
103
+ def hash_password(password: str) -> str:
104
+ """Simple SHA256 hash for demo. Use bcrypt in production!"""
105
+ return hashlib.sha256(password.encode()).hexdigest()
106
+
107
+ def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
108
+ """Verify JWT token and return username"""
109
+ try:
110
+ payload = jwt.decode(credentials.credentials, JWT_SECRET, algorithms=[JWT_ALGORITHM])
111
+ username = payload.get("sub")
112
+ if username is None:
113
+ raise HTTPException(status_code=401, detail="Invalid token")
114
+ return username
115
+ except jwt.ExpiredSignatureError:
116
+ raise HTTPException(status_code=401, detail="Token expired")
117
+ except jwt.JWTError:
118
+ raise HTTPException(status_code=401, detail="Invalid token")
119
+
120
+ def load_config() -> Dict[str, Any]:
121
+ """Load service_config.jsonc"""
122
+ config_path = Path("service_config.jsonc")
123
+ with open(config_path, 'r', encoding='utf-8') as f:
124
+ return commentjson.load(f)
125
+
126
+ def save_config(config: Dict[str, Any]):
127
+ """Save service_config.jsonc with pretty formatting"""
128
+ config_path = Path("service_config.jsonc")
129
+ with open(config_path, 'w', encoding='utf-8') as f:
130
+ # Convert to JSON string with proper formatting
131
+ json_str = json.dumps(config, indent=2, ensure_ascii=False)
132
+ f.write(json_str)
133
+
134
+ def get_timestamp() -> str:
135
+ """Get current ISO timestamp"""
136
+ return datetime.utcnow().isoformat() + "Z"
137
+
138
+ def add_activity_log(config: Dict[str, Any], user: str, action: str, entity_type: str,
139
+ entity_id: Any, entity_name: str, details: str = ""):
140
+ """Add entry to activity log"""
141
+ if "activity_log" not in config:
142
+ config["activity_log"] = []
143
+
144
+ log_entry = {
145
+ "id": len(config["activity_log"]) + 1,
146
+ "timestamp": get_timestamp(),
147
+ "user": user,
148
+ "action": action,
149
+ "entity_type": entity_type,
150
+ "entity_id": entity_id,
151
+ "entity_name": entity_name,
152
+ "details": details
153
+ }
154
+
155
+ config["activity_log"].append(log_entry)
156
+
157
+ # Keep only last 100 entries
158
+ if len(config["activity_log"]) > 100:
159
+ config["activity_log"] = config["activity_log"][-100:]
160
+
161
+ # ===================== Auth Endpoints =====================
162
+ @router.post("/login", response_model=LoginResponse)
163
+ async def login(request: LoginRequest):
164
+ """User login"""
165
+ config = load_config()
166
+
167
+ # Find user
168
+ users = config.get("config", {}).get("users", [])
169
+ user = next((u for u in users if u["username"] == request.username), None)
170
+
171
+ if not user:
172
+ raise HTTPException(status_code=401, detail="Invalid credentials")
173
+
174
+ # Verify password (simple hash for demo)
175
+ if hash_password(request.password) != user["password_hash"]:
176
+ raise HTTPException(status_code=401, detail="Invalid credentials")
177
+
178
+ # Create JWT token
179
+ expire = datetime.utcnow() + timedelta(hours=JWT_EXPIRATION_HOURS)
180
+ payload = {
181
+ "sub": request.username,
182
+ "exp": expire
183
+ }
184
+ token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
185
+
186
+ log(f"✅ User '{request.username}' logged in")
187
+ return LoginResponse(token=token, username=request.username)
188
+
189
+ log(f"✅ User '{request.username}' logged in")
190
+ return LoginResponse(token=token, username=request.username)
191
+
192
+ @router.post("/change-password")
193
+ async def change_password(
194
+ current_password: str,
195
+ new_password: str,
196
+ username: str = Depends(verify_token)
197
+ ):
198
+ """Change user password"""
199
+ config = load_config()
200
+
201
+ # Find user
202
+ users = config.get("config", {}).get("users", [])
203
+ user = next((u for u in users if u["username"] == username), None)
204
+
205
+ if not user:
206
+ raise HTTPException(status_code=404, detail="User not found")
207
+
208
+ # Verify current password
209
+ if hash_password(current_password) != user["password_hash"]:
210
+ raise HTTPException(status_code=401, detail="Current password is incorrect")
211
+
212
+ # Update password
213
+ user["password_hash"] = hash_password(new_password)
214
+
215
+ # Save config
216
+ save_config(config)
217
+
218
+ log(f"✅ Password changed for user '{username}'")
219
+ return {"success": True, "message": "Password changed successfully"}
220
+
221
+ # ===================== Environment Endpoints =====================
222
+ @router.get("/environment")
223
+ async def get_environment(username: str = Depends(verify_token)):
224
+ """Get environment configuration"""
225
+ config = load_config()
226
+ env_config = config.get("config", {})
227
+
228
+ return {
229
+ "work_mode": env_config.get("work_mode", "hfcloud"),
230
+ "cloud_token": env_config.get("cloud_token", ""),
231
+ "spark_endpoint": env_config.get("spark_endpoint", "")
232
+ }
233
+
234
+ @router.put("/environment")
235
+ async def update_environment(env: EnvironmentUpdate, username: str = Depends(verify_token)):
236
+ """Update environment configuration"""
237
+ config = load_config()
238
+
239
+ # Update config
240
+ config["config"]["work_mode"] = env.work_mode
241
+ config["config"]["cloud_token"] = env.cloud_token if env.work_mode != "on-premise" else ""
242
+ config["config"]["spark_endpoint"] = env.spark_endpoint
243
+ config["config"]["last_update_date"] = get_timestamp()
244
+ config["config"]["last_update_user"] = username
245
+
246
+ # Save
247
+ save_config(config)
248
+
249
+ # Add activity log
250
+ add_activity_log(config, username, "UPDATE_ENVIRONMENT", "config", 0, "environment",
251
+ f"Work mode: {env.work_mode}")
252
+ save_config(config)
253
+
254
+ log(f"✅ Environment updated by {username}")
255
+ return {"success": True}
256
+
257
+ # ===================== Project Endpoints =====================
258
+ @router.get("/projects")
259
+ async def list_projects(
260
+ include_deleted: bool = False,
261
+ username: str = Depends(verify_token)
262
+ ):
263
+ """List all projects"""
264
+ config = load_config()
265
+ projects = config.get("projects", [])
266
+
267
+ # Filter deleted if needed
268
+ if not include_deleted:
269
+ projects = [p for p in projects if not p.get("deleted", False)]
270
+
271
+ return projects
272
+
273
+ @router.post("/projects")
274
+ async def create_project(project: ProjectCreate, username: str = Depends(verify_token)):
275
+ """Create new project"""
276
+ config = load_config()
277
+
278
+ # Check duplicate name
279
+ existing = [p for p in config.get("projects", []) if p["name"] == project.name]
280
+ if existing:
281
+ raise HTTPException(status_code=400, detail="Project name already exists")
282
+
283
+ # Get next ID
284
+ config["config"]["project_id_counter"] = config["config"].get("project_id_counter", 0) + 1
285
+ project_id = config["config"]["project_id_counter"]
286
+
287
+ # Create project
288
+ new_project = {
289
+ "id": project_id,
290
+ "name": project.name,
291
+ "caption": project.caption,
292
+ "enabled": True,
293
+ "last_version_number": 0,
294
+ "version_id_counter": 0,
295
+ "versions": [],
296
+ "deleted": False,
297
+ "created_date": get_timestamp(),
298
+ "created_by": username,
299
+ "last_update_date": get_timestamp(),
300
+ "last_update_user": username
301
+ }
302
+
303
+ if "projects" not in config:
304
+ config["projects"] = []
305
+ config["projects"].append(new_project)
306
+
307
+ # Add activity log
308
+ add_activity_log(config, username, "CREATE_PROJECT", "project", project_id, project.name)
309
+
310
+ # Save
311
+ save_config(config)
312
+
313
+ log(f"✅ Project '{project.name}' created by {username}")
314
+ return new_project
315
+
316
+ @router.put("/projects/{project_id}")
317
+ async def update_project(
318
+ project_id: int,
319
+ update: ProjectUpdate,
320
+ username: str = Depends(verify_token)
321
+ ):
322
+ """Update project"""
323
+ config = load_config()
324
+
325
+ # Find project
326
+ project = next((p for p in config.get("projects", []) if p["id"] == project_id), None)
327
+ if not project:
328
+ raise HTTPException(status_code=404, detail="Project not found")
329
+
330
+ # Check race condition
331
+ if project.get("last_update_date") != update.last_update_date:
332
+ raise HTTPException(status_code=409, detail="Project was modified by another user")
333
+
334
+ # Update
335
+ project["caption"] = update.caption
336
+ project["last_update_date"] = get_timestamp()
337
+ project["last_update_user"] = username
338
+
339
+ # Add activity log
340
+ add_activity_log(config, username, "UPDATE_PROJECT", "project", project_id, project["name"])
341
+
342
+ # Save
343
+ save_config(config)
344
+
345
+ log(f"✅ Project '{project['name']}' updated by {username}")
346
+ return project
347
+
348
+ @router.delete("/projects/{project_id}")
349
+ async def delete_project(project_id: int, username: str = Depends(verify_token)):
350
+ """Delete project (soft delete)"""
351
+ config = load_config()
352
+
353
+ # Find project
354
+ project = next((p for p in config.get("projects", []) if p["id"] == project_id), None)
355
+ if not project:
356
+ raise HTTPException(status_code=404, detail="Project not found")
357
+
358
+ # Soft delete
359
+ project["deleted"] = True
360
+ project["last_update_date"] = get_timestamp()
361
+ project["last_update_user"] = username
362
+
363
+ # Add activity log
364
+ add_activity_log(config, username, "DELETE_PROJECT", "project", project_id, project["name"])
365
+
366
+ # Save
367
+ save_config(config)
368
+
369
+ log(f"✅ Project '{project['name']}' deleted by {username}")
370
+ return {"success": True}
371
+
372
+ @router.patch("/projects/{project_id}/toggle")
373
+ async def toggle_project(project_id: int, username: str = Depends(verify_token)):
374
+ """Enable/disable project"""
375
+ config = load_config()
376
+
377
+ # Find project
378
+ project = next((p for p in config.get("projects", []) if p["id"] == project_id), None)
379
+ if not project:
380
+ raise HTTPException(status_code=404, detail="Project not found")
381
+
382
+ # Toggle
383
+ project["enabled"] = not project.get("enabled", True)
384
+ project["last_update_date"] = get_timestamp()
385
+ project["last_update_user"] = username
386
+
387
+ # Add activity log
388
+ action = "ENABLE_PROJECT" if project["enabled"] else "DISABLE_PROJECT"
389
+ add_activity_log(config, username, action, "project", project_id, project["name"])
390
+
391
+ # Save
392
+ save_config(config)
393
+
394
+ log(f"✅ Project '{project['name']}' {'enabled' if project['enabled'] else 'disabled'} by {username}")
395
+ return {"enabled": project["enabled"]}
396
+
397
+ # ===================== Version Endpoints =====================
398
+ @router.post("/projects/{project_id}/versions")
399
+ async def create_version(
400
+ project_id: int,
401
+ version: VersionCreate,
402
+ username: str = Depends(verify_token)
403
+ ):
404
+ """Create new version from existing version"""
405
+ config = load_config()
406
+
407
+ # Find project
408
+ project = next((p for p in config.get("projects", []) if p["id"] == project_id), None)
409
+ if not project:
410
+ raise HTTPException(status_code=404, detail="Project not found")
411
+
412
+ # Check if there's unpublished version
413
+ unpublished = [v for v in project.get("versions", []) if not v.get("published", False)]
414
+ if unpublished:
415
+ raise HTTPException(status_code=400, detail="There is already an unpublished version")
416
+
417
+ # Find source version
418
+ source = next((v for v in project.get("versions", []) if v["id"] == version.source_version_id), None)
419
+ if not source:
420
+ raise HTTPException(status_code=404, detail="Source version not found")
421
+
422
+ # Create new version
423
+ project["version_id_counter"] = project.get("version_id_counter", 0) + 1
424
+ new_version = source.copy()
425
+ new_version["id"] = project["version_id_counter"]
426
+ new_version["caption"] = version.caption
427
+ new_version["published"] = False
428
+ new_version["created_date"] = get_timestamp()
429
+ new_version["created_by"] = username
430
+ new_version["last_update_date"] = get_timestamp()
431
+ new_version["last_update_user"] = username
432
+ new_version["publish_date"] = None
433
+ new_version["published_by"] = None
434
+
435
+ project["versions"].append(new_version)
436
+ project["last_update_date"] = get_timestamp()
437
+ project["last_update_user"] = username
438
+
439
+ # Add activity log
440
+ add_activity_log(config, username, "CREATE_VERSION", "version", new_version["id"],
441
+ f"{project['name']} v{new_version['id']}")
442
+
443
+ # Save
444
+ save_config(config)
445
+
446
+ log(f"✅ Version {new_version['id']} created for project '{project['name']}' by {username}")
447
+ return new_version
448
+
449
+ @router.put("/projects/{project_id}/versions/{version_id}")
450
+ async def update_version(
451
+ project_id: int,
452
+ version_id: int,
453
+ update: VersionUpdate,
454
+ username: str = Depends(verify_token)
455
+ ):
456
+ """Update version"""
457
+ config = load_config()
458
+
459
+ # Find project and version
460
+ project = next((p for p in config.get("projects", []) if p["id"] == project_id), None)
461
+ if not project:
462
+ raise HTTPException(status_code=404, detail="Project not found")
463
+
464
+ version = next((v for v in project.get("versions", []) if v["id"] == version_id), None)
465
+ if not version:
466
+ raise HTTPException(status_code=404, detail="Version not found")
467
+
468
+ # Check if published
469
+ if version.get("published", False):
470
+ raise HTTPException(status_code=400, detail="Cannot update published version")
471
+
472
+ # Check race condition
473
+ if version.get("last_update_date") != update.last_update_date:
474
+ raise HTTPException(status_code=409, detail="Version was modified by another user")
475
+
476
+ # Update
477
+ version["caption"] = update.caption
478
+ version["general_prompt"] = update.general_prompt
479
+ version["llm"] = update.llm
480
+ version["intents"] = [intent.dict() for intent in update.intents]
481
+ version["last_update_date"] = get_timestamp()
482
+ version["last_update_user"] = username
483
+
484
+ project["last_update_date"] = get_timestamp()
485
+ project["last_update_user"] = username
486
+
487
+ # Add activity log
488
+ add_activity_log(config, username, "UPDATE_VERSION", "version", version_id,
489
+ f"{project['name']} v{version_id}")
490
+
491
+ # Save
492
+ save_config(config)
493
+
494
+ log(f"✅ Version {version_id} updated for project '{project['name']}' by {username}")
495
+ return version
496
+
497
+ @router.post("/projects/{project_id}/versions/{version_id}/publish")
498
+ async def publish_version(
499
+ project_id: int,
500
+ version_id: int,
501
+ username: str = Depends(verify_token)
502
+ ):
503
+ """Publish version"""
504
+ config = load_config()
505
+
506
+ # Find project and version
507
+ project = next((p for p in config.get("projects", []) if p["id"] == project_id), None)
508
+ if not project:
509
+ raise HTTPException(status_code=404, detail="Project not found")
510
+
511
+ version = next((v for v in project.get("versions", []) if v["id"] == version_id), None)
512
+ if not version:
513
+ raise HTTPException(status_code=404, detail="Version not found")
514
+
515
+ # Unpublish all other versions
516
+ for v in project.get("versions", []):
517
+ if v["id"] != version_id:
518
+ v["published"] = False
519
+
520
+ # Publish this version
521
+ version["published"] = True
522
+ version["publish_date"] = get_timestamp()
523
+ version["published_by"] = username
524
+ version["last_update_date"] = get_timestamp()
525
+ version["last_update_user"] = username
526
+
527
+ project["last_update_date"] = get_timestamp()
528
+ project["last_update_user"] = username
529
+
530
+ # Add activity log
531
+ add_activity_log(config, username, "PUBLISH_VERSION", "version", version_id,
532
+ f"{project['name']} v{version_id}")
533
+
534
+ # Save
535
+ save_config(config)
536
+
537
+ # TODO: Notify Spark about new version
538
+
539
+ log(f"✅ Version {version_id} published for project '{project['name']}' by {username}")
540
+ return {"success": True}
541
+
542
+ @router.delete("/projects/{project_id}/versions/{version_id}")
543
+ async def delete_version(
544
+ project_id: int,
545
+ version_id: int,
546
+ username: str = Depends(verify_token)
547
+ ):
548
+ """Delete version (soft delete)"""
549
+ config = load_config()
550
+
551
+ # Find project and version
552
+ project = next((p for p in config.get("projects", []) if p["id"] == project_id), None)
553
+ if not project:
554
+ raise HTTPException(status_code=404, detail="Project not found")
555
+
556
+ version = next((v for v in project.get("versions", []) if v["id"] == version_id), None)
557
+ if not version:
558
+ raise HTTPException(status_code=404, detail="Version not found")
559
+
560
+ # Cannot delete published version
561
+ if version.get("published", False):
562
+ raise HTTPException(status_code=400, detail="Cannot delete published version")
563
+
564
+ # Soft delete
565
+ version["deleted"] = True
566
+ version["last_update_date"] = get_timestamp()
567
+ version["last_update_user"] = username
568
+
569
+ project["last_update_date"] = get_timestamp()
570
+ project["last_update_user"] = username
571
+
572
+ # Add activity log
573
+ add_activity_log(config, username, "DELETE_VERSION", "version", version_id,
574
+ f"{project['name']} v{version_id}")
575
+
576
+ # Save
577
+ save_config(config)
578
+
579
+ log(f"✅ Version {version_id} deleted for project '{project['name']}' by {username}")
580
+ return {"success": True}
581
+
582
+ # ===================== API Endpoints =====================
583
+ @router.get("/apis")
584
+ async def list_apis(
585
+ include_deleted: bool = False,
586
+ username: str = Depends(verify_token)
587
+ ):
588
+ """List all APIs"""
589
+ config = load_config()
590
+ apis = config.get("apis", [])
591
+
592
+ # Filter deleted if needed
593
+ if not include_deleted:
594
+ apis = [a for a in apis if not a.get("deleted", False)]
595
+
596
+ return apis
597
+
598
+ @router.post("/apis")
599
+ async def create_api(api: APICreate, username: str = Depends(verify_token)):
600
+ """Create new API"""
601
+ config = load_config()
602
+
603
+ # Check duplicate name
604
+ existing = [a for a in config.get("apis", []) if a["name"] == api.name]
605
+ if existing:
606
+ raise HTTPException(status_code=400, detail="API name already exists")
607
+
608
+ # Create API
609
+ new_api = api.dict()
610
+ new_api["deleted"] = False
611
+ new_api["created_date"] = get_timestamp()
612
+ new_api["created_by"] = username
613
+ new_api["last_update_date"] = get_timestamp()
614
+ new_api["last_update_user"] = username
615
+
616
+ if "apis" not in config:
617
+ config["apis"] = []
618
+ config["apis"].append(new_api)
619
+
620
+ # Add activity log
621
+ add_activity_log(config, username, "CREATE_API", "api", api.name, api.name)
622
+
623
+ # Save
624
+ save_config(config)
625
+
626
+ log(f"✅ API '{api.name}' created by {username}")
627
+ return new_api
628
+
629
+ @router.put("/apis/{api_name}")
630
+ async def update_api(
631
+ api_name: str,
632
+ update: APIUpdate,
633
+ username: str = Depends(verify_token)
634
+ ):
635
+ """Update API"""
636
+ config = load_config()
637
+
638
+ # Find API
639
+ api = next((a for a in config.get("apis", []) if a["name"] == api_name), None)
640
+ if not api:
641
+ raise HTTPException(status_code=404, detail="API not found")
642
+
643
+ # Check race condition
644
+ if api.get("last_update_date") != update.last_update_date:
645
+ raise HTTPException(status_code=409, detail="API was modified by another user")
646
+
647
+ # Check if API is in use
648
+ for project in config.get("projects", []):
649
+ for version in project.get("versions", []):
650
+ for intent in version.get("intents", []):
651
+ if intent.get("action") == api_name and version.get("published", False):
652
+ raise HTTPException(status_code=400,
653
+ detail=f"API is used in published version of project '{project['name']}'")
654
+
655
+ # Update
656
+ update_dict = update.dict()
657
+ del update_dict["last_update_date"]
658
+ api.update(update_dict)
659
+ api["last_update_date"] = get_timestamp()
660
+ api["last_update_user"] = username
661
+
662
+ # Add activity log
663
+ add_activity_log(config, username, "UPDATE_API", "api", api_name, api_name)
664
+
665
+ # Save
666
+ save_config(config)
667
+
668
+ log(f"✅ API '{api_name}' updated by {username}")
669
+ return api
670
+
671
+ @router.delete("/apis/{api_name}")
672
+ async def delete_api(api_name: str, username: str = Depends(verify_token)):
673
+ """Delete API (soft delete)"""
674
+ config = load_config()
675
+
676
+ # Find API
677
+ api = next((a for a in config.get("apis", []) if a["name"] == api_name), None)
678
+ if not api:
679
+ raise HTTPException(status_code=404, detail="API not found")
680
+
681
+ # Check if API is in use
682
+ for project in config.get("projects", []):
683
+ for version in project.get("versions", []):
684
+ for intent in version.get("intents", []):
685
+ if intent.get("action") == api_name:
686
+ raise HTTPException(status_code=400,
687
+ detail=f"API is used in project '{project['name']}'")
688
+
689
+ # Soft delete
690
+ api["deleted"] = True
691
+ api["last_update_date"] = get_timestamp()
692
+ api["last_update_user"] = username
693
+
694
+ # Add activity log
695
+ add_activity_log(config, username, "DELETE_API", "api", api_name, api_name)
696
+
697
+ # Save
698
+ save_config(config)
699
+
700
+ log(f"✅ API '{api_name}' deleted by {username}")
701
+ return {"success": True}
702
+
703
+ # ===================== Test Endpoints =====================
704
+ @router.post("/apis/test")
705
+ async def test_api(api: APICreate, username: str = Depends(verify_token)):
706
+ """Test API endpoint"""
707
+ import requests
708
+
709
+ try:
710
+ # Prepare request
711
+ headers = api.headers.copy()
712
+
713
+ # Make request
714
+ response = requests.request(
715
+ method=api.method,
716
+ url=api.url,
717
+ headers=headers,
718
+ json=api.body_template if api.method in ["POST", "PUT", "PATCH"] else None,
719
+ params=api.body_template if api.method == "GET" else None,
720
+ timeout=api.timeout_seconds
721
+ )
722
+
723
+ return {
724
+ "success": True,
725
+ "status_code": response.status_code,
726
+ "response_time_ms": int(response.elapsed.total_seconds() * 1000),
727
+ "headers": dict(response.headers),
728
+ "body": response.text[:1000] # First 1000 chars
729
+ }
730
+ except Exception as e:
731
+ return {
732
+ "success": False,
733
+ "error": str(e)
734
+ }
735
+
736
+ @router.get("/activity-log")
737
+ async def get_activity_log(
738
+ limit: int = 50,
739
+ username: str = Depends(verify_token)
740
+ ):
741
+ """Get activity log"""
742
+ config = load_config()
743
+ logs = config.get("activity_log", [])
744
+
745
+ # Return last N entries
746
+ return logs[-limit:]
747
+
748
+ @router.post("/test/run-all")
749
+ async def run_all_tests(test: TestRequest, username: str = Depends(verify_token)):
750
+ """Run all tests"""
751
+ # This is a placeholder - in real implementation, this would run actual tests
752
+ log(f"🧪 Running {test.test_type} tests requested by {username}")
753
+
754
+ # Simulate test results
755
+ results = {
756
+ "test_type": test.test_type,
757
+ "start_time": get_timestamp(),
758
+ "tests": [
759
+ {"name": "Login with valid credentials", "status": "PASS", "duration_ms": 120},
760
+ {"name": "Create new project", "status": "PASS", "duration_ms": 340},
761
+ {"name": "Delete API in use", "status": "PASS", "duration_ms": 45},
762
+ {"name": "Race condition detection", "status": "PASS", "duration_ms": 567},
763
+ {"name": "Invalid token handling", "status": "PASS", "duration_ms": 23}
764
+ ],
765
+ "summary": {
766
+ "total": 5,
767
+ "passed": 5,
768
+ "failed": 0,
769
+ "duration_ms": 1095
770
+ }
771
+ }
772
+
773
+ return results
774
+
775
+ @router.post("/validate/regex")
776
+ async def validate_regex(pattern: str, test_value: str, username: str = Depends(verify_token)):
777
+ """Validate regex pattern"""
778
+ import re
779
+
780
+ try:
781
+ compiled = re.compile(pattern)
782
+ match = compiled.fullmatch(test_value)
783
+ return {
784
+ "valid": True,
785
+ "matches": match is not None
786
+ }
787
+ except re.error as e:
788
+ return {
789
+ "valid": False,
790
+ "error": str(e)
791
+ }
792
+
793
+ # ===================== Export/Import =====================
794
+ @router.get("/projects/{project_id}/export")
795
+ async def export_project(project_id: int, username: str = Depends(verify_token)):
796
+ """Export project as JSON"""
797
+ config = load_config()
798
+
799
+ # Find project
800
+ project = next((p for p in config.get("projects", []) if p["id"] == project_id), None)
801
+ if not project:
802
+ raise HTTPException(status_code=404, detail="Project not found")
803
+
804
+ # Collect all related APIs
805
+ api_names = set()
806
+ for version in project.get("versions", []):
807
+ for intent in version.get("intents", []):
808
+ api_names.add(intent.get("action"))
809
+
810
+ apis = [a for a in config.get("apis", []) if a["name"] in api_names]
811
+
812
+ # Create export
813
+ export_data = {
814
+ "export_date": get_timestamp(),
815
+ "exported_by": username,
816
+ "project": project,
817
+ "apis": apis
818
+ }
819
+
820
+ log(f"📤 Project '{project['name']}' exported by {username}")
821
+ return export_data
822
+
823
+ @router.post("/projects/import")
824
+ async def import_project(data: Dict[str, Any], username: str = Depends(verify_token)):
825
+ """Import project from JSON"""
826
+ config = load_config()
827
+
828
+ # Extract project and APIs
829
+ project_data = data.get("project", {})
830
+ apis_data = data.get("apis", [])
831
+
832
+ # Check duplicate project name
833
+ existing = [p for p in config.get("projects", []) if p["name"] == project_data.get("name")]
834
+ if existing:
835
+ # Generate new name
836
+ project_data["name"] = f"{project_data['name']}_imported_{int(datetime.now().timestamp())}"
837
+
838
+ # Generate new IDs
839
+ config["config"]["project_id_counter"] = config["config"].get("project_id_counter", 0) + 1
840
+ project_data["id"] = config["config"]["project_id_counter"]
841
+
842
+ # Reset version IDs
843
+ version_counter = 0
844
+ for version in project_data.get("versions", []):
845
+ version_counter += 1
846
+ version["id"] = version_counter
847
+ version["published"] = False # Imported versions are unpublished
848
+
849
+ project_data["version_id_counter"] = version_counter
850
+ project_data["created_date"] = get_timestamp()
851
+ project_data["created_by"] = username
852
+ project_data["last_update_date"] = get_timestamp()
853
+ project_data["last_update_user"] = username
854
+
855
+ # Import APIs
856
+ imported_apis = []
857
+ for api_data in apis_data:
858
+ # Check if API already exists
859
+ existing_api = next((a for a in config.get("apis", []) if a["name"] == api_data.get("name")), None)
860
+ if not existing_api:
861
+ api_data["created_date"] = get_timestamp()
862
+ api_data["created_by"] = username
863
+ api_data["last_update_date"] = get_timestamp()
864
+ api_data["last_update_user"] = username
865
+ api_data["deleted"] = False
866
+ config["apis"].append(api_data)
867
+ imported_apis.append(api_data["name"])
868
+
869
+ # Add project
870
+ config["projects"].append(project_data)
871
+
872
+ # Add activity log
873
+ add_activity_log(config, username, "IMPORT_PROJECT", "project", project_data["id"],
874
+ project_data["name"], f"Imported with {len(imported_apis)} APIs")
875
+
876
+ # Save
877
+ save_config(config)
878
+
879
+ log(f"📥 Project '{project_data['name']}' imported by {username}")
880
+ return {
881
+ "success": True,
882
+ "project_name": project_data["name"],
883
+ "project_id": project_data["id"],
884
+ "imported_apis": imported_apis
885
  }