ciyidogan commited on
Commit
c86c3d6
·
verified ·
1 Parent(s): 703632b

Update config_provider.py

Browse files
Files changed (1) hide show
  1. config_provider.py +670 -192
config_provider.py CHANGED
@@ -1,21 +1,21 @@
1
  """
2
- Flare – ConfigProvider
3
  """
4
 
5
  from __future__ import annotations
6
  import json, os, threading
7
  from pathlib import Path
8
  from typing import Any, Dict, List, Optional
 
9
  import commentjson
10
- from datetime import datetime, timezone
11
 
12
  from pydantic import BaseModel, Field, HttpUrl, ValidationError
13
  from utils import log
14
  from encryption_utils import decrypt
15
 
16
- # ============== Provider Configs ==============
17
  class ProviderConfig(BaseModel):
18
- """Configuration for available providers (LLM, TTS, STT)"""
19
  type: str = Field(..., pattern=r"^(llm|tts|stt)$")
20
  name: str
21
  display_name: str
@@ -25,38 +25,42 @@ class ProviderConfig(BaseModel):
25
  description: Optional[str] = None
26
 
27
  class ProviderSettings(BaseModel):
28
- """Provider-specific settings"""
29
  name: str
30
  api_key: Optional[str] = None
31
  endpoint: Optional[str] = None
32
  settings: Dict[str, Any] = Field(default_factory=dict)
33
 
34
- # ============== Parameter Collection ==============
35
- class ParameterCollectionConfig(BaseModel):
36
- """Configuration for parameter collection behavior"""
37
- max_params_per_question: int = Field(2, ge=1, le=5)
38
- retry_unanswered: bool = True
39
- collection_prompt: str = ""
40
-
41
- class Config:
42
- extra = "allow"
43
 
44
- # ============== Global Config ==============
45
  class GlobalConfig(BaseModel):
46
- # Provider configurations
47
  llm_provider: ProviderSettings
48
- tts_provider: ProviderSettings
49
- stt_provider: ProviderSettings
50
 
51
  # Available providers
52
- providers: List[ProviderConfig] = Field(default_factory=list)
53
 
54
- # Users
55
  users: List["UserConfig"] = []
56
 
 
57
  def get_provider_config(self, provider_type: str, provider_name: str) -> Optional[ProviderConfig]:
58
- """Get configuration for a specific provider"""
59
- return next((p for p in self.providers if p.type == provider_type and p.name == provider_name), None)
 
 
 
60
 
61
  def get_providers_by_type(self, provider_type: str) -> List[ProviderConfig]:
62
  """Get all providers of a specific type"""
@@ -64,31 +68,31 @@ class GlobalConfig(BaseModel):
64
 
65
  def get_plain_api_key(self, provider_type: str) -> Optional[str]:
66
  """Get decrypted API key for a provider type"""
67
- provider = getattr(self, f"{provider_type}_provider", None)
68
- if not provider or not provider.api_key:
69
- return None
70
-
71
- raw_key = provider.api_key
72
- # If it starts with enc:, decrypt it
73
- if raw_key.startswith("enc:"):
74
- log(f"🔓 Decrypting {provider_type} API key...")
75
- decrypted = decrypt(raw_key)
76
- log(f"🔓 {provider_type} key decrypted: {'***' + decrypted[-4:] if decrypted else 'None'}")
77
- return decrypted
78
-
79
- log(f"🔑 {provider_type} key/path: {'***' + raw_key[-4:] if raw_key else 'None'}")
80
- return raw_key
81
 
82
- class Config:
83
- extra = "allow"
 
 
 
 
 
 
84
 
85
- # ============== User Config ==============
86
  class UserConfig(BaseModel):
87
  username: str
88
  password_hash: str
89
  salt: str
90
 
91
- # ============== Retry / Proxy ==============
92
  class RetryConfig(BaseModel):
93
  retry_count: int = Field(3, alias="max_attempts")
94
  backoff_seconds: int = 2
@@ -98,7 +102,6 @@ class ProxyConfig(BaseModel):
98
  enabled: bool = True
99
  url: HttpUrl
100
 
101
- # ============== API & Auth ==============
102
  class APIAuthConfig(BaseModel):
103
  enabled: bool = False
104
  token_endpoint: Optional[HttpUrl] = None
@@ -122,24 +125,21 @@ class APIConfig(BaseModel):
122
  proxy: Optional[str | ProxyConfig] = None
123
  auth: Optional[APIAuthConfig] = None
124
  response_prompt: Optional[str] = None
 
 
 
 
 
 
125
 
126
  class Config:
127
  extra = "allow"
128
  populate_by_name = True
129
 
130
- # ============== Localized Content ==============
131
- class LocalizedCaption(BaseModel):
132
- locale_code: str
133
- caption: str
134
-
135
- class LocalizedExample(BaseModel):
136
- locale_code: str
137
- example: str
138
-
139
- # ============== Intent / Param ==============
140
  class ParameterConfig(BaseModel):
141
  name: str
142
- caption: List[LocalizedCaption] # Multi-language captions
143
  type: str = Field(..., pattern=r"^(int|float|bool|str|string|date)$")
144
  required: bool = True
145
  variable_name: str
@@ -147,6 +147,13 @@ class ParameterConfig(BaseModel):
147
  validation_regex: Optional[str] = None
148
  invalid_prompt: Optional[str] = None
149
  type_error_prompt: Optional[str] = None
 
 
 
 
 
 
 
150
 
151
  def get_caption_for_locale(self, locale_code: str, default_locale: str = "tr") -> str:
152
  """Get caption for specific locale with fallback"""
@@ -155,24 +162,24 @@ class ParameterConfig(BaseModel):
155
  if caption:
156
  return caption
157
 
 
 
 
 
 
 
158
  # Try default locale
159
- caption = next((c.caption for c in self.caption if c.locale_code == default_locale), None)
160
  if caption:
161
  return caption
162
 
163
- # Return first available or parameter name
164
  return self.caption[0].caption if self.caption else self.name
165
 
