ciyidogan commited on
Commit
ae8f75a
·
verified ·
1 Parent(s): 90bb830

Update config/config_provider.py

Browse files
Files changed (1) hide show
  1. config/config_provider.py +949 -949
config/config_provider.py CHANGED
@@ -1,950 +1,950 @@
1
- """
2
- Thread-Safe Configuration Provider for Flare Platform
3
- """
4
- import threading
5
- import os
6
- import json
7
- import commentjson
8
- from typing import Optional, Dict, List, Any
9
- from datetime import datetime
10
- from pathlib import Path
11
- import tempfile
12
- import shutil
13
- from utils import get_current_timestamp, normalize_timestamp, timestamps_equal
14
-
15
- from config_models import (
16
- ServiceConfig, GlobalConfig, ProjectConfig, VersionConfig,
17
- IntentConfig, APIConfig, ActivityLogEntry, ParameterConfig,
18
- LLMConfiguration, GenerationConfig
19
- )
20
- from utils.logger import log_info, log_error, log_warning, log_debug, LogTimer
21
- from utils.exceptions import (
22
- RaceConditionError, ConfigurationError, ResourceNotFoundError,
23
- DuplicateResourceError, ValidationError
24
- )
25
- from utils.encryption_utils import encrypt, decrypt
26
-
27
- class ConfigProvider:
28
- """Thread-safe singleton configuration provider"""
29
-
30
- _instance: Optional[ServiceConfig] = None
31
- _lock = threading.RLock() # Reentrant lock for nested calls
32
- _file_lock = threading.Lock() # Separate lock for file operations
33
- _CONFIG_PATH = Path(__file__).parent.parent / "service_config.jsonc"
34
-
35
- @staticmethod
36
- def _normalize_date(date_str: Optional[str]) -> str:
37
- """Normalize date string for comparison"""
38
- if not date_str:
39
- return ""
40
- return date_str.replace(' ', 'T').replace('+00:00', 'Z').replace('.000Z', 'Z')
41
-
42
- @classmethod
43
- def get(cls) -> ServiceConfig:
44
- """Get cached configuration - thread-safe"""
45
- if cls._instance is None:
46
- with cls._lock:
47
- # Double-checked locking pattern
48
- if cls._instance is None:
49
- with LogTimer("config_load"):
50
- cls._instance = cls._load()
51
- cls._instance.build_index()
52
- log_info("Configuration loaded successfully")
53
- return cls._instance
54
-
55
- @classmethod
56
- def reload(cls) -> ServiceConfig:
57
- """Force reload configuration from file"""
58
- with cls._lock:
59
- log_info("Reloading configuration...")
60
- cls._instance = None
61
- return cls.get()
62
-
63
- @classmethod
64
- def _load(cls) -> ServiceConfig:
65
- """Load configuration from file"""
66
- try:
67
- if not cls._CONFIG_PATH.exists():
68
- raise ConfigurationError(
69
- f"Config file not found: {cls._CONFIG_PATH}",
70
- config_key="service_config.jsonc"
71
- )
72
-
73
- with open(cls._CONFIG_PATH, 'r', encoding='utf-8') as f:
74
- config_data = commentjson.load(f)
75
-
76
- # Debug: İlk project'in tarihini kontrol et
77
- if 'projects' in config_data and len(config_data['projects']) > 0:
78
- first_project = config_data['projects'][0]
79
- log_debug(f"🔍 Raw project data - last_update_date: {first_project.get('last_update_date')}")
80
-
81
- # Ensure required fields
82
- if 'config' not in config_data:
83
- config_data['config'] = {}
84
-
85
- # Ensure providers exist
86
- cls._ensure_providers(config_data)
87
-
88
- # Parse API configs (handle JSON strings)
89
- if 'apis' in config_data:
90
- cls._parse_api_configs(config_data['apis'])
91
-
92
- # Validate and create model
93
- cfg = ServiceConfig.model_validate(config_data)
94
-
95
- # Debug: Model'e dönüştükten sonra kontrol et
96
- if cfg.projects and len(cfg.projects) > 0:
97
- log_debug(f"🔍 Parsed project - last_update_date: {cfg.projects[0].last_update_date}")
98
- log_debug(f"🔍 Type: {type(cfg.projects[0].last_update_date)}")
99
-
100
- # Log versions published status after parsing
101
- for version in cfg.projects[0].versions:
102
- log_debug(f"🔍 Parsed version {version.no} - published: {version.published} (type: {type(version.published)})")
103
-
104
- log_debug(
105
- "Configuration loaded",
106
- projects=len(cfg.projects),
107
- apis=len(cfg.apis),
108
- users=len(cfg.global_config.users)
109
- )
110
-
111
- return cfg
112
-
113
- except Exception as e:
114
- log_error(f"Error loading config", error=str(e), path=str(cls._CONFIG_PATH))
115
- raise ConfigurationError(f"Failed to load configuration: {e}")
116
-
117
- @classmethod
118
- def _parse_api_configs(cls, apis: List[Dict[str, Any]]) -> None:
119
- """Parse JSON string fields in API configs"""
120
- for api in apis:
121
- # Parse headers
122
- if 'headers' in api and isinstance(api['headers'], str):
123
- try:
124
- api['headers'] = json.loads(api['headers'])
125
- except json.JSONDecodeError:
126
- api['headers'] = {}
127
-
128
- # Parse body_template
129
- if 'body_template' in api and isinstance(api['body_template'], str):
130
- try:
131
- api['body_template'] = json.loads(api['body_template'])
132
- except json.JSONDecodeError:
133
- api['body_template'] = {}
134
-
135
- # Parse auth configs
136
- if 'auth' in api and api['auth']:
137
- cls._parse_auth_config(api['auth'])
138
-
139
- @classmethod
140
- def _parse_auth_config(cls, auth: Dict[str, Any]) -> None:
141
- """Parse auth configuration"""
142
- # Parse token_request_body
143
- if 'token_request_body' in auth and isinstance(auth['token_request_body'], str):
144
- try:
145
- auth['token_request_body'] = json.loads(auth['token_request_body'])
146
- except json.JSONDecodeError:
147
- auth['token_request_body'] = {}
148
-
149
- # Parse token_refresh_body
150
- if 'token_refresh_body' in auth and isinstance(auth['token_refresh_body'], str):
151
- try:
152
- auth['token_refresh_body'] = json.loads(auth['token_refresh_body'])
153
- except json.JSONDecodeError:
154
- auth['token_refresh_body'] = {}
155
-
156
- @classmethod
157
- def save(cls, config: ServiceConfig, username: str) -> None:
158
- """Thread-safe configuration save with optimistic locking"""
159
- with cls._file_lock:
160
- try:
161
- # Convert to dict for JSON serialization
162
- config_dict = config.model_dump()
163
-
164
- # Load current config for race condition check
165
- try:
166
- current_config = cls._load()
167
-
168
- # Check for race condition
169
- if config.last_update_date and current_config.last_update_date:
170
- if not timestamps_equal(config.last_update_date, current_config.last_update_date):
171
- raise RaceConditionError(
172
- "Configuration was modified by another user",
173
- current_user=username,
174
- last_update_user=current_config.last_update_user,
175
- last_update_date=current_config.last_update_date,
176
- entity_type="configuration"
177
- )
178
- except ConfigurationError as e:
179
- # Eğer mevcut config yüklenemiyorsa, race condition kontrolünü atla
180
- log_warning(f"Could not load current config for race condition check: {e}")
181
- current_config = None
182
-
183
- # Update metadata
184
- config.last_update_date = get_current_timestamp()
185
- config.last_update_user = username
186
-
187
- # Convert to JSON - Pydantic v2 kullanımı
188
- data = config.model_dump(mode='json')
189
- json_str = json.dumps(data, ensure_ascii=False, indent=2)
190
-
191
- # Backup current file if exists
192
- backup_path = None
193
- if cls._CONFIG_PATH.exists():
194
- backup_path = cls._CONFIG_PATH.with_suffix('.backup')
195
- shutil.copy2(str(cls._CONFIG_PATH), str(backup_path))
196
- log_debug(f"Created backup at {backup_path}")
197
-
198
- try:
199
- # Write to temporary file first
200
- temp_path = cls._CONFIG_PATH.with_suffix('.tmp')
201
- with open(temp_path, 'w', encoding='utf-8') as f:
202
- f.write(json_str)
203
-
204
- # Validate the temp file by trying to load it
205
- with open(temp_path, 'r', encoding='utf-8') as f:
206
- test_data = commentjson.load(f)
207
- ServiceConfig.model_validate(test_data)
208
-
209
- # If validation passes, replace the original
210
- shutil.move(str(temp_path), str(cls._CONFIG_PATH))
211
-
212
- # Delete backup if save successful
213
- if backup_path and backup_path.exists():
214
- backup_path.unlink()
215
-
216
- except Exception as e:
217
- # Restore from backup if something went wrong
218
- if backup_path and backup_path.exists():
219
- shutil.move(str(backup_path), str(cls._CONFIG_PATH))
220
- log_error(f"Restored configuration from backup due to error: {e}")
221
- raise
222
-
223
- # Update cached instance
224
- with cls._lock:
225
- cls._instance = config
226
-
227
- log_info(
228
- "Configuration saved successfully",
229
- user=username,
230
- last_update=config.last_update_date
231
- )
232
-
233
- except Exception as e:
234
- log_error(f"Failed to save config", error=str(e))
235
- raise ConfigurationError(
236
- f"Failed to save configuration: {str(e)}",
237
- config_key="service_config.jsonc"
238
- )
239
-
240
- # ===================== Environment Methods =====================
241
-
242
- @classmethod
243
- def update_environment(cls, update_data: dict, username: str) -> None:
244
- """Update environment configuration"""
245
- with cls._lock:
246
- config = cls.get()
247
-
248
- # Update providers
249
- if 'llm_provider' in update_data:
250
- config.global_config.llm_provider = update_data['llm_provider']
251
-
252
- if 'tts_provider' in update_data:
253
- config.global_config.tts_provider = update_data['tts_provider']
254
-
255
- if 'stt_provider' in update_data:
256
- config.global_config.stt_provider = update_data['stt_provider']
257
-
258
- # Log activity
259
- cls._add_activity(
260
- config, username, "UPDATE_ENVIRONMENT",
261
- "environment", None,
262
- f"Updated providers"
263
- )
264
-
265
- # Save
266
- cls.save(config, username)
267
-
268
- @classmethod
269
- def _ensure_providers(cls, config_data: Dict[str, Any]) -> None:
270
- """Ensure config has required provider structure"""
271
- if 'config' not in config_data:
272
- config_data['config'] = {}
273
-
274
- config = config_data['config']
275
-
276
- # Ensure provider settings exist
277
- if 'llm_provider' not in config:
278
- config['llm_provider'] = {
279
- 'name': 'spark_cloud',
280
- 'api_key': '',
281
- 'endpoint': 'http://localhost:8080',
282
- 'settings': {}
283
- }
284
-
285
- if 'tts_provider' not in config:
286
- config['tts_provider'] = {
287
- 'name': 'no_tts',
288
- 'api_key': '',
289
- 'endpoint': None,
290
- 'settings': {}
291
- }
292
-
293
- if 'stt_provider' not in config:
294
- config['stt_provider'] = {
295
- 'name': 'no_stt',
296
- 'api_key': '',
297
- 'endpoint': None,
298
- 'settings': {}
299
- }
300
-
301
- # Ensure providers list exists
302
- if 'providers' not in config:
303
- config['providers'] = [
304
- {
305
- "type": "llm",
306
- "name": "spark_cloud",
307
- "display_name": "Spark LLM (Cloud)",
308
- "requires_endpoint": True,
309
- "requires_api_key": True,
310
- "requires_repo_info": False,
311
- "description": "Spark Cloud LLM Service"
312
- },
313
- {
314
- "type": "tts",
315
- "name": "no_tts",
316
- "display_name": "No TTS",
317
- "requires_endpoint": False,
318
- "requires_api_key": False,
319
- "requires_repo_info": False,
320
- "description": "Text-to-Speech disabled"
321
- },
322
- {
323
- "type": "stt",
324
- "name": "no_stt",
325
- "display_name": "No STT",
326
- "requires_endpoint": False,
327
- "requires_api_key": False,
328
- "requires_repo_info": False,
329
- "description": "Speech-to-Text disabled"
330
- }
331
- ]
332
-
333
- # ===================== Project Methods =====================
334
-
335
- @classmethod
336
- def get_project(cls, project_id: int) -> Optional[ProjectConfig]:
337
- """Get project by ID"""
338
- config = cls.get()
339
- return next((p for p in config.projects if p.id == project_id), None)
340
-
341
- @classmethod
342
- def create_project(cls, project_data: dict, username: str) -> ProjectConfig:
343
- """Create new project with initial version"""
344
- with cls._lock:
345
- config = cls.get()
346
-
347
- # Check for duplicate name
348
- existing_project = next((p for p in config.projects if p.name == project_data['name'] and not p.deleted), None)
349
- if existing_project:
350
- raise DuplicateResourceError("Project", project_data['name'])
351
-
352
-
353
- # Create project
354
- project = ProjectConfig(
355
- id=config.project_id_counter,
356
- created_date=get_current_timestamp(),
357
- created_by=username,
358
- version_id_counter=1, # Başlangıç değeri
359
- versions=[], # Boş başla
360
- **project_data
361
- )
362
-
363
- # Create initial version with proper models
364
- initial_version = VersionConfig(
365
- no=1,
366
- caption="Initial version",
367
- description="Auto-generated initial version",
368
- published=False, # Explicitly set to False
369
- deleted=False,
370
- general_prompt="You are a helpful assistant.",
371
- welcome_prompt=None,
372
- llm=LLMConfiguration(
373
- repo_id="ytu-ce-cosmos/Turkish-Llama-8b-Instruct-v0.1",
374
- generation_config=GenerationConfig(
375
- max_new_tokens=512,
376
- temperature=0.7,
377
- top_p=0.9,
378
- repetition_penalty=1.1,
379
- do_sample=True
380
- ),
381
- use_fine_tune=False,
382
- fine_tune_zip=""
383
- ),
384
- intents=[],
385
- created_date=get_current_timestamp(),
386
- created_by=username,
387
- last_update_date=None,
388
- last_update_user=None,
389
- publish_date=None,
390
- published_by=None
391
- )
392
-
393
- # Add initial version to project
394
- project.versions.append(initial_version)
395
- project.version_id_counter = 2 # Next version will be 2
396
-
397
- # Update config
398
- config.projects.append(project)
399
- config.project_id_counter += 1
400
-
401
- # Log activity
402
- cls._add_activity(
403
- config, username, "CREATE_PROJECT",
404
- "project", project.name,
405
- f"Created with initial version"
406
- )
407
-
408
- # Save
409
- cls.save(config, username)
410
-
411
- log_info(
412
- "Project created with initial version",
413
- project_id=project.id,
414
- name=project.name,
415
- user=username
416
- )
417
-
418
- return project
419
-
420
- @classmethod
421
- def update_project(cls, project_id: int, update_data: dict, username: str, expected_last_update: Optional[str] = None) -> ProjectConfig:
422
- """Update project with optimistic locking"""
423
- with cls._lock:
424
- config = cls.get()
425
- project = cls.get_project(project_id)
426
-
427
- if not project:
428
- raise ResourceNotFoundError("project", project_id)
429
-
430
- # Check race condition
431
- if expected_last_update is not None and expected_last_update != '':
432
- if project.last_update_date and not timestamps_equal(expected_last_update, project.last_update_date):
433
- raise RaceConditionError(
434
- f"Project '{project.name}' was modified by another user",
435
- current_user=username,
436
- last_update_user=project.last_update_user,
437
- last_update_date=project.last_update_date,
438
- entity_type="project",
439
- entity_id=project_id
440
- )
441
-
442
- # Update fields
443
- for key, value in update_data.items():
444
- if hasattr(project, key) and key not in ['id', 'created_date', 'created_by', 'last_update_date', 'last_update_user']:
445
- setattr(project, key, value)
446
-
447
- project.last_update_date = get_current_timestamp()
448
- project.last_update_user = username
449
-
450
- cls._add_activity(
451
- config, username, "UPDATE_PROJECT",
452
- "project", project.name
453
- )
454
-
455
- # Save
456
- cls.save(config, username)
457
-
458
- log_info(
459
- "Project updated",
460
- project_id=project.id,
461
- user=username
462
- )
463
-
464
- return project
465
-
466
- @classmethod
467
- def delete_project(cls, project_id: int, username: str) -> None:
468
- """Soft delete project"""
469
- with cls._lock:
470
- config = cls.get()
471
- project = cls.get_project(project_id)
472
-
473
- if not project:
474
- raise ResourceNotFoundError("project", project_id)
475
-
476
- project.deleted = True
477
- project.last_update_date = get_current_timestamp()
478
- project.last_update_user = username
479
-
480
- cls._add_activity(
481
- config, username, "DELETE_PROJECT",
482
- "project", project.name
483
- )
484
-
485
- # Save
486
- cls.save(config, username)
487
-
488
- log_info(
489
- "Project deleted",
490
- project_id=project.id,
491
- user=username
492
- )
493
-
494
- @classmethod
495
- def toggle_project(cls, project_id: int, username: str) -> bool:
496
- """Toggle project enabled status"""
497
- with cls._lock:
498
- config = cls.get()
499
- project = cls.get_project(project_id)
500
-
501
- if not project:
502
- raise ResourceNotFoundError("project", project_id)
503
-
504
- project.enabled = not project.enabled
505
- project.last_update_date = get_current_timestamp()
506
- project.last_update_user = username
507
-
508
- # Log activity
509
- cls._add_activity(
510
- config, username, "TOGGLE_PROJECT",
511
- "project", project.name,
512
- f"{'Enabled' if project.enabled else 'Disabled'}"
513
- )
514
-
515
- # Save
516
- cls.save(config, username)
517
-
518
- log_info(
519
- "Project toggled",
520
- project_id=project.id,
521
- enabled=project.enabled,
522
- user=username
523
- )
524
-
525
- return project.enabled
526
-
527
- # ===================== Version Methods =====================
528
-
529
- @classmethod
530
- def create_version(cls, project_id: int, version_data: dict, username: str) -> VersionConfig:
531
- """Create new version"""
532
- with cls._lock:
533
- config = cls.get()
534
- project = cls.get_project(project_id)
535
-
536
- if not project:
537
- raise ResourceNotFoundError("project", project_id)
538
-
539
- # Handle source version copy
540
- if 'source_version_no' in version_data and version_data['source_version_no']:
541
- source_version = next((v for v in project.versions if v.no == version_data['source_version_no']), None)
542
- if source_version:
543
- # Copy from source version
544
- version_dict = source_version.model_dump()
545
- # Remove fields that shouldn't be copied
546
- for field in ['no', 'created_date', 'created_by', 'published', 'publish_date',
547
- 'published_by', 'last_update_date', 'last_update_user']:
548
- version_dict.pop(field, None)
549
- # Override with provided data
550
- version_dict['caption'] = version_data.get('caption', f"Copy of {source_version.caption}")
551
- else:
552
- # Source not found, create blank
553
- version_dict = {
554
- 'caption': version_data.get('caption', 'New Version'),
555
- 'general_prompt': '',
556
- 'welcome_prompt': None,
557
- 'llm': {
558
- 'repo_id': '',
559
- 'generation_config': {
560
- 'max_new_tokens': 512,
561
- 'temperature': 0.7,
562
- 'top_p': 0.95,
563
- 'repetition_penalty': 1.1
564
- },
565
- 'use_fine_tune': False,
566
- 'fine_tune_zip': ''
567
- },
568
- 'intents': []
569
- }
570
- else:
571
- # Create blank version
572
- version_dict = {
573
- 'caption': version_data.get('caption', 'New Version'),
574
- 'general_prompt': '',
575
- 'welcome_prompt': None,
576
- 'llm': {
577
- 'repo_id': '',
578
- 'generation_config': {
579
- 'max_new_tokens': 512,
580
- 'temperature': 0.7,
581
- 'top_p': 0.95,
582
- 'repetition_penalty': 1.1
583
- },
584
- 'use_fine_tune': False,
585
- 'fine_tune_zip': ''
586
- },
587
- 'intents': []
588
- }
589
-
590
- # Create version
591
- version = VersionConfig(
592
- no=project.version_id_counter,
593
- published=False, # New versions are always unpublished
594
- deleted=False,
595
- created_date=get_current_timestamp(),
596
- created_by=username,
597
- last_update_date=None,
598
- last_update_user=None,
599
- publish_date=None,
600
- published_by=None,
601
- **version_dict
602
- )
603
-
604
- # Update project
605
- project.versions.append(version)
606
- project.version_id_counter += 1
607
- project.last_update_date = get_current_timestamp()
608
- project.last_update_user = username
609
-
610
- # Log activity
611
- cls._add_activity(
612
- config, username, "CREATE_VERSION",
613
- "version", version.no, f"{project.name} v{version.no}",
614
- f"Project: {project.name}"
615
- )
616
-
617
- # Save
618
- cls.save(config, username)
619
-
620
- log_info(
621
- "Version created",
622
- project_id=project.id,
623
- version_no=version.no,
624
- user=username
625
- )
626
-
627
- return version
628
-
629
- @classmethod
630
- def publish_version(cls, project_id: int, version_no: int, username: str) -> tuple[ProjectConfig, VersionConfig]:
631
- """Publish a version"""
632
- with cls._lock:
633
- config = cls.get()
634
- project = cls.get_project(project_id)
635
-
636
- if not project:
637
- raise ResourceNotFoundError("project", project_id)
638
-
639
- version = next((v for v in project.versions if v.no == version_no), None)
640
- if not version:
641
- raise ResourceNotFoundError("version", version_no)
642
-
643
- # Unpublish other versions
644
- for v in project.versions:
645
- if v.published and v.no != version_no:
646
- v.published = False
647
-
648
- # Publish this version
649
- version.published = True
650
- version.publish_date = get_current_timestamp()
651
- version.published_by = username
652
-
653
- # Update project
654
- project.last_update_date = get_current_timestamp()
655
- project.last_update_user = username
656
-
657
- # Log activity
658
- cls._add_activity(
659
- config, username, "PUBLISH_VERSION",
660
- "version", f"{project.name} v{version.no}"
661
- )
662
-
663
- # Save
664
- cls.save(config, username)
665
-
666
- log_info(
667
- "Version published",
668
- project_id=project.id,
669
- version_no=version.no,
670
- user=username
671
- )
672
-
673
- return project, version
674
-
675
- @classmethod
676
- def update_version(cls, project_id: int, version_no: int, update_data: dict, username: str, expected_last_update: Optional[str] = None) -> VersionConfig:
677
- """Update version with optimistic locking"""
678
- with cls._lock:
679
- config = cls.get()
680
- project = cls.get_project(project_id)
681
-
682
- if not project:
683
- raise ResourceNotFoundError("project", project_id)
684
-
685
- version = next((v for v in project.versions if v.no == version_no), None)
686
- if not version:
687
- raise ResourceNotFoundError("version", version_no)
688
-
689
- # Ensure published is a boolean (safety check)
690
- if version.published is None:
691
- version.published = False
692
-
693
- # Published versions cannot be edited
694
- if version.published:
695
- raise ValidationError("Published versions cannot be modified")
696
-
697
- # Check race condition
698
- if expected_last_update is not None and expected_last_update != '':
699
- if version.last_update_date and not timestamps_equal(expected_last_update, version.last_update_date):
700
- raise RaceConditionError(
701
- f"Version '{version.no}' was modified by another user",
702
- current_user=username,
703
- last_update_user=version.last_update_user,
704
- last_update_date=version.last_update_date,
705
- entity_type="version",
706
- entity_id=f"{project_id}:{version_no}"
707
- )
708
-
709
- # Update fields
710
- for key, value in update_data.items():
711
- if hasattr(version, key) and key not in ['no', 'created_date', 'created_by', 'published', 'last_update_date']:
712
- # Handle LLM config
713
- if key == 'llm' and isinstance(value, dict):
714
- setattr(version, key, LLMConfiguration(**value))
715
- # Handle intents
716
- elif key == 'intents' and isinstance(value, list):
717
- intents = []
718
- for intent_data in value:
719
- if isinstance(intent_data, dict):
720
- intents.append(IntentConfig(**intent_data))
721
- else:
722
- intents.append(intent_data)
723
- setattr(version, key, intents)
724
- else:
725
- setattr(version, key, value)
726
-
727
- version.last_update_date = get_current_timestamp()
728
- version.last_update_user = username
729
-
730
- # Update project last update
731
- project.last_update_date = get_current_timestamp()
732
- project.last_update_user = username
733
-
734
- # Log activity
735
- cls._add_activity(
736
- config, username, "UPDATE_VERSION",
737
- "version", f"{project.name} v{version.no}"
738
- )
739
-
740
- # Save
741
- cls.save(config, username)
742
-
743
- log_info(
744
- "Version updated",
745
- project_id=project.id,
746
- version_no=version.no,
747
- user=username
748
- )
749
-
750
- return version
751
-
752
- @classmethod
753
- def delete_version(cls, project_id: int, version_no: int, username: str) -> None:
754
- """Soft delete version"""
755
- with cls._lock:
756
- config = cls.get()
757
- project = cls.get_project(project_id)
758
-
759
- if not project:
760
- raise ResourceNotFoundError("project", project_id)
761
-
762
- version = next((v for v in project.versions if v.no == version_no), None)
763
- if not version:
764
- raise ResourceNotFoundError("version", version_no)
765
-
766
- if version.published:
767
- raise ValidationError("Cannot delete published version")
768
-
769
- version.deleted = True
770
- version.last_update_date = get_current_timestamp()
771
- version.last_update_user = username
772
-
773
- # Update project
774
- project.last_update_date = get_current_timestamp()
775
- project.last_update_user = username
776
-
777
- # Log activity
778
- cls._add_activity(
779
- config, username, "DELETE_VERSION",
780
- "version", f"{project.name} v{version.no}"
781
- )
782
-
783
- # Save
784
- cls.save(config, username)
785
-
786
- log_info(
787
- "Version deleted",
788
- project_id=project.id,
789
- version_no=version.no,
790
- user=username
791
- )
792
-
793
- # ===================== API Methods =====================
794
- @classmethod
795
- def create_api(cls, api_data: dict, username: str) -> APIConfig:
796
- """Create new API"""
797
- with cls._lock:
798
- config = cls.get()
799
-
800
- # Check for duplicate name
801
- existing_api = next((a for a in config.apis if a.name == api_data['name'] and not a.deleted), None)
802
- if existing_api:
803
- raise DuplicateResourceError("API", api_data['name'])
804
-
805
- # Create API
806
- api = APIConfig(
807
- created_date=get_current_timestamp(),
808
- created_by=username,
809
- **api_data
810
- )
811
-
812
- # Add to config
813
- config.apis.append(api)
814
-
815
- # Rebuild index
816
- config.build_index()
817
-
818
- # Log activity
819
- cls._add_activity(
820
- config, username, "CREATE_API",
821
- "api", api.name
822
- )
823
-
824
- # Save
825
- cls.save(config, username)
826
-
827
- log_info(
828
- "API created",
829
- api_name=api.name,
830
- user=username
831
- )
832
-
833
- return api
834
-
835
- @classmethod
836
- def update_api(cls, api_name: str, update_data: dict, username: str, expected_last_update: Optional[str] = None) -> APIConfig:
837
- """Update API with optimistic locking"""
838
- with cls._lock:
839
- config = cls.get()
840
- api = config.get_api(api_name)
841
-
842
- if not api:
843
- raise ResourceNotFoundError("api", api_name)
844
-
845
- # Check race condition
846
- if expected_last_update is not None and expected_last_update != '':
847
- if api.last_update_date and not timestamps_equal(expected_last_update, api.last_update_date):
848
- raise RaceConditionError(
849
- f"API '{api.name}' was modified by another user",
850
- current_user=username,
851
- last_update_user=api.last_update_user,
852
- last_update_date=api.last_update_date,
853
- entity_type="api",
854
- entity_id=api.name
855
- )
856
-
857
- # Update fields
858
- for key, value in update_data.items():
859
- if hasattr(api, key) and key not in ['name', 'created_date', 'created_by', 'last_update_date']:
860
- setattr(api, key, value)
861
-
862
- api.last_update_date = get_current_timestamp()
863
- api.last_update_user = username
864
-
865
- # Rebuild index
866
- config.build_index()
867
-
868
- # Log activity
869
- cls._add_activity(
870
- config, username, "UPDATE_API",
871
- "api", api.name
872
- )
873
-
874
- # Save
875
- cls.save(config, username)
876
-
877
- log_info(
878
- "API updated",
879
- api_name=api.name,
880
- user=username
881
- )
882
-
883
- return api
884
-
885
- @classmethod
886
- def delete_api(cls, api_name: str, username: str) -> None:
887
- """Soft delete API"""
888
- with cls._lock:
889
- config = cls.get()
890
- api = config.get_api(api_name)
891
-
892
- if not api:
893
- raise ResourceNotFoundError("api", api_name)
894
-
895
- api.deleted = True
896
- api.last_update_date = get_current_timestamp()
897
- api.last_update_user = username
898
-
899
- # Rebuild index
900
- config.build_index()
901
-
902
- # Log activity
903
- cls._add_activity(
904
- config, username, "DELETE_API",
905
- "api", api.name
906
- )
907
-
908
- # Save
909
- cls.save(config, username)
910
-
911
- log_info(
912
- "API deleted",
913
- api_name=api.name,
914
- user=username
915
- )
916
-
917
- # ===================== Activity Methods =====================
918
- @classmethod
919
- def _add_activity(
920
- cls,
921
- config: ServiceConfig,
922
- username: str,
923
- action: str,
924
- entity_type: str,
925
- entity_name: Optional[str] = None,
926
- details: Optional[str] = None
927
- ) -> None:
928
- """Add activity log entry"""
929
- # Activity ID'sini oluştur - mevcut en yüksek ID'yi bul
930
- max_id = 0
931
- if config.activity_log:
932
- max_id = max((entry.id for entry in config.activity_log if entry.id), default=0)
933
-
934
- activity_id = max_id + 1
935
-
936
- activity = ActivityLogEntry(
937
- id=activity_id,
938
- timestamp=get_current_timestamp(),
939
- username=username,
940
- action=action,
941
- entity_type=entity_type,
942
- entity_name=entity_name,
943
- details=details
944
- )
945
-
946
- config.activity_log.append(activity)
947
-
948
- # Keep only last 1000 entries
949
- if len(config.activity_log) > 1000:
950
  config.activity_log = config.activity_log[-1000:]
 