166
- def canonical_type(self) -> str:
167
- if self.type == "string":
168
- return "str"
169
- elif self.type == "date":
170
- return "str" # Store dates as strings in ISO format
171
- return self.type
172
-
173
  class IntentConfig(BaseModel):
174
  name: str
175
  caption: Optional[str] = ""
 
176
  dependencies: List[str] = []
177
  examples: List[LocalizedExample] = [] # Multi-language examples
178
  detection_prompt: Optional[str] = None
@@ -180,15 +187,27 @@ class IntentConfig(BaseModel):
180
  action: str
181
  fallback_timeout_prompt: Optional[str] = None
182
  fallback_error_prompt: Optional[str] = None
183
-
184
- def get_examples_for_locale(self, locale_code: str) -> List[str]:
185
- """Get examples for specific locale"""
186
- return [ex.example for ex in self.examples if ex.locale_code == locale_code]
187
 
188
  class Config:
189
  extra = "allow"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
 
191
- # ============== Version / Project ==============
192
  class LLMConfig(BaseModel):
193
  repo_id: str
194
  generation_config: Dict[str, Any] = {}
@@ -197,11 +216,20 @@ class LLMConfig(BaseModel):
197
 
198
  class VersionConfig(BaseModel):
199
  id: int = Field(..., alias="version_number")
 
200
  caption: Optional[str] = ""
 
201
  published: bool = False
 
 
 
 
 
 
 
202
  general_prompt: str
203
- llm: Optional["LLMConfig"] = None # Optional based on provider
204
- intents: List["IntentConfig"]
205
 
206
  class Config:
207
  extra = "allow"
@@ -211,20 +239,47 @@ class ProjectConfig(BaseModel):
211
  id: Optional[int] = None
212
  name: str
213
  caption: Optional[str] = ""
 
 
214
  enabled: bool = True
215
- default_locale: str = "tr" # Changed from default_language
216
- supported_locales: List[str] = ["tr"] # Changed from supported_languages
217
  last_version_number: Optional[int] = None
 
218
  versions: List[VersionConfig]
 
 
 
 
 
 
 
 
 
 
219
 
220
  class Config:
221
  extra = "allow"
222
 
223
- # ============== Service Config ==============
 
 
 
 
 
 
 
 
 
 
224
  class ServiceConfig(BaseModel):
225
  global_config: GlobalConfig = Field(..., alias="config")
226
  projects: List[ProjectConfig]
227
  apis: List[APIConfig]
 
 
 
 
 
 
228
 
229
  # runtime helpers (skip validation)
230
  _api_by_name: Dict[str, APIConfig] = {}
@@ -232,14 +287,50 @@ class ServiceConfig(BaseModel):
232
  def build_index(self):
233
  self._api_by_name = {a.name: a for a in self.apis}
234
 
235
- def get_api(self, name: str) -> APIConfig | None:
236
  return self._api_by_name.get(name)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
 
238
- # ============== Provider Singleton ==============
239
  class ConfigProvider:
240
  _instance: Optional[ServiceConfig] = None
241
  _CONFIG_PATH = Path(__file__).parent / "service_config.jsonc"
242
  _lock = threading.Lock()
 
243
 
244
  @classmethod
245
  def get(cls) -> ServiceConfig:
@@ -250,6 +341,10 @@ class ConfigProvider:
250
  if cls._instance is None:
251
  cls._instance = cls._load()
252
  cls._instance.build_index()
 
 
 
 
253
  return cls._instance
254
 
255
  @classmethod
@@ -260,21 +355,6 @@ class ConfigProvider:
260
  cls._instance = None
261
  return cls.get()
262
 
263
- @classmethod
264
- def update_config(cls, config_dict: dict):
265
- """Update the current configuration with new values"""
266
- if cls._instance is None:
267
- cls.get()
268
-
269
- # Update global config
270
- if 'config' in config_dict:
271
- for key, value in config_dict['config'].items():
272
- if hasattr(cls._instance.global_config, key):
273
- setattr(cls._instance.global_config, key, value)
274
-
275
- # Save to file
276
- cls._instance.save()
277
-
278
  @classmethod
279
  def _load(cls) -> ServiceConfig:
280
  """Load configuration from service_config.jsonc"""
@@ -291,134 +371,532 @@ class ConfigProvider:
291
  if 'config' not in config_data:
292
  config_data['config'] = {}
293
 
294
- config_section = config_data['config']
295
-
296
- # Set defaults for missing fields if needed
297
- if 'llm_provider' not in config_section:
298
- config_section['llm_provider'] = {
299
- "name": "spark",
300
- "api_key": None,
301
- "endpoint": "http://localhost:7861",
302
- "settings": {}
303
- }
304
-
305
- if 'tts_provider' not in config_section:
306
- config_section['tts_provider'] = {
307
- "name": "no_tts",
308
- "api_key": None,
309
- "endpoint": None,
310
- "settings": {}
311
- }
312
-
313
- if 'stt_provider' not in config_section:
314
- config_section['stt_provider'] = {
315
- "name": "no_stt",
316
- "api_key": None,
317
- "endpoint": None,
318
- "settings": {}
319
- }
320
-
321
- if 'providers' not in config_section:
322
- config_section['providers'] = []
323
-
324
- config_section.setdefault('users', [])
325
-
326
- # Convert string body/headers to dict if needed
327
- for api in config_data.get('apis', []):
328
- if isinstance(api.get('headers'), str):
329
- try:
330
- api['headers'] = json.loads(api['headers'])
331
- except:
332
- api['headers'] = {}
333
-
334
- if isinstance(api.get('body_template'), str):
335
- try:
336
- api['body_template'] = json.loads(api['body_template'])
337
- except:
338
- api['body_template'] = {}
339
-
340
- # Handle auth section
341
- if api.get('auth'):
342
- auth = api['auth']
343
- if isinstance(auth.get('token_request_body'), str):
344
  try:
345
- auth['token_request_body'] = json.loads(auth['token_request_body'])
346
  except:
347
- auth['token_request_body'] = {}
348
- if isinstance(auth.get('token_refresh_body'), str):
 
349
  try:
350
- auth['token_refresh_body'] = json.loads(auth['token_refresh_body'])
351
  except:
352
- auth['token_refresh_body'] = {}
 
 
 
 
 
 
 
 
353
 
354
- # Parse and validate
355
  cfg = ServiceConfig.model_validate(config_data)
356
-
357
  log("✅ Configuration loaded successfully")
358
  return cfg
359
 
360
- except FileNotFoundError:
361
- log(f"❌ Config file not found: {cls._CONFIG_PATH}")
362
- raise
363
- except (commentjson.JSONLibraryException, ValidationError) as e:
364
- log(f"❌ Config parsing/validation error: {e}")
365
- raise
366
  except Exception as e:
367
- log(f"❌ Unexpected error loading config: {e}")
368
  raise
369
 
370
- # ============== Environment Methods ==============
371
  @classmethod
372
- def update_environment(cls, env_data: dict, username: str):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373
  """Update environment configuration"""
374
  if cls._instance is None:
375
  cls.get()
376
 
377
- cfg = cls._instance
378
- timestamp = _get_iso_timestamp()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379
 
380
- # Update provider configurations
381
- if 'llm_provider' in env_data:
382
- cfg.global_config.llm_provider = ProviderSettings(**env_data['llm_provider'])
383
- if 'tts_provider' in env_data:
384
- cfg.global_config.tts_provider = ProviderSettings(**env_data['tts_provider'])
385
- if 'stt_provider' in env_data:
386
- cfg.global_config.stt_provider = ProviderSettings(**env_data['stt_provider'])
387
 
388
  # Save
389
- cls._save()
390
 
391
- # Log activity
392
- cls._log_activity("UPDATE_ENVIRONMENT", "environment", username=username)
393
 
394
- # ============== Save Method ==============
395
  @classmethod
396
- def _save(cls):
397
- """Save current config to file"""
398
  if cls._instance is None:
399
- return
400
 
401
- with cls._lock:
402
- config_dict = {
403
- "config": cls._instance.global_config.model_dump(exclude_none=True),
404
- "projects": [p.model_dump(by_alias=True) for p in cls._instance.projects],
405
- "apis": [a.model_dump(by_alias=True) for a in cls._instance.apis]
406
- }
407
-
408
- # Write to file
409
- with open(cls._CONFIG_PATH, 'w', encoding='utf-8') as f:
410
- f.write(commentjson.dumps(config_dict, indent=2, ensure_ascii=False))
411
-
412
- log("💾 Configuration saved")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
413
 
414
- # ============== Activity Logging ==============
415
  @classmethod