1
+ """
2
+ Thread-Safe Configuration Provider for Flare Platform
3
+ """
4
+ import threading
5
+ import os
6
+ import json
7
+ import commentjson
8
+ from typing import Optional, Dict, List, Any
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ import tempfile
12
+ import shutil
13
+ from utils import get_current_timestamp, normalize_timestamp, timestamps_equal
14
+
15
+ from .config_models import (
16
+ ServiceConfig, GlobalConfig, ProjectConfig, VersionConfig,
17
+ IntentConfig, APIConfig, ActivityLogEntry, ParameterConfig,
18
+ LLMConfiguration, GenerationConfig
19
+ )
20
+ from utils.logger import log_info, log_error, log_warning, log_debug, LogTimer
21
+ from utils.exceptions import (
22
+ RaceConditionError, ConfigurationError, ResourceNotFoundError,
23
+ DuplicateResourceError, ValidationError
24
+ )
25
+ from utils.encryption_utils import encrypt, decrypt
26
+
27
+ class ConfigProvider:
28
+ """Thread-safe singleton configuration provider"""
29
+
30
+ _instance: Optional[ServiceConfig] = None
31
+ _lock = threading.RLock() # Reentrant lock for nested calls
32
+ _file_lock = threading.Lock() # Separate lock for file operations
33
+ _CONFIG_PATH = Path(__file__).parent.parent / "service_config.jsonc"
34
+
35
+ @staticmethod
36
+ def _normalize_date(date_str: Optional[str]) -> str:
37
+ """Normalize date string for comparison"""
38
+ if not date_str:
39
+ return ""
40
+ return date_str.replace(' ', 'T').replace('+00:00', 'Z').replace('.000Z', 'Z')
41
+
42
+ @classmethod
43
+ def get(cls) -> ServiceConfig:
44
+ """Get cached configuration - thread-safe"""
45
+ if cls._instance is None:
46
+ with cls._lock:
47
+ # Double-checked locking pattern
48
+ if cls._instance is None:
49
+ with LogTimer("config_load"):
50
+ cls._instance = cls._load()
51
+ cls._instance.build_index()
52
+ log_info("Configuration loaded successfully")
53
+ return cls._instance
54
+
55
+ @classmethod
56
+ def reload(cls) -> ServiceConfig:
57
+ """Force reload configuration from file"""
58
+ with cls._lock:
59
+ log_info("Reloading configuration...")
60
+ cls._instance = None
61
+ return cls.get()
62
+
63
+ @classmethod
64
+ def _load(cls) -> ServiceConfig:
65
+ """Load configuration from file"""
66
+ try:
67
+ if not cls._CONFIG_PATH.exists():
68
+ raise ConfigurationError(
69
+ f"Config file not found: {cls._CONFIG_PATH}",
70
+ config_key="service_config.jsonc"
71
+ )
72
+
73
+ with open(cls._CONFIG_PATH, 'r', encoding='utf-8') as f:
74
+ config_data = commentjson.load(f)
75
+
76
+ # Debug: İlk project'in tarihini kontrol et
77
+ if 'projects' in config_data and len(config_data['projects']) > 0:
78
+ first_project = config_data['projects'][0]
79
+ log_debug(f"🔍 Raw project data - last_update_date: {first_project.get('last_update_date')}")
80
+
81
+ # Ensure required fields
82
+ if 'config' not in config_data:
83
+ config_data['config'] = {}
84
+
85
+ # Ensure providers exist
86
+ cls._ensure_providers(config_data)
87
+
88
+ # Parse API configs (handle JSON strings)
89
+ if 'apis' in config_data:
90
+ cls._parse_api_configs(config_data['apis'])
91
+
92
+ # Validate and create model
93
+ cfg = ServiceConfig.model_validate(config_data)
94
+
95
+ # Debug: Model'e dönüştükten sonra kontrol et
96
+ if cfg.projects and len(cfg.projects) > 0:
97
+ log_debug(f"🔍 Parsed project - last_update_date: {cfg.projects[0].last_update_date}")
98
+ log_debug(f"🔍 Type: {type(cfg.projects[0].last_update_date)}")
99
+
100
+ # Log versions published status after parsing
101
+ for version in cfg.projects[0].versions:
102
+ log_debug(f"🔍 Parsed version {version.no} - published: {version.published} (type: {type(version.published)})")
103
+
104
+ log_debug(
105
+ "Configuration loaded",
106
+ projects=len(cfg.projects),
107
+ apis=len(cfg.apis),
108
+ users=len(cfg.global_config.users)
109
+ )
110
+
111
+ return cfg
112
+
113
+ except Exception as e:
114
+ log_error(f"Error loading config", error=str(e), path=str(cls._CONFIG_PATH))
115
+ raise ConfigurationError(f"Failed to load configuration: {e}")
116
+
117
+ @classmethod
118
+ def _parse_api_configs(cls, apis: List[Dict[str, Any]]) -> None:
119
+ """Parse JSON string fields in API configs"""
120
+ for api in apis:
121
+ # Parse headers
122
+ if 'headers' in api and isinstance(api['headers'], str):
123
+ try:
124
+ api['headers'] = json.loads(api['headers'])
125
+ except json.JSONDecodeError:
126
+ api['headers'] = {}
127
+
128
+ # Parse body_template
129
+ if 'body_template' in api and isinstance(api['body_template'], str):
130
+ try:
131
+ api['body_template'] = json.loads(api['body_template'])
132
+ except json.JSONDecodeError:
133
+ api['body_template'] = {}
134
+
135
+ # Parse auth configs
136
+ if 'auth' in api and api['auth']:
137
+ cls._parse_auth_config(api['auth'])
138
+
139
+ @classmethod
140
+ def _parse_auth_config(cls, auth: Dict[str, Any]) -> None:
141
+ """Parse auth configuration"""
142
+ # Parse token_request_body
143
+ if 'token_request_body' in auth and isinstance(auth['token_request_body'], str):
144
+ try:
145
+ auth['token_request_body'] = json.loads(auth['token_request_body'])
146
+ except json.JSONDecodeError:
147
+ auth['token_request_body'] = {}
148
+
149
+ # Parse token_refresh_body
150
+ if 'token_refresh_body' in auth and isinstance(auth['token_refresh_body'], str):
151
+ try:
152
+ auth['token_refresh_body'] = json.loads(auth['token_refresh_body'])
153
+ except json.JSONDecodeError:
154
+ auth['token_refresh_body'] = {}
155
+
156
+ @classmethod
157
+ def save(cls, config: ServiceConfig, username: str) -> None:
158
+ """Thread-safe configuration save with optimistic locking"""
159
+ with cls._file_lock:
160
+ try:
161
+ # Convert to dict for JSON serialization
162
+ config_dict = config.model_dump()
163
+
164
+ # Load current config for race condition check
165
+ try:
166
+ current_config = cls._load()
167
+
168
+ # Check for race condition
169
+ if config.last_update_date and current_config.last_update_date:
170
+ if not timestamps_equal(config.last_update_date, current_config.last_update_date):
171
+ raise RaceConditionError(
172
+ "Configuration was modified by another user",
173
+ current_user=username,
174
+ last_update_user=current_config.last_update_user,
175
+ last_update_date=current_config.last_update_date,
176
+ entity_type="configuration"
177
+ )
178
+ except ConfigurationError as e:
179
+ # Eğer mevcut config yüklenemiyorsa, race condition kontrolünü atla
180
+ log_warning(f"Could not load current config for race condition check: {e}")
181
+ current_config = None
182
+
183
+ # Update metadata
184
+ config.last_update_date = get_current_timestamp()
185
+ config.last_update_user = username
186
+
187
+ # Convert to JSON - Pydantic v2 kullanımı
188
+ data = config.model_dump(mode='json')
189
+ json_str = json.dumps(data, ensure_ascii=False, indent=2)
190
+
191
+ # Backup current file if exists
192
+ backup_path = None
193
+ if cls._CONFIG_PATH.exists():
194
+ backup_path = cls._CONFIG_PATH.with_suffix('.backup')
195
+ shutil.copy2(str(cls._CONFIG_PATH), str(backup_path))
196
+ log_debug(f"Created backup at {backup_path}")
197
+
198
+ try:
199
+ # Write to temporary file first
200
+ temp_path = cls._CONFIG_PATH.with_suffix('.tmp')
201
+ with open(temp_path, 'w', encoding='utf-8') as f:
202
+ f.write(json_str)
203
+
204
+ # Validate the temp file by trying to load it
205
+ with open(temp_path, 'r', encoding='utf-8') as f:
206
+ test_data = commentjson.load(f)
207
+ ServiceConfig.model_validate(test_data)
208
+
209
+ # If validation passes, replace the original
210
+ shutil.move(str(temp_path), str(cls._CONFIG_PATH))
211
+
212
+ # Delete backup if save successful
213
+ if backup_path and backup_path.exists():
214
+ backup_path.unlink()
215
+
216
+ except Exception as e:
217
+ # Restore from backup if something went wrong
218
+ if backup_path and backup_path.exists():
219
+ shutil.move(str(backup_path), str(cls._CONFIG_PATH))
220
+ log_error(f"Restored configuration from backup due to error: {e}")
221
+ raise
222
+
223
+ # Update cached instance
224
+ with cls._lock:
225
+ cls._instance = config
226
+
227
+ log_info(
228
+ "Configuration saved successfully",
229
+ user=username,
230
+ last_update=config.last_update_date
231
+ )
232
+
233
+ except Exception as e:
234
+ log_error(f"Failed to save config", error=str(e))
235
+ raise ConfigurationError(
236
+ f"Failed to save configuration: {str(e)}",
237
+ config_key="service_config.jsonc"
238
+ )
239
+
240
+ # ===================== Environment Methods =====================
241
+
242
+ @classmethod
243
+ def update_environment(cls, update_data: dict, username: str) -> None:
244
+ """Update environment configuration"""
245
+ with cls._lock:
246
+ config = cls.get()
247
+
248
+ # Update providers
249
+ if 'llm_provider' in update_data:
250
+ config.global_config.llm_provider = update_data['llm_provider']
251
+
252
+ if 'tts_provider' in update_data:
253
+ config.global_config.tts_provider = update_data['tts_provider']
254
+
255
+ if 'stt_provider' in update_data:
256
+ config.global_config.stt_provider = update_data['stt_provider']
257
+
258
+ # Log activity
259
+ cls._add_activity(
260
+ config, username, "UPDATE_ENVIRONMENT",
261
+ "environment", None,
262
+ f"Updated providers"
263
+ )
264
+
265
+ # Save
266
+ cls.save(config, username)
267
+
268
+ @classmethod
269
+ def _ensure_providers(cls, config_data: Dict[str, Any]) -> None:
270
+ """Ensure config has required provider structure"""
271
+ if 'config' not in config_data:
272
+ config_data['config'] = {}
273
+
274
+ config = config_data['config']
275
+
276
+ # Ensure provider settings exist
277
+ if 'llm_provider' not in config:
278
+ config['llm_provider'] = {
279
+ 'name': 'spark_cloud',
280
+ 'api_key': '',
281
+ 'endpoint': 'http://localhost:8080',
282
+ 'settings': {}
283
+ }
284
+
285
+ if 'tts_provider' not in config:
286
+ config['tts_provider'] = {
287
+ 'name': 'no_tts',
288
+ 'api_key': '',
289
+ 'endpoint': None,
290
+ 'settings': {}
291
+ }
292
+
293
+ if 'stt_provider' not in config:
294
+ config['stt_provider'] = {
295
+ 'name': 'no_stt',
296
+ 'api_key': '',
297
+ 'endpoint': None,
298
+ 'settings': {}
299
+ }
300
+
301
+ # Ensure providers list exists
302
+ if 'providers' not in config:
303
+ config['providers'] = [
304
+ {
305
+ "type": "llm",
306
+ "name": "spark_cloud",
307
+ "display_name": "Spark LLM (Cloud)",
308
+ "requires_endpoint": True,
309
+ "requires_api_key": True,
310
+ "requires_repo_info": False,
311
+ "description": "Spark Cloud LLM Service"
312
+ },
313
+ {
314
+ "type": "tts",
315
+ "name": "no_tts",
316
+ "display_name": "No TTS",
317
+ "requires_endpoint": False,
318
+ "requires_api_key": False,
319
+ "requires_repo_info": False,
320
+ "description": "Text-to-Speech disabled"
321
+ },
322
+ {
323
+ "type": "stt",
324
+ "name": "no_stt",
325
+ "display_name": "No STT",
326
+ "requires_endpoint": False,
327
+ "requires_api_key": False,
328
+ "requires_repo_info": False,
329
+ "description": "Speech-to-Text disabled"
330
+ }
331
+ ]
332
+
333
+ # ===================== Project Methods =====================
334
+
335
+ @classmethod
336
+ def get_project(cls, project_id: int) -> Optional[ProjectConfig]:
337
+ """Get project by ID"""
338
+ config = cls.get()
339
+ return next((p for p in config.projects if p.id == project_id), None)
340
+
341
+ @classmethod
342
+ def create_project(cls, project_data: dict, username: str) -> ProjectConfig:
343
+ """Create new project with initial version"""
344
+ with cls._lock:
345
+ config = cls.get()
346
+
347
+ # Check for duplicate name
348
+ existing_project = next((p for p in config.projects if p.name == project_data['name'] and not p.deleted), None)
349
+ if existing_project:
350
+ raise DuplicateResourceError("Project", project_data['name'])
351
+
352
+
353
+ # Create project
354
+ project = ProjectConfig(
355
+ id=config.project_id_counter,
356
+ created_date=get_current_timestamp(),
357
+ created_by=username,
358
+ version_id_counter=1, # Başlangıç değeri
359
+ versions=[], # Boş başla
360
+ **project_data
361
+ )
362
+
363
+ # Create initial version with proper models
364
+ initial_version = VersionConfig(
365
+ no=1,
366
+ caption="Initial version",
367
+ description="Auto-generated initial version",
368
+ published=False, # Explicitly set to False
369
+ deleted=False,
370
+ general_prompt="You are a helpful assistant.",
371
+ welcome_prompt=None,
372
+ llm=LLMConfiguration(
373
+ repo_id="ytu-ce-cosmos/Turkish-Llama-8b-Instruct-v0.1",
374
+ generation_config=GenerationConfig(
375
+ max_new_tokens=512,
376
+ temperature=0.7,
377
+ top_p=0.9,
378
+ repetition_penalty=1.1,
379
+ do_sample=True
380
+ ),
381
+ use_fine_tune=False,
382
+ fine_tune_zip=""
383
+ ),
384
+ intents=[],
385
+ created_date=get_current_timestamp(),
386
+ created_by=username,
387
+ last_update_date=None,
388
+ last_update_user=None,
389
+ publish_date=None,
390
+ published_by=None
391
+ )
392
+
393
+ # Add initial version to project
394
+ project.versions.append(initial_version)
395
+ project.version_id_counter = 2 # Next version will be 2
396
+
397
+ # Update config
398
+ config.projects.append(project)
399
+ config.project_id_counter += 1
400
+
401
+ # Log activity
402
+ cls._add_activity(
403
+ config, username, "CREATE_PROJECT",
404
+ "project", project.name,
405
+ f"Created with initial version"
406
+ )
407
+
408
+ # Save
409
+ cls.save(config, username)
410
+
411
+ log_info(
412
+ "Project created with initial version",
413
+ project_id=project.id,
414
+ name=project.name,
415
+ user=username
416
+ )
417
+
418
+ return project
419
+
420
+ @classmethod
421
+ def update_project(cls, project_id: int, update_data: dict, username: str, expected_last_update: Optional[str] = None) -> ProjectConfig:
422
+ """Update project with optimistic locking"""
423
+ with cls._lock:
424
+ config = cls.get()
425
+ project = cls.get_project(project_id)
426
+
427
+ if not project:
428
+ raise ResourceNotFoundError("project", project_id)
429
+
430
+ # Check race condition
431
+ if expected_last_update is not None and expected_last_update != '':
432
+ if project.last_update_date and not timestamps_equal(expected_last_update, project.last_update_date):
433
+ raise RaceConditionError(
434
+ f"Project '{project.name}' was modified by another user",
435
+ current_user=username,
436
+ last_update_user=project.last_update_user,
437
+ last_update_date=project.last_update_date,
438
+ entity_type="project",
439
+ entity_id=project_id
440
+ )
441
+
442
+ # Update fields
443
+ for key, value in update_data.items():
444
+ if hasattr(project, key) and key not in ['id', 'created_date', 'created_by', 'last_update_date', 'last_update_user']:
445
+ setattr(project, key, value)
446
+
447
+ project.last_update_date = get_current_timestamp()
448
+ project.last_update_user = username
449
+
450
+ cls._add_activity(
451
+ config, username, "UPDATE_PROJECT",
452
+ "project", project.name
453
+ )
454
+
455
+ # Save
456
+ cls.save(config, username)
457
+
458
+ log_info(
459
+ "Project updated",
460
+ project_id=project.id,
461
+ user=username
462
+ )
463
+
464
+ return project
465
+
466
+ @classmethod
467
+ def delete_project(cls, project_id: int, username: str) -> None:
468
+ """Soft delete project"""
469
+ with cls._lock:
470
+ config = cls.get()
471
+ project = cls.get_project(project_id)
472
+
473
+ if not project:
474
+ raise ResourceNotFoundError("project", project_id)
475
+
476
+ project.deleted = True
477
+ project.last_update_date = get_current_timestamp()
478
+ project.last_update_user = username
479
+
480
+ cls._add_activity(
481
+ config, username, "DELETE_PROJECT",
482
+ "project", project.name
483
+ )
484
+
485
+ # Save
486
+ cls.save(config, username)
487
+
488
+ log_info(
489
+ "Project deleted",
490
+ project_id=project.id,
491
+ user=username
492
+ )
493
+
494
+ @classmethod
495
+ def toggle_project(cls, project_id: int, username: str) -> bool:
496
+ """Toggle project enabled status"""
497
+ with cls._lock:
498
+ config = cls.get()
499
+ project = cls.get_project(project_id)
500
+
501
+ if not project:
502
+ raise ResourceNotFoundError("project", project_id)
503
+
504
+ project.enabled = not project.enabled
505
+ project.last_update_date = get_current_timestamp()
506
+ project.last_update_user = username
507
+
508
+ # Log activity
509
+ cls._add_activity(
510
+ config, username, "TOGGLE_PROJECT",
511
+ "project", project.name,
512
+ f"{'Enabled' if project.enabled else 'Disabled'}"
513
+ )
514
+
515
+ # Save
516
+ cls.save(config, username)
517
+
518
+ log_info(
519
+ "Project toggled",
520
+ project_id=project.id,
521
+ enabled=project.enabled,
522
+ user=username
523
+ )
524
+
525
+ return project.enabled
526
+
527
+ # ===================== Version Methods =====================
528
+
529
+ @classmethod
530
+ def create_version(cls, project_id: int, version_data: dict, username: str) -> VersionConfig:
531
+ """Create new version"""
532
+ with cls._lock:
533
+ config = cls.get()
534
+ project = cls.get_project(project_id)
535
+
536
+ if not project:
537
+ raise ResourceNotFoundError("project", project_id)
538
+
539
+ # Handle source version copy
540
+ if 'source_version_no' in version_data and version_data['source_version_no']:
541
+ source_version = next((v for v in project.versions if v.no == version_data['source_version_no']), None)
542
+ if source_version:
543
+ # Copy from source version
544
+ version_dict = source_version.model_dump()
545
+ # Remove fields that shouldn't be copied
546
+ for field in ['no', 'created_date', 'created_by', 'published', 'publish_date',
547
+ 'published_by', 'last_update_date', 'last_update_user']:
548
+ version_dict.pop(field, None)
549
+ # Override with provided data
550
+ version_dict['caption'] = version_data.get('caption', f"Copy of {source_version.caption}")
551
+ else:
552
+ # Source not found, create blank
553
+ version_dict = {
554
+ 'caption': version_data.get('caption', 'New Version'),
555
+ 'general_prompt': '',
556
+ 'welcome_prompt': None,
557
+ 'llm': {
558
+ 'repo_id': '',
559
+ 'generation_config': {
560
+ 'max_new_tokens': 512,
561
+ 'temperature': 0.7,
562
+ 'top_p': 0.95,
563
+ 'repetition_penalty': 1.1
564
+ },
565
+ 'use_fine_tune': False,
566
+ 'fine_tune_zip': ''
567
+ },
568
+ 'intents': []
569
+ }
570
+ else:
571
+ # Create blank version
572
+ version_dict = {
573
+ 'caption': version_data.get('caption', 'New Version'),
574
+ 'general_prompt': '',
575
+ 'welcome_prompt': None,
576
+ 'llm': {
577
+ 'repo_id': '',
578
+ 'generation_config': {
579
+ 'max_new_tokens': 512,
580
+ 'temperature': 0.7,
581
+ 'top_p': 0.95,
582
+ 'repetition_penalty': 1.1
583
+ },
584
+ 'use_fine_tune': False,
585
+ 'fine_tune_zip': ''
586
+ },
587
+ 'intents': []
588
+ }
589
+
590
+ # Create version
591
+ version = VersionConfig(
592
+ no=project.version_id_counter,
593
+ published=False, # New versions are always unpublished
594
+ deleted=False,
595
+ created_date=get_current_timestamp(),
596
+ created_by=username,
597
+ last_update_date=None,
598
+ last_update_user=None,
599
+ publish_date=None,
600
+ published_by=None,
601
+ **version_dict
602
+ )
603
+
604
+ # Update project
605
+ project.versions.append(version)
606
+ project.version_id_counter += 1
607
+ project.last_update_date = get_current_timestamp()
608
+ project.last_update_user = username
609
+
610
+ # Log activity
611
+ cls._add_activity(
612
+ config, username, "CREATE_VERSION",
613
+ "version", version.no, f"{project.name} v{version.no}",
614
+ f"Project: {project.name}"
615
+ )
616
+
617
+ # Save
618
+ cls.save(config, username)
619
+
620
+ log_info(
621
+ "Version created",
622
+ project_id=project.id,
623
+ version_no=version.no,
624
+ user=username
625
+ )
626
+
627
+ return version
628
+
629
+ @classmethod
630
+ def publish_version(cls, project_id: int, version_no: int, username: str) -> tuple[ProjectConfig, VersionConfig]:
631
+ """Publish a version"""
632
+ with cls._lock:
633
+ config = cls.get()
634
+ project = cls.get_project(project_id)
635
+
636
+ if not project:
637
+ raise ResourceNotFoundError("project", project_id)
638
+
639
+ version = next((v for v in project.versions if v.no == version_no), None)
640
+ if not version:
641
+ raise ResourceNotFoundError("version", version_no)
642
+
643
+ # Unpublish other versions
644
+ for v in project.versions:
645
+ if v.published and v.no != version_no:
646
+ v.published = False
647
+
648
+ # Publish this version
649
+ version.published = True
650
+ version.publish_date = get_current_timestamp()
651
+ version.published_by = username
652
+
653
+ # Update project
654
+ project.last_update_date = get_current_timestamp()
655
+ project.last_update_user = username
656
+
657
+ # Log activity
658
+ cls._add_activity(
659
+ config, username, "PUBLISH_VERSION",
660
+ "version", f"{project.name} v{version.no}"
661
+ )
662
+
663
+ # Save
664
+ cls.save(config, username)
665
+
666
+ log_info(
667
+ "Version published",
668
+ project_id=project.id,
669
+ version_no=version.no,
670
+ user=username
671
+ )
672
+
673
+ return project, version
674
+
675
+ @classmethod
676
+ def update_version(cls, project_id: int, version_no: int, update_data: dict, username: str, expected_last_update: Optional[str] = None) -> VersionConfig:
677
+ """Update version with optimistic locking"""
678
+ with cls._lock:
679
+ config = cls.get()
680
+ project = cls.get_project(project_id)
681
+
682
+ if not project:
683
+ raise ResourceNotFoundError("project", project_id)
684
+
685
+ version = next((v for v in project.versions if v.no == version_no), None)
686
+ if not version:
687
+ raise ResourceNotFoundError("version", version_no)
688
+
689
+ # Ensure published is a boolean (safety check)
690
+ if version.published is None:
691
+ version.published = False
692
+
693
+ # Published versions cannot be edited
694
+ if version.published:
695
+ raise ValidationError("Published versions cannot be modified")
696
+
697
+ # Check race condition
698
+ if expected_last_update is not None and expected_last_update != '':
699
+ if version.last_update_date and not timestamps_equal(expected_last_update, version.last_update_date):
700
+ raise RaceConditionError(
701
+ f"Version '{version.no}' was modified by another user",
702
+ current_user=username,
703
+ last_update_user=version.last_update_user,
704
+ last_update_date=version.last_update_date,
705
+ entity_type="version",
706
+ entity_id=f"{project_id}:{version_no}"
707
+ )
708
+
709
+ # Update fields
710
+ for key, value in update_data.items():
711
+ if hasattr(version, key) and key not in ['no', 'created_date', 'created_by', 'published', 'last_update_date']:
712
+ # Handle LLM config
713
+ if key == 'llm' and isinstance(value, dict):
714
+ setattr(version, key, LLMConfiguration(**value))
715
+ # Handle intents
716
+ elif key == 'intents' and isinstance(value, list):
717
+ intents = []
718
+ for intent_data in value:
719
+ if isinstance(intent_data, dict):
720
+ intents.append(IntentConfig(**intent_data))
721
+ else:
722
+ intents.append(intent_data)
723
+ setattr(version, key, intents)
724
+ else:
725
+ setattr(version, key, value)
726
+
727
+ version.last_update_date = get_current_timestamp()
728
+ version.last_update_user = username
729
+
730
+ # Update project last update
731
+ project.last_update_date = get_current_timestamp()
732
+ project.last_update_user = username
733
+
734
+ # Log activity
735
+ cls._add_activity(
736
+ config, username, "UPDATE_VERSION",
737
+ "version", f"{project.name} v{version.no}"
738
+ )
739
+
740
+ # Save
741
+ cls.save(config, username)
742
+
743
+ log_info(
744
+ "Version updated",
745
+ project_id=project.id,
746
+ version_no=version.no,
747
+ user=username
748
+ )
749
+
750
+ return version
751
+
752
+ @classmethod
753
+ def delete_version(cls, project_id: int, version_no: int, username: str) -> None:
754
+ """Soft delete version"""
755
+ with cls._lock:
756
+ config = cls.get()
757
+ project = cls.get_project(project_id)
758
+
759
+ if not project:
760
+ raise ResourceNotFoundError("project", project_id)
761
+
762
+ version = next((v for v in project.versions if v.no == version_no), None)
763
+ if not version:
764
+ raise ResourceNotFoundError("version", version_no)
765
+
766
+ if version.published:
767
+ raise ValidationError("Cannot delete published version")
768
+
769
+ version.deleted = True
770
+ version.last_update_date = get_current_timestamp()
771
+ version.last_update_user = username
772
+
773
+ # Update project
774
+ project.last_update_date = get_current_timestamp()
775
+ project.last_update_user = username
776
+
777
+ # Log activity
778
+ cls._add_activity(
779
+ config, username, "DELETE_VERSION",
780
+ "version", f"{project.name} v{version.no}"
781
+ )
782
+
783
+ # Save
784
+ cls.save(config, username)
785
+
786
+ log_info(
787
+ "Version deleted",
788
+ project_id=project.id,
789
+ version_no=version.no,
790
+ user=username
791
+ )
792
+
793
+ # ===================== API Methods =====================
794
+ @classmethod
795
+ def create_api(cls, api_data: dict, username: str) -> APIConfig:
796
+ """Create new API"""
797
+ with cls._lock:
798
+ config = cls.get()
799
+
800
+ # Check for duplicate name
801
+ existing_api = next((a for a in config.apis if a.name == api_data['name'] and not a.deleted), None)
802
+ if existing_api:
803
+ raise DuplicateResourceError("API", api_data['name'])
804
+
805
+ # Create API
806
+ api = APIConfig(
807
+ created_date=get_current_timestamp(),
808
+ created_by=username,
809
+ **api_data
810
+ )
811
+
812
+ # Add to config
813
+ config.apis.append(api)
814
+
815
+ # Rebuild index
816
+ config.build_index()
817
+
818
+ # Log activity
819
+ cls._add_activity(
820
+ config, username, "CREATE_API",
821
+ "api", api.name
822
+ )
823
+
824
+ # Save
825
+ cls.save(config, username)
826
+
827
+ log_info(
828
+ "API created",
829
+ api_name=api.name,
830
+ user=username
831
+ )
832
+
833
+ return api
834
+
835
+ @classmethod
836
+ def update_api(cls, api_name: str, update_data: dict, username: str, expected_last_update: Optional[str] = None) -> APIConfig:
837
+ """Update API with optimistic locking"""
838
+ with cls._lock:
839
+ config = cls.get()
840
+ api = config.get_api(api_name)
841
+
842
+ if not api:
843
+ raise ResourceNotFoundError("api", api_name)
844
+
845
+ # Check race condition
846
+ if expected_last_update is not None and expected_last_update != '':
847
+ if api.last_update_date and not timestamps_equal(expected_last_update, api.last_update_date):
848
+ raise RaceConditionError(
849
+ f"API '{api.name}' was modified by another user",
850
+ current_user=username,
851
+ last_update_user=api.last_update_user,
852
+ last_update_date=api.last_update_date,
853
+ entity_type="api",
854
+ entity_id=api.name
855
+ )
856
+
857
+ # Update fields
858
+ for key, value in update_data.items():
859
+ if hasattr(api, key) and key not in ['name', 'created_date', 'created_by', 'last_update_date']:
860
+ setattr(api, key, value)
861
+
862
+ api.last_update_date = get_current_timestamp()
863
+ api.last_update_user = username
864
+
865
+ # Rebuild index
866
+ config.build_index()
867
+
868
+ # Log activity
869
+ cls._add_activity(
870
+ config, username, "UPDATE_API",
871
+ "api", api.name
872
+ )
873
+
874
+ # Save
875
+ cls.save(config, username)
876
+
877
+ log_info(
878
+ "API updated",
879
+ api_name=api.name,
880
+ user=username
881
+ )
882
+
883
+ return api
884
+
885
+ @classmethod
886
+ def delete_api(cls, api_name: str, username: str) -> None:
887
+ """Soft delete API"""
888
+ with cls._lock:
889
+ config = cls.get()
890
+ api = config.get_api(api_name)
891
+
892
+ if not api:
893
+ raise ResourceNotFoundError("api", api_name)
894
+
895
+ api.deleted = True
896
+ api.last_update_date = get_current_timestamp()
897
+ api.last_update_user = username
898
+
899
+ # Rebuild index
900
+ config.build_index()
901
+
902
+ # Log activity
903
+ cls._add_activity(
904
+ config, username, "DELETE_API",
905
+ "api", api.name
906
+ )
907
+
908
+ # Save
909
+ cls.save(config, username)
910
+
911
+ log_info(
912
+ "API deleted",
913
+ api_name=api.name,
914
+ user=username
915
+ )
916
+
917
+ # ===================== Activity Methods =====================
918
+ @classmethod
919
+ def _add_activity(
920
+ cls,
921
+ config: ServiceConfig,
922
+ username: str,
923
+ action: str,
924
+ entity_type: str,
925
+ entity_name: Optional[str] = None,
926
+ details: Optional[str] = None
927
+ ) -> None:
928
+ """Add activity log entry"""
929
+ # Activity ID'sini oluştur - mevcut en yüksek ID'yi bul
930
+ max_id = 0
931
+ if config.activity_log:
932
+ max_id = max((entry.id for entry in config.activity_log if entry.id), default=0)
933
+
934
+ activity_id = max_id + 1
935
+
936
+ activity = ActivityLogEntry(
937
+ id=activity_id,
938
+ timestamp=get_current_timestamp(),
939
+ username=username,
940
+ action=action,
941
+ entity_type=entity_type,
942
+ entity_name=entity_name,
943
+ details=details
944
+ )
945
+
946
+ config.activity_log.append(activity)
947
+
948
+ # Keep only last 1000 entries
949
+ if len(config.activity_log) > 1000:
950
  config.activity_log = config.activity_log[-1000:]