416
- def _log_activity(cls, action: str, entity_type: str, entity_id: Any = None,
417
- entity_name: str = None, username: str = "system", details: str = None):
418
- """Log activity - placeholder for future implementation"""
419
- log(f"📝 Activity: {action} on {entity_type} by {username}")
420
-
421
- # ============== Helper Functions ==============
422
- def _get_iso_timestamp() -> str:
423
- """Get current timestamp in ISO format"""
424
- return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
+ Flare – ConfigProvider (with Provider Abstraction and Multi-language Support)
3
  """
4
 
5
  from __future__ import annotations
6
  import json, os, threading
7
  from pathlib import Path
8
  from typing import Any, Dict, List, Optional
9
+ from datetime import datetime
10
  import commentjson
 
11
 
12
  from pydantic import BaseModel, Field, HttpUrl, ValidationError
13
  from utils import log
14
  from encryption_utils import decrypt
15
 
16
+ # ===================== New Provider Classes =====================
17
  class ProviderConfig(BaseModel):
18
+ """Provider definition with requirements"""
19
  type: str = Field(..., pattern=r"^(llm|tts|stt)$")
20
  name: str
21
  display_name: str
 
25
  description: Optional[str] = None
26
 
27
  class ProviderSettings(BaseModel):
28
+ """Runtime provider settings"""
29
  name: str
30
  api_key: Optional[str] = None
31
  endpoint: Optional[str] = None
32
  settings: Dict[str, Any] = Field(default_factory=dict)
33
 
34
+ class LocalizedCaption(BaseModel):
35
+ """Multi-language caption support"""
36
+ locale_code: str
37
+ caption: str
38
+
39
+ class LocalizedExample(BaseModel):
40
+ """Multi-language example support"""
41
+ locale_code: str
42
+ example: str
43
 
44
+ # ===================== Global Configuration =====================
45
  class GlobalConfig(BaseModel):
46
+ # Provider settings (replaces work_mode, cloud_token, spark_endpoint)
47
  llm_provider: ProviderSettings
48
+ tts_provider: ProviderSettings = ProviderSettings(name="no_tts")
49
+ stt_provider: ProviderSettings = ProviderSettings(name="no_stt")
50
 
51
  # Available providers
52
+ providers: List[ProviderConfig] = []
53
 
54
+ # User management
55
  users: List["UserConfig"] = []
56
 
57
+ # Helper methods for providers
58
  def get_provider_config(self, provider_type: str, provider_name: str) -> Optional[ProviderConfig]:
59
+ """Get provider configuration by type and name"""
60
+ return next(
61
+ (p for p in self.providers if p.type == provider_type and p.name == provider_name),
62
+ None
63
+ )
64
 
65
  def get_providers_by_type(self, provider_type: str) -> List[ProviderConfig]:
66
  """Get all providers of a specific type"""
 
68
 
69
  def get_plain_api_key(self, provider_type: str) -> Optional[str]:
70
  """Get decrypted API key for a provider type"""
71
+ provider_map = {
72
+ "llm": self.llm_provider,
73
+ "tts": self.tts_provider,
74
+ "stt": self.stt_provider
75
+ }
76
+ provider = provider_map.get(provider_type)
77
+ if provider and provider.api_key:
78
+ return decrypt(provider.api_key) if provider.api_key else None
79
+ return None
 
 
 
 
 
80
 
81
+ # Backward compatibility helpers
82
+ def is_cloud_mode(self) -> bool:
83
+ """Check if running in cloud mode (HuggingFace)"""
84
+ return bool(os.environ.get("SPACE_ID"))
85
+
86
+ def is_gpt_mode(self) -> bool:
87
+ """Check if using GPT provider"""
88
+ return self.llm_provider.name.startswith("gpt4o")
89
 
90
+ # ===================== Other Config Classes =====================
91
  class UserConfig(BaseModel):
92
  username: str
93
  password_hash: str
94
  salt: str
95
 
 
96
  class RetryConfig(BaseModel):
97
  retry_count: int = Field(3, alias="max_attempts")
98
  backoff_seconds: int = 2
 
102
  enabled: bool = True
103
  url: HttpUrl
104
 
 
105
  class APIAuthConfig(BaseModel):
106
  enabled: bool = False
107
  token_endpoint: Optional[HttpUrl] = None
 
125
  proxy: Optional[str | ProxyConfig] = None
126
  auth: Optional[APIAuthConfig] = None
127
  response_prompt: Optional[str] = None
128
+ response_mappings: List[Dict[str, Any]] = []
129
+ deleted: bool = False
130
+ last_update_date: Optional[str] = None
131
+ last_update_user: Optional[str] = None
132
+ created_date: Optional[str] = None
133
+ created_by: Optional[str] = None
134
 
135
  class Config:
136
  extra = "allow"
137
  populate_by_name = True
138
 
139
+ # ===================== Intent / Parameter =====================
 
 
 
 
 
 
 
 
 
140
  class ParameterConfig(BaseModel):
141
  name: str
142
+ caption: List[LocalizedCaption] = [] # Multi-language captions
143
  type: str = Field(..., pattern=r"^(int|float|bool|str|string|date)$")
144
  required: bool = True
145
  variable_name: str
 
147
  validation_regex: Optional[str] = None
148
  invalid_prompt: Optional[str] = None
149
  type_error_prompt: Optional[str] = None
150
+
151
+ def canonical_type(self) -> str:
152
+ if self.type == "string":
153
+ return "str"
154
+ elif self.type == "date":
155
+ return "str" # Store dates as strings in ISO format
156
+ return self.type
157
 
158
  def get_caption_for_locale(self, locale_code: str, default_locale: str = "tr") -> str:
159
  """Get caption for specific locale with fallback"""
 
162
  if caption:
163
  return caption
164
 
165
+ # Try language code only (e.g., "tr" from "tr-TR")
166
+ lang_code = locale_code.split("-")[0]
167
+ caption = next((c.caption for c in self.caption if c.locale_code.startswith(lang_code)), None)
168
+ if caption:
169
+ return caption
170
+
171
  # Try default locale
172
+ caption = next((c.caption for c in self.caption if c.locale_code.startswith(default_locale)), None)
173
  if caption:
174
  return caption
175
 
176
+ # Return first available or name
177
  return self.caption[0].caption if self.caption else self.name
178
 
 
 
 
 
 
 
 
179
  class IntentConfig(BaseModel):
180
  name: str
181
  caption: Optional[str] = ""
182
+ # Removed locale field - will use project's locale settings
183
  dependencies: List[str] = []
184
  examples: List[LocalizedExample] = [] # Multi-language examples
185
  detection_prompt: Optional[str] = None
 
187
  action: str
188
  fallback_timeout_prompt: Optional[str] = None
189
  fallback_error_prompt: Optional[str] = None
 
 
 
 
190
 
191
  class Config:
192
  extra = "allow"
193
+
194
+ def get_examples_for_locale(self, locale_code: str) -> List[str]:
195
+ """Get examples for specific locale"""
196
+ # Try exact match
197
+ examples = [e.example for e in self.examples if e.locale_code == locale_code]
198
+ if examples:
199
+ return examples
200
+
201
+ # Try language code only
202
+ lang_code = locale_code.split("-")[0]
203
+ examples = [e.example for e in self.examples if e.locale_code.startswith(lang_code)]
204
+ if examples:
205
+ return examples
206
+
207
+ # Return all examples if no locale match
208
+ return [e.example for e in self.examples]
209
 
210
+ # ===================== Version / Project =====================
211
  class LLMConfig(BaseModel):
212
  repo_id: str
213
  generation_config: Dict[str, Any] = {}
 
216
 
217
  class VersionConfig(BaseModel):
218
  id: int = Field(..., alias="version_number")
219
+ no: Optional[int] = None
220
  caption: Optional[str] = ""
221
+ description: Optional[str] = ""
222
  published: bool = False
223
+ deleted: bool = False
224
+ created_date: Optional[str] = None
225
+ created_by: Optional[str] = None
226
+ last_update_date: Optional[str] = None
227
+ last_update_user: Optional[str] = None
228
+ publish_date: Optional[str] = None
229
+ published_by: Optional[str] = None
230
  general_prompt: str
231
+ llm: LLMConfig
232
+ intents: List[IntentConfig]
233
 
234
  class Config:
235
  extra = "allow"
 
239
  id: Optional[int] = None
240
  name: str
241
  caption: Optional[str] = ""
242
+ icon: Optional[str] = "folder"
243
+ description: Optional[str] = ""
244
  enabled: bool = True
 
 
245
  last_version_number: Optional[int] = None
246
+ version_id_counter: int = 1
247
  versions: List[VersionConfig]
248
+ # Language settings - changed from default_language/supported_languages
249
+ default_locale: str = "tr"
250
+ supported_locales: List[str] = ["tr"]
251
+ timezone: Optional[str] = "Europe/Istanbul"
252
+ region: Optional[str] = "tr-TR"
253
+ deleted: bool = False
254
+ created_date: Optional[str] = None
255
+ created_by: Optional[str] = None
256
+ last_update_date: Optional[str] = None
257
+ last_update_user: Optional[str] = None
258
 
259
  class Config:
260
  extra = "allow"
261
 
262
+ # ===================== Activity Log =====================
263
+ class ActivityLogEntry(BaseModel):
264
+ timestamp: str
265
+ username: str
266
+ action: str
267
+ entity_type: str
268
+ entity_id: Optional[int] = None
269
+ entity_name: Optional[str] = None
270
+ details: Optional[str] = None
271
+
272
+ # ===================== Service Config =====================
273
  class ServiceConfig(BaseModel):
274
  global_config: GlobalConfig = Field(..., alias="config")
275
  projects: List[ProjectConfig]
276
  apis: List[APIConfig]
277
+ activity_log: List[ActivityLogEntry] = []
278
+
279
+ # Config level fields
280
+ project_id_counter: int = 1
281
+ last_update_date: Optional[str] = None
282
+ last_update_user: Optional[str] = None
283
 
284
  # runtime helpers (skip validation)
285
  _api_by_name: Dict[str, APIConfig] = {}
 
287
  def build_index(self):
288
  self._api_by_name = {a.name: a for a in self.apis}
289
 
290
+ def get_api(self, name: str) -> Optional[APIConfig]:
291
  return self._api_by_name.get(name)
292
+
293
+ def to_jsonc_dict(self) -> dict:
294
+ """Convert to dict for saving to JSONC file"""
295
+ data = self.model_dump(by_alias=True, exclude={'_api_by_name'})
296
+
297
+ # Convert API configs
298
+ for api in data.get('apis', []):
299
+ # Convert headers and body_template to JSON strings if needed
300
+ if 'headers' in api and isinstance(api['headers'], dict) and api['headers']:
301
+ api['headers'] = json.dumps(api['headers'], ensure_ascii=False)
302
+ if 'body_template' in api and isinstance(api['body_template'], dict) and api['body_template']:
303
+ api['body_template'] = json.dumps(api['body_template'], ensure_ascii=False)
304
+
305
+ # Convert auth configs
306
+ if 'auth' in api and api['auth']:
307
+ if 'token_request_body' in api['auth'] and isinstance(api['auth']['token_request_body'], dict):
308
+ api['auth']['body_template'] = api['auth']['token_request_body']
309
+ del api['auth']['token_request_body']
310
+ if 'token_refresh_body' in api['auth'] and isinstance(api['auth']['token_refresh_body'], dict):
311
+ api['auth']['token_refresh_body'] = json.dumps(api['auth']['token_refresh_body'], ensure_ascii=False)
312
+
313
+ return data
314
+
315
+ def save(self):
316
+ """Save configuration to file"""
317
+ config_path = Path(__file__).parent / "service_config.jsonc"
318
+ data = self.to_jsonc_dict()
319
+
320
+ # Pretty print with indentation
321
+ json_str = json.dumps(data, ensure_ascii=False, indent=2)
322
+
323
+ with open(config_path, 'w', encoding='utf-8') as f:
324
+ f.write(json_str)
325
+
326
+ log("✅ Configuration saved to service_config.jsonc")
327
 
328
+ # ===================== Provider Singleton =====================
329
  class ConfigProvider:
330
  _instance: Optional[ServiceConfig] = None
331
  _CONFIG_PATH = Path(__file__).parent / "service_config.jsonc"
332
  _lock = threading.Lock()
333
+ _environment_checked = False
334
 
335
  @classmethod
336
  def get(cls) -> ServiceConfig:
 
341
  if cls._instance is None:
342
  cls._instance = cls._load()
343
  cls._instance.build_index()
344
+ # Environment kontrolünü sadece ilk yüklemede yap
345
+ if not cls._environment_checked:
346
+ cls._check_environment_setup()
347
+ cls._environment_checked = True
348
  return cls._instance
349
 
350
  @classmethod
 
355
  cls._instance = None
356
  return cls.get()
357
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
358
  @classmethod
359
  def _load(cls) -> ServiceConfig:
360
  """Load configuration from service_config.jsonc"""
 
371
  if 'config' not in config_data:
372
  config_data['config'] = {}
373
 
374
+ # Parse API configs specially
375
+ if 'apis' in config_data:
376
+ for api in config_data['apis']:
377
+ # Parse JSON string fields
378
+ if 'headers' in api and isinstance(api['headers'], str):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379
  try:
380
+ api['headers'] = json.loads(api['headers'])
381
  except:
382
+ api['headers'] = {}
383
+
384
+ if 'body_template' in api and isinstance(api['body_template'], str):
385
  try:
386
+ api['body_template'] = json.loads(api['body_template'])
387
  except:
388
+ api['body_template'] = {}
389
+
390
+ # Parse auth configs
391
+ if 'auth' in api and api['auth']:
392
+ if 'body_template' in api['auth'] and isinstance(api['auth']['body_template'], str):
393
+ try:
394
+ api['auth']['token_request_body'] = json.loads(api['auth']['body_template'])
395
+ except:
396
+ api['auth']['token_request_body'] = {}
397
 
398
+ # Load and validate
399
  cfg = ServiceConfig.model_validate(config_data)
 
400
  log("✅ Configuration loaded successfully")
401
  return cfg
402
 
 
 
 
 
 
 
403
  except Exception as e:
404
+ log(f"❌ Error loading config: {e}")
405
  raise
406
 
 
407
  @classmethod
408
+ def _check_environment_setup(cls):
409
+ """Check if environment is properly configured"""
410
+ if not cls._instance:
411
+ return
412
+
413
+ cfg = cls._instance.global_config
414
+
415
+ # Check LLM provider
416
+ if not cfg.llm_provider or not cfg.llm_provider.name:
417
+ log("⚠️ WARNING: No LLM provider configured")
418
+ return
419
+
420
+ provider_config = cfg.get_provider_config("llm", cfg.llm_provider.name)
421
+ if not provider_config:
422
+ log(f"⚠️ WARNING: Unknown LLM provider: {cfg.llm_provider.name}")
423
+ return
424
+
425
+ # Check requirements
426
+ if provider_config.requires_api_key and not cfg.llm_provider.api_key:
427
+ log(f"⚠️ WARNING: {provider_config.display_name} requires API key but none configured")
428
+
429
+ if provider_config.requires_endpoint and not cfg.llm_provider.endpoint:
430
+ log(f"⚠️ WARNING: {provider_config.display_name} requires endpoint but none configured")
431
+
432
+ log(f"✅ LLM Provider: {provider_config.display_name}")
433
+
434
+ # ===================== CRUD Operations =====================
435
+ @classmethod
436
+ def add_activity_log(cls, username: str, action: str, entity_type: str,
437
+ entity_id: Optional[int] = None, entity_name: Optional[str] = None,
438
+ details: Optional[str] = None):
439
+ """Add activity log entry"""
440
+ if cls._instance is None:
441
+ cls.get()
442
+
443
+ entry = ActivityLogEntry(
444
+ timestamp=datetime.now().isoformat() + "Z",
445
+ username=username,
446
+ action=action,
447
+ entity_type=entity_type,
448
+ entity_id=entity_id,
449
+ entity_name=entity_name,
450
+ details=details
451
+ )
452
+
453
+ cls._instance.activity_log.append(entry)
454
+
455
+ # Keep only last 1000 entries
456
+ if len(cls._instance.activity_log) > 1000:
457
+ cls._instance.activity_log = cls._instance.activity_log[-1000:]
458
+
459
+ @classmethod
460
+ def update_environment(cls, update_data: dict, username: str) -> None:
461
  """Update environment configuration"""
462
  if cls._instance is None:
463
  cls.get()
464
 
465
+ config = cls._instance.global_config
466
+
467
+ # Update provider settings
468
+ if 'llm_provider' in update_data:
469
+ llm_data = update_data['llm_provider']
470
+ if 'api_key' in llm_data and llm_data['api_key'] and not llm_data['api_key'].startswith('enc:'):
471
+ from encryption_utils import encrypt
472
+ llm_data['api_key'] = encrypt(llm_data['api_key'])
473
+ config.llm_provider = ProviderSettings(**llm_data)
474
+
475
+ if 'tts_provider' in update_data:
476
+ tts_data = update_data['tts_provider']
477
+ if 'api_key' in tts_data and tts_data['api_key'] and not tts_data['api_key'].startswith('enc:'):
478
+ from encryption_utils import encrypt
479
+ tts_data['api_key'] = encrypt(tts_data['api_key'])
480
+ config.tts_provider = ProviderSettings(**tts_data)
481
+
482
+ if 'stt_provider' in update_data:
483
+ stt_data = update_data['stt_provider']
484
+ if 'api_key' in stt_data and stt_data['api_key'] and not stt_data['api_key'].startswith('enc:'):
485
+ from encryption_utils import encrypt
486
+ stt_data['api_key'] = encrypt(stt_data['api_key'])
487
+ config.stt_provider = ProviderSettings(**stt_data)
488
+
489
+ # Update metadata
490
+ cls._instance.last_update_date = datetime.now().isoformat() + "Z"
491
+ cls._instance.last_update_user = username
492
+
493
+ # Add activity log
494
+ cls.add_activity_log(username, "UPDATE_ENVIRONMENT", "environment", None, None,
495
+ f"Updated providers: LLM={config.llm_provider.name}, TTS={config.tts_provider.name}, STT={config.stt_provider.name}")
496
+
497
+ # Save
498
+ cls._instance.save()
499
+
500
+ @classmethod
501
+ def get_project(cls, project_id: int) -> Optional[ProjectConfig]:
502
+ """Get project by ID"""
503
+ if cls._instance is None:
504
+ cls.get()
505
+
506
+ return next((p for p in cls._instance.projects if p.id == project_id), None)
507
+
508
+ @classmethod
509
+ def create_project(cls, project_data: dict, username: str) -> ProjectConfig:
510
+ """Create new project with initial version"""
511
+ if cls._instance is None:
512
+ cls.get()
513
+
514
+ # Check name uniqueness
515
+ if any(p.name == project_data['name'] for p in cls._instance.projects if not p.deleted):
516
+ raise ValueError(f"Project name '{project_data['name']}' already exists")
517
+
518
+ # Create project
519
+ new_project = ProjectConfig(
520
+ id=cls._instance.project_id_counter,
521
+ name=project_data['name'],
522
+ caption=project_data.get('caption', ''),
523
+ icon=project_data.get('icon', 'folder'),
524
+ description=project_data.get('description', ''),
525
+ enabled=True,
526
+ default_locale=project_data.get('default_locale', 'tr'),
527
+ supported_locales=project_data.get('supported_locales', ['tr']),
528
+ timezone=project_data.get('timezone', 'Europe/Istanbul'),
529
+ region=project_data.get('region', 'tr-TR'),
530
+ version_id_counter=1,
531
+ versions=[],
532
+ deleted=False,
533
+ created_date=datetime.now().isoformat() + "Z",
534
+ created_by=username,
535
+ last_update_date=datetime.now().isoformat() + "Z",
536
+ last_update_user=username
537
+ )
538
+
539
+ # Create initial version
540
+ initial_version = VersionConfig(
541
+ id=1,
542
+ version_number=1,
543
+ no=1,
544
+ caption="Version 1",
545
+ description="Initial version",
546
+ published=False,
547
+ deleted=False,
548
+ created_date=datetime.now().isoformat() + "Z",
549
+ created_by=username,
550
+ last_update_date=datetime.now().isoformat() + "Z",
551
+ last_update_user=username,
552
+ general_prompt="You are a helpful assistant.",
553
+ llm=LLMConfig(
554
+ repo_id="",
555
+ generation_config={
556
+ "max_new_tokens": 512,
557
+ "temperature": 0.7,
558
+ "top_p": 0.95
559
+ }
560
+ ),
561
+ intents=[]
562
+ )
563
+
564
+ new_project.versions.append(initial_version)
565
+ new_project.last_version_number = 1
566
+
567
+ # Add to config
568
+ cls._instance.projects.append(new_project)
569
+ cls._instance.project_id_counter += 1
570
 
571
+ # Add activity log
572
+ cls.add_activity_log(username, "CREATE_PROJECT", "project", new_project.id, new_project.name)
 
 
 
 
 
573
 
574
  # Save
575
+ cls._instance.save()
576
 
577
+ return new_project
 
578
 
 
579
  @classmethod
580
+ def update_project(cls, project_id: int, update_data: dict, username: str) -> ProjectConfig:
581
+ """Update project"""
582
  if cls._instance is None:
583
+ cls.get()
584
 
585
+ project = cls.get_project(project_id)
586
+ if not project:
587
+ raise ValueError(f"Project not found: {project_id}")
588
+
589
+ if project.deleted:
590
+ raise ValueError("Cannot update deleted project")
591
+
592
+ # Update fields
593
+ if 'caption' in update_data:
594
+ project.caption = update_data['caption']
595
+ if 'icon' in update_data:
596
+ project.icon = update_data['icon']
597
+ if 'description' in update_data:
598
+ project.description = update_data['description']
599
+ if 'default_locale' in update_data:
600
+ project.default_locale = update_data['default_locale']
601
+ if 'supported_locales' in update_data:
602
+ project.supported_locales = update_data['supported_locales']
603
+ if 'timezone' in update_data:
604
+ project.timezone = update_data['timezone']
605
+ if 'region' in update_data:
606
+ project.region = update_data['region']
607
+
608
+ # Update metadata
609
+ project.last_update_date = datetime.now().isoformat() + "Z"
610
+ project.last_update_user = username
611
+
612
+ # Add activity log
613
+ cls.add_activity_log(username, "UPDATE_PROJECT", "project", project.id, project.name)
614
+
615
+ # Save
616
+ cls._instance.save()
617
+
618
+ return project
619
 
 
620
  @classmethod
621
+ def delete_project(cls, project_id: int, username: str) -> None:
622
+ """Soft delete project"""
623
+ if cls._instance is None:
624
+ cls.get()
625
+
626
+ project = cls.get_project(project_id)
627
+ if not project:
628
+ raise ValueError(f"Project not found: {project_id}")
629
+
630
+ project.deleted = True
631
+ project.last_update_date = datetime.now().isoformat() + "Z"
632
+ project.last_update_user = username
633
+
634
+ # Add activity log
635
+ cls.add_activity_log(username, "DELETE_PROJECT", "project", project.id, project.name)
636
+
637
+ # Save
638
+ cls._instance.save()
639
+
640
+ @classmethod
641
+ def toggle_project(cls, project_id: int, username: str) -> bool:
642
+ """Toggle project enabled status"""
643
+ if cls._instance is None:
644
+ cls.get()
645
+
646
+ project = cls.get_project(project_id)
647
+ if not project:
648
+ raise ValueError(f"Project not found: {project_id}")
649
+
650
+ project.enabled = not project.enabled
651
+ project.last_update_date = datetime.now().isoformat() + "Z"
652
+ project.last_update_user = username
653
+
654
+ # Add activity log
655
+ action = "ENABLE_PROJECT" if project.enabled else "DISABLE_PROJECT"
656
+ cls.add_activity_log(username, action, "project", project.id, project.name)
657
+
658
+ # Save
659
+ cls._instance.save()
660
+
661
+ return project.enabled
662
+
663
+ @classmethod
664
+ def create_api(cls, api_data: dict, username: str) -> APIConfig:
665
+ """Create new API"""
666
+ if cls._instance is None:
667
+ cls.get()
668
+
669
+ # Check name uniqueness
670
+ if any(a.name == api_data['name'] for a in cls._instance.apis if not a.deleted):
671
+ raise ValueError(f"API name '{api_data['name']}' already exists")
672
+
673
+ # Create API
674
+ new_api = APIConfig(
675
+ name=api_data['name'],
676
+ url=api_data['url'],
677
+ method=api_data.get('method', 'GET'),
678
+ headers=api_data.get('headers', {}),
679
+ body_template=api_data.get('body_template', {}),
680
+ timeout_seconds=api_data.get('timeout_seconds', 10),
681
+ retry=RetryConfig(**api_data.get('retry', {})) if 'retry' in api_data else RetryConfig(),
682
+ proxy=api_data.get('proxy'),
683
+ auth=APIAuthConfig(**api_data.get('auth', {})) if api_data.get('auth') else None,
684
+ response_prompt=api_data.get('response_prompt'),
685
+ response_mappings=api_data.get('response_mappings', []),
686
+ deleted=False,
687
+ created_date=datetime.now().isoformat() + "Z",
688
+ created_by=username,
689
+ last_update_date=datetime.now().isoformat() + "Z",
690
+ last_update_user=username
691
+ )
692
+
693
+ # Add to config
694
+ cls._instance.apis.append(new_api)
695
+
696
+ # Rebuild index
697
+ cls._instance.build_index()
698
+
699
+ # Add activity log
700
+ cls.add_activity_log(username, "CREATE_API", "api", None, new_api.name)
701
+
702
+ # Save
703
+ cls._instance.save()
704
+
705
+ return new_api
706
+
707
+ @classmethod
708
+ def update_api(cls, api_name: str, update_data: dict, username: str) -> APIConfig:
709
+ """Update API"""
710
+ if cls._instance is None:
711
+ cls.get()
712
+
713
+ api = next((a for a in cls._instance.apis if a.name == api_name and not a.deleted), None)
714
+ if not api:
715
+ raise ValueError(f"API not found: {api_name}")
716
+
717
+ # Update fields
718
+ for field in ['url', 'method', 'headers', 'body_template', 'timeout_seconds',
719
+ 'proxy', 'response_prompt', 'response_mappings']:
720
+ if field in update_data:
721
+ setattr(api, field, update_data[field])
722
+
723
+ # Update retry config
724
+ if 'retry' in update_data:
725
+ api.retry = RetryConfig(**update_data['retry'])
726
+
727
+ # Update auth config
728
+ if 'auth' in update_data:
729
+ if update_data['auth']:
730
+ api.auth = APIAuthConfig(**update_data['auth'])
731
+ else:
732
+ api.auth = None
733
+
734
+ # Update metadata
735
+ api.last_update_date = datetime.now().isoformat() + "Z"
736
+ api.last_update_user = username
737
+
738
+ # Add activity log
739
+ cls.add_activity_log(username, "UPDATE_API", "api", None, api.name)
740
+
741
+ # Save
742
+ cls._instance.save()
743
+
744
+ return api
745
+
746
+ @classmethod
747
+ def delete_api(cls, api_name: str, username: str) -> None:
748
+ """Soft delete API"""
749
+ if cls._instance is None:
750
+ cls.get()
751
+
752
+ api = next((a for a in cls._instance.apis if a.name == api_name and not a.deleted), None)
753
+ if not api:
754
+ raise ValueError(f"API not found: {api_name}")
755
+
756
+ # Check if API is used in any intent
757
+ for project in cls._instance.projects:
758
+ if getattr(project, 'deleted', False):
759
+ continue
760
+
761
+ for version in project.versions:
762
+ if getattr(version, 'deleted', False):
763
+ continue
764
+
765
+ for intent in version.intents:
766
+ if intent.action == api_name:
767
+ raise ValueError(f"API is used in intent '{intent.name}' in project '{project.name}' version {version.no}")
768
+
769
+ api.deleted = True
770
+ api.last_update_date = datetime.now().isoformat() + "Z"
771
+ api.last_update_user = username
772
+
773
+ # Add activity log
774
+ cls.add_activity_log(username, "DELETE_API", "api", None, api_name)
775
+
776
+ # Save
777
+ cls._instance.save()
778
+
779
+ @classmethod
780
+ def import_project(cls, project_data: dict, username: str) -> ProjectConfig:
781
+ """Import project from JSON"""
782
+ if cls._instance is None:
783
+ cls.get()
784
+
785
+ # Validate structure
786
+ if "name" not in project_data:
787
+ raise ValueError("Invalid project data")
788
+
789
+ # Create new project with imported data
790
+ imported_data = {
791
+ "name": project_data["name"],
792
+ "caption": project_data.get("caption", ""),
793
+ "icon": project_data.get("icon", "folder"),
794
+ "description": project_data.get("description", ""),
795
+ "default_locale": project_data.get("default_locale", "tr"),
796
+ "supported_locales": project_data.get("supported_locales", ["tr"])
797
+ }
798
+
799
+ # Create project
800
+ new_project = cls.create_project(imported_data, username)
801
+
802
+ # Clear default version
803
+ new_project.versions = []
804
+
805
+ # Import versions
806
+ for idx, version_data in enumerate(project_data.get("versions", [])):
807
+ new_version = VersionConfig(
808
+ id=idx + 1,
809
+ version_number=idx + 1,
810
+ no=idx + 1,
811
+ caption=version_data.get("caption", f"Version {idx + 1}"),
812
+ description=version_data.get("description", ""),
813
+ published=False,
814
+ deleted=False,
815
+ created_date=datetime.now().isoformat() + "Z",
816
+ created_by=username,
817
+ last_update_date=datetime.now().isoformat() + "Z",
818
+ last_update_user=username,
819
+ general_prompt=version_data.get("general_prompt", ""),
820
+ llm=LLMConfig(**version_data.get("llm", {})) if version_data.get("llm") else LLMConfig(
821
+ repo_id="",
822
+ generation_config={
823
+ "max_new_tokens": 512,
824
+ "temperature": 0.7,
825
+ "top_p": 0.95
826
+ }
827
+ ),
828
+ intents=[]
829
+ )
830
+
831
+ # Import intents
832
+ for intent_data in version_data.get("intents", []):
833
+ intent = IntentConfig(
834
+ name=intent_data.get("name", ""),
835
+ caption=intent_data.get("caption", ""),
836
+ detection_prompt=intent_data.get("detection_prompt", ""),
837
+ action=intent_data.get("action", ""),
838
+ fallback_timeout_prompt=intent_data.get("fallback_timeout_prompt"),
839
+ fallback_error_prompt=intent_data.get("fallback_error_prompt"),
840
+ examples=[],
841
+ parameters=[]
842
+ )
843
+
844
+ # Convert examples
845
+ if "examples" in intent_data:
846
+ if isinstance(intent_data["examples"], list):
847
+ for example in intent_data["examples"]:
848
+ if isinstance(example, str):
849
+ # Old format - use project default locale
850
+ intent.examples.append(LocalizedExample(
851
+ locale_code=new_project.default_locale,
852
+ example=example
853
+ ))
854
+ elif isinstance(example, dict):
855
+ # New format
856
+ intent.examples.append(LocalizedExample(**example))
857
+
858
+ # Convert parameters
859
+ for param_data in intent_data.get("parameters", []):
860
+ param = ParameterConfig(
861
+ name=param_data.get("name", ""),
862
+ type=param_data.get("type", "str"),
863
+ required=param_data.get("required", True),
864
+ variable_name=param_data.get("variable_name", param_data.get("name", "")),
865
+ extraction_prompt=param_data.get("extraction_prompt"),
866
+ validation_regex=param_data.get("validation_regex"),
867
+ invalid_prompt=param_data.get("invalid_prompt"),
868
+ type_error_prompt=param_data.get("type_error_prompt"),
869
+ caption=[]
870
+ )
871
+
872
+ # Convert caption
873
+ if "caption" in param_data:
874
+ if isinstance(param_data["caption"], str):
875
+ # Old format
876
+ param.caption.append(LocalizedCaption(
877
+ locale_code=new_project.default_locale,
878
+ caption=param_data["caption"]
879
+ ))
880
+ elif isinstance(param_data["caption"], list):
881
+ # New format
882
+ for cap in param_data["caption"]:
883
+ param.caption.append(LocalizedCaption(**cap))
884
+
885
+ intent.parameters.append(param)
886
+
887
+ new_version.intents.append(intent)
888
+
889
+ new_project.versions.append(new_version)
890
+ new_project.last_version_number = new_version.id
891
+
892
+ # Update project counter if needed
893
+ new_project.version_id_counter = len(new_project.versions) + 1
894
+
895
+ # Save
896
+ cls._instance.save()
897
+
898
+ # Add activity log
899
+ cls.add_activity_log(username, "IMPORT_PROJECT", "project", new_project.id, new_project.name,
900
+ f"Imported with {len(new_project.versions)} versions")
901
+
902
+ return new_project