iatoolkit 0.63.1__py3-none-any.whl → 0.67.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of iatoolkit might be problematic. Click here for more details.
- iatoolkit/__init__.py +2 -0
- iatoolkit/base_company.py +1 -20
- iatoolkit/common/routes.py +11 -2
- iatoolkit/common/session_manager.py +2 -0
- iatoolkit/common/util.py +17 -0
- iatoolkit/company_registry.py +1 -2
- iatoolkit/iatoolkit.py +41 -5
- iatoolkit/locales/en.yaml +167 -0
- iatoolkit/locales/es.yaml +163 -0
- iatoolkit/repositories/database_manager.py +3 -3
- iatoolkit/repositories/document_repo.py +1 -1
- iatoolkit/repositories/models.py +2 -3
- iatoolkit/repositories/profile_repo.py +0 -4
- iatoolkit/services/auth_service.py +14 -9
- iatoolkit/services/branding_service.py +32 -22
- iatoolkit/services/configuration_service.py +140 -0
- iatoolkit/services/dispatcher_service.py +20 -18
- iatoolkit/services/document_service.py +5 -2
- iatoolkit/services/excel_service.py +15 -11
- iatoolkit/services/file_processor_service.py +4 -12
- iatoolkit/services/history_service.py +8 -7
- iatoolkit/services/i18n_service.py +104 -0
- iatoolkit/services/jwt_service.py +7 -9
- iatoolkit/services/language_service.py +79 -0
- iatoolkit/services/load_documents_service.py +4 -4
- iatoolkit/services/mail_service.py +9 -4
- iatoolkit/services/onboarding_service.py +10 -4
- iatoolkit/services/profile_service.py +58 -38
- iatoolkit/services/prompt_manager_service.py +20 -16
- iatoolkit/services/query_service.py +15 -14
- iatoolkit/services/sql_service.py +6 -2
- iatoolkit/services/user_feedback_service.py +16 -14
- iatoolkit/static/js/chat_feedback_button.js +57 -87
- iatoolkit/static/js/chat_help_content.js +124 -0
- iatoolkit/static/js/chat_history_button.js +48 -65
- iatoolkit/static/js/chat_main.js +27 -24
- iatoolkit/static/js/chat_reload_button.js +28 -45
- iatoolkit/static/styles/chat_iatoolkit.css +223 -315
- iatoolkit/static/styles/chat_modal.css +63 -97
- iatoolkit/static/styles/chat_public.css +107 -0
- iatoolkit/static/styles/landing_page.css +0 -1
- iatoolkit/templates/_company_header.html +6 -2
- iatoolkit/templates/_login_widget.html +42 -0
- iatoolkit/templates/base.html +34 -19
- iatoolkit/templates/change_password.html +22 -20
- iatoolkit/templates/chat.html +58 -27
- iatoolkit/templates/chat_modals.html +113 -74
- iatoolkit/templates/error.html +12 -13
- iatoolkit/templates/forgot_password.html +11 -7
- iatoolkit/templates/index.html +8 -3
- iatoolkit/templates/login_simulation.html +16 -5
- iatoolkit/templates/onboarding_shell.html +0 -1
- iatoolkit/templates/signup.html +14 -14
- iatoolkit/views/base_login_view.py +12 -1
- iatoolkit/views/change_password_view.py +49 -33
- iatoolkit/views/forgot_password_view.py +20 -19
- iatoolkit/views/help_content_api_view.py +54 -0
- iatoolkit/views/history_api_view.py +13 -9
- iatoolkit/views/home_view.py +30 -38
- iatoolkit/views/init_context_api_view.py +16 -11
- iatoolkit/views/llmquery_api_view.py +38 -26
- iatoolkit/views/login_simulation_view.py +14 -2
- iatoolkit/views/login_view.py +47 -35
- iatoolkit/views/logout_api_view.py +26 -22
- iatoolkit/views/profile_api_view.py +46 -0
- iatoolkit/views/prompt_api_view.py +6 -6
- iatoolkit/views/signup_view.py +26 -24
- iatoolkit/views/user_feedback_api_view.py +19 -18
- iatoolkit/views/verify_user_view.py +30 -29
- {iatoolkit-0.63.1.dist-info → iatoolkit-0.67.0.dist-info}/METADATA +40 -22
- iatoolkit-0.67.0.dist-info/RECORD +120 -0
- iatoolkit-0.67.0.dist-info/licenses/LICENSE +21 -0
- iatoolkit/static/styles/chat_info.css +0 -53
- iatoolkit/templates/header.html +0 -31
- iatoolkit/templates/test.html +0 -9
- iatoolkit-0.63.1.dist-info/RECORD +0 -112
- {iatoolkit-0.63.1.dist-info → iatoolkit-0.67.0.dist-info}/WHEEL +0 -0
- {iatoolkit-0.63.1.dist-info → iatoolkit-0.67.0.dist-info}/top_level.txt +0 -0
|
@@ -6,32 +6,33 @@
|
|
|
6
6
|
from injector import inject
|
|
7
7
|
from iatoolkit.repositories.llm_query_repo import LLMQueryRepo
|
|
8
8
|
from iatoolkit.repositories.profile_repo import ProfileRepo
|
|
9
|
+
from iatoolkit.services.i18n_service import I18nService
|
|
10
|
+
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
class HistoryService:
|
|
12
14
|
@inject
|
|
13
15
|
def __init__(self, llm_query_repo: LLMQueryRepo,
|
|
14
|
-
profile_repo: ProfileRepo
|
|
16
|
+
profile_repo: ProfileRepo,
|
|
17
|
+
i18n_service: I18nService):
|
|
15
18
|
self.llm_query_repo = llm_query_repo
|
|
16
19
|
self.profile_repo = profile_repo
|
|
20
|
+
self.i18n_service = i18n_service
|
|
17
21
|
|
|
18
22
|
def get_history(self,
|
|
19
23
|
company_short_name: str,
|
|
20
24
|
user_identifier: str) -> dict:
|
|
21
25
|
try:
|
|
22
|
-
# validate company
|
|
23
26
|
company = self.profile_repo.get_company_by_short_name(company_short_name)
|
|
24
27
|
if not company:
|
|
25
|
-
return {
|
|
28
|
+
return {"error": self.i18n_service.t('errors.company_not_found', company_short_name=company_short_name)}
|
|
26
29
|
|
|
27
30
|
history = self.llm_query_repo.get_history(company, user_identifier)
|
|
28
|
-
|
|
29
31
|
if not history:
|
|
30
|
-
return {'message': '
|
|
32
|
+
return {'message': 'empty history', 'history': []}
|
|
31
33
|
|
|
32
34
|
history_list = [query.to_dict() for query in history]
|
|
33
|
-
|
|
34
|
-
return {'message': 'Historial obtenido correctamente', 'history': history_list}
|
|
35
|
+
return {'message': 'history loaded ok', 'history': history_list}
|
|
35
36
|
|
|
36
37
|
except Exception as e:
|
|
37
38
|
return {'error': str(e)}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# iatoolkit/services/i18n_service.py
|
|
2
|
+
import os
|
|
3
|
+
import logging
|
|
4
|
+
from injector import inject, singleton
|
|
5
|
+
from iatoolkit.common.util import Utility
|
|
6
|
+
from iatoolkit.services.language_service import LanguageService
|
|
7
|
+
|
|
8
|
+
@singleton
|
|
9
|
+
class I18nService:
|
|
10
|
+
"""
|
|
11
|
+
Servicio centralizado para manejar la internacionalización (i18n).
|
|
12
|
+
Carga todas las traducciones desde archivos YAML en memoria al iniciar.
|
|
13
|
+
"""
|
|
14
|
+
FALLBACK_LANGUAGE = 'es'
|
|
15
|
+
|
|
16
|
+
@inject
|
|
17
|
+
def __init__(self, util: Utility, language_service: LanguageService):
|
|
18
|
+
self.util = util
|
|
19
|
+
self.language_service = language_service
|
|
20
|
+
|
|
21
|
+
self.translations = {}
|
|
22
|
+
self._load_translations()
|
|
23
|
+
|
|
24
|
+
def _load_translations(self):
|
|
25
|
+
"""
|
|
26
|
+
Carga todos los archivos .yaml del directorio 'locales' en memoria.
|
|
27
|
+
"""
|
|
28
|
+
locales_dir = os.path.join(os.path.dirname(__file__), '..', 'locales')
|
|
29
|
+
if not os.path.exists(locales_dir):
|
|
30
|
+
logging.error("Directory 'locales' not found.")
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
for filename in os.listdir(locales_dir):
|
|
34
|
+
if filename.endswith('.yaml'):
|
|
35
|
+
lang_code = filename.split('.')[0]
|
|
36
|
+
filepath = os.path.join(locales_dir, filename)
|
|
37
|
+
try:
|
|
38
|
+
self.translations[lang_code] = self.util.load_schema_from_yaml(filepath)
|
|
39
|
+
except Exception as e:
|
|
40
|
+
logging.error(f"Error while loading the translation file {filepath}: {e}")
|
|
41
|
+
|
|
42
|
+
def _get_nested_key(self, lang: str, key: str):
|
|
43
|
+
"""
|
|
44
|
+
Obtiene un valor de un diccionario anidado usando una clave con puntos.
|
|
45
|
+
"""
|
|
46
|
+
data = self.translations.get(lang, {})
|
|
47
|
+
keys = key.split('.')
|
|
48
|
+
for k in keys:
|
|
49
|
+
if isinstance(data, dict) and k in data:
|
|
50
|
+
data = data[k]
|
|
51
|
+
else:
|
|
52
|
+
return None
|
|
53
|
+
return data
|
|
54
|
+
|
|
55
|
+
def get_translation_block(self, key: str, lang: str = None) -> dict:
|
|
56
|
+
"""
|
|
57
|
+
Gets a whole dictionary block from the translations.
|
|
58
|
+
Useful for passing a set of translations to JavaScript.
|
|
59
|
+
"""
|
|
60
|
+
if lang is None:
|
|
61
|
+
lang = self.language_service.get_current_language()
|
|
62
|
+
|
|
63
|
+
# 1. Try to get the block in the requested language
|
|
64
|
+
block = self._get_nested_key(lang, key)
|
|
65
|
+
|
|
66
|
+
# 2. If not found, try the fallback language
|
|
67
|
+
if not isinstance(block, dict):
|
|
68
|
+
block = self._get_nested_key(self.FALLBACK_LANGUAGE, key)
|
|
69
|
+
|
|
70
|
+
return block if isinstance(block, dict) else {}
|
|
71
|
+
|
|
72
|
+
def t(self, key: str, lang: str = None, **kwargs) -> str:
|
|
73
|
+
"""
|
|
74
|
+
Gets the translation for a given key.
|
|
75
|
+
If 'lang' is provided, it's used. Otherwise, it's determined automatically.
|
|
76
|
+
"""
|
|
77
|
+
# If no specific language is requested, determine it from the current context.
|
|
78
|
+
if lang is None:
|
|
79
|
+
lang = self.language_service.get_current_language()
|
|
80
|
+
|
|
81
|
+
# 1. Attempt to get the translation in the requested language
|
|
82
|
+
message = self._get_nested_key(lang, key)
|
|
83
|
+
|
|
84
|
+
# 2. If not found, try the fallback language
|
|
85
|
+
if message is None and lang != self.FALLBACK_LANGUAGE:
|
|
86
|
+
logging.warning(
|
|
87
|
+
f"Translation key '{key}' not found for language '{lang}'. Attempting fallback to '{self.FALLBACK_LANGUAGE}'.")
|
|
88
|
+
message = self._get_nested_key(self.FALLBACK_LANGUAGE, key)
|
|
89
|
+
|
|
90
|
+
# 3. If still not found, return the key itself as a last resort
|
|
91
|
+
if message is None:
|
|
92
|
+
logging.error(
|
|
93
|
+
f"Translation key '{key}' not found, even in fallback '{self.FALLBACK_LANGUAGE}'.")
|
|
94
|
+
return key
|
|
95
|
+
|
|
96
|
+
# 4. If variables are provided, format the message
|
|
97
|
+
if kwargs:
|
|
98
|
+
try:
|
|
99
|
+
return message.format(**kwargs)
|
|
100
|
+
except KeyError as e:
|
|
101
|
+
logging.error(f"Error formatting key '{key}': missing variable {e} in arguments.")
|
|
102
|
+
return message
|
|
103
|
+
|
|
104
|
+
return message
|
|
@@ -20,8 +20,8 @@ class JWTService:
|
|
|
20
20
|
self.secret_key = app.config['JWT_SECRET_KEY']
|
|
21
21
|
self.algorithm = app.config['JWT_ALGORITHM']
|
|
22
22
|
except KeyError as e:
|
|
23
|
-
logging.error(f"
|
|
24
|
-
raise RuntimeError(f"
|
|
23
|
+
logging.error(f"missing JWT configuration: {e}.")
|
|
24
|
+
raise RuntimeError(f"missing JWT configuration variables: {e}")
|
|
25
25
|
|
|
26
26
|
def generate_chat_jwt(self,
|
|
27
27
|
company_short_name: str,
|
|
@@ -58,25 +58,23 @@ class JWTService:
|
|
|
58
58
|
|
|
59
59
|
# Validaciones adicionales
|
|
60
60
|
if payload.get('type') != 'chat_session':
|
|
61
|
-
logging.warning(f"
|
|
61
|
+
logging.warning(f"Invalid JWT type '{payload.get('type')}'")
|
|
62
62
|
return None
|
|
63
63
|
|
|
64
64
|
# user_identifier debe estar presente
|
|
65
65
|
if not payload.get('user_identifier'):
|
|
66
|
-
logging.warning(f"
|
|
66
|
+
logging.warning(f"missing user_identifier in JWT payload.")
|
|
67
67
|
return None
|
|
68
68
|
|
|
69
69
|
if not payload.get('company_short_name'):
|
|
70
|
-
logging.warning(f"
|
|
70
|
+
logging.warning(f"missing company_short_name in JWT payload.")
|
|
71
71
|
return None
|
|
72
72
|
|
|
73
|
-
logging.debug(
|
|
74
|
-
f"JWT validado exitosamente para company: {payload.get('company_short_name')}, user: {payload.get('external_user_id')}")
|
|
75
73
|
return payload
|
|
76
74
|
|
|
77
75
|
except jwt.InvalidTokenError as e:
|
|
78
|
-
logging.warning(f"
|
|
76
|
+
logging.warning(f"Invalid JWT token:: {e}")
|
|
79
77
|
return None
|
|
80
78
|
except Exception as e:
|
|
81
|
-
logging.error(f"
|
|
79
|
+
logging.error(f"unexpected error during JWT validation: {e}")
|
|
82
80
|
return None
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# iatoolkit/services/language_service.py
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from injector import inject, singleton
|
|
5
|
+
from flask import g, request
|
|
6
|
+
from iatoolkit.repositories.profile_repo import ProfileRepo
|
|
7
|
+
from iatoolkit.common.session_manager import SessionManager
|
|
8
|
+
|
|
9
|
+
@singleton
|
|
10
|
+
class LanguageService:
|
|
11
|
+
"""
|
|
12
|
+
Determines the correct language for the current request
|
|
13
|
+
based on a defined priority order (session, URL, etc.)
|
|
14
|
+
and caches it in the Flask 'g' object for the request's lifecycle.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
FALLBACK_LANGUAGE = 'es'
|
|
18
|
+
|
|
19
|
+
@inject
|
|
20
|
+
def __init__(self, profile_repo: ProfileRepo):
|
|
21
|
+
self.profile_repo = profile_repo
|
|
22
|
+
|
|
23
|
+
def _get_company_short_name(self) -> str | None:
|
|
24
|
+
"""
|
|
25
|
+
Gets the company_short_name from the current request context.
|
|
26
|
+
This handles different scenarios like web sessions, public URLs, and API calls.
|
|
27
|
+
|
|
28
|
+
Priority Order:
|
|
29
|
+
1. Flask Session (for logged-in web users).
|
|
30
|
+
2. URL rule variable (for public pages and API endpoints).
|
|
31
|
+
"""
|
|
32
|
+
# 1. Check session for logged-in users
|
|
33
|
+
company_short_name = SessionManager.get('company_short_name')
|
|
34
|
+
if company_short_name:
|
|
35
|
+
return company_short_name
|
|
36
|
+
|
|
37
|
+
# 2. Check URL arguments (e.g., /<company_short_name>/login)
|
|
38
|
+
# This covers public pages and most API calls.
|
|
39
|
+
if request.view_args and 'company_short_name' in request.view_args:
|
|
40
|
+
return request.view_args['company_short_name']
|
|
41
|
+
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
def get_current_language(self) -> str:
|
|
45
|
+
"""
|
|
46
|
+
Determines and caches the language for the current request using a priority order:
|
|
47
|
+
1. User's preference (from their profile).
|
|
48
|
+
2. Company's default language.
|
|
49
|
+
3. System-wide fallback language ('es').
|
|
50
|
+
"""
|
|
51
|
+
if 'lang' in g:
|
|
52
|
+
return g.lang
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
# Priority 1: User's preferred language
|
|
56
|
+
user_identifier = SessionManager.get('user_identifier')
|
|
57
|
+
if user_identifier:
|
|
58
|
+
user = self.profile_repo.get_user_by_email(user_identifier)
|
|
59
|
+
if user and user.preferred_language:
|
|
60
|
+
logging.debug(f"Language determined by user preference: {user.preferred_language}")
|
|
61
|
+
g.lang = user.preferred_language
|
|
62
|
+
return g.lang
|
|
63
|
+
|
|
64
|
+
# Priority 2: Company's default language
|
|
65
|
+
company_short_name = self._get_company_short_name()
|
|
66
|
+
if company_short_name:
|
|
67
|
+
company = self.profile_repo.get_company_by_short_name(company_short_name)
|
|
68
|
+
if company and company.default_language:
|
|
69
|
+
logging.debug(f"Language determined by company default: {company.default_language}")
|
|
70
|
+
g.lang = company.default_language
|
|
71
|
+
return g.lang
|
|
72
|
+
except Exception as e:
|
|
73
|
+
logging.info(f"Could not determine language, falling back to default. Reason: {e}")
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
# Priority 3: System-wide fallback
|
|
77
|
+
logging.info(f"Language determined by system fallback: {self.FALLBACK_LANGUAGE}")
|
|
78
|
+
g.lang = self.FALLBACK_LANGUAGE
|
|
79
|
+
return g.lang
|
|
@@ -72,7 +72,7 @@ class LoadDocumentsService:
|
|
|
72
72
|
"""
|
|
73
73
|
if not connector_config:
|
|
74
74
|
raise IAToolkitException(IAToolkitException.ErrorType.MISSING_PARAMETER,
|
|
75
|
-
f"
|
|
75
|
+
f"Missing connector config")
|
|
76
76
|
|
|
77
77
|
try:
|
|
78
78
|
if not filters:
|
|
@@ -123,7 +123,7 @@ class LoadDocumentsService:
|
|
|
123
123
|
|
|
124
124
|
if not company:
|
|
125
125
|
raise IAToolkitException(IAToolkitException.ErrorType.MISSING_PARAMETER,
|
|
126
|
-
f"
|
|
126
|
+
f"missing company")
|
|
127
127
|
|
|
128
128
|
# check if file exist in repositories
|
|
129
129
|
if self.doc_repo.get(company_id=company.id,filename=filename):
|
|
@@ -182,6 +182,6 @@ class LoadDocumentsService:
|
|
|
182
182
|
self.doc_repo.session.rollback()
|
|
183
183
|
|
|
184
184
|
# if something fails, throw exception
|
|
185
|
-
logging.exception("Error
|
|
185
|
+
logging.exception("Error processing file %s: %s", filename, str(e))
|
|
186
186
|
raise IAToolkitException(IAToolkitException.ErrorType.LOAD_DOCUMENT_ERROR,
|
|
187
|
-
f"Error
|
|
187
|
+
f"Error while processing file: {filename}")
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
# IAToolkit is open source software.
|
|
5
5
|
|
|
6
6
|
from iatoolkit.infra.mail_app import MailApp
|
|
7
|
+
from iatoolkit.services.i18n_service import I18nService
|
|
7
8
|
from injector import inject
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from iatoolkit.common.exceptions import IAToolkitException
|
|
@@ -13,18 +14,22 @@ TEMP_DIR = Path("static/temp")
|
|
|
13
14
|
|
|
14
15
|
class MailService:
|
|
15
16
|
@inject
|
|
16
|
-
def __init__(self,
|
|
17
|
+
def __init__(self,
|
|
18
|
+
mail_app: MailApp,
|
|
19
|
+
i18n_service: I18nService):
|
|
17
20
|
self.mail_app = mail_app
|
|
21
|
+
self.i18n_service = i18n_service
|
|
22
|
+
|
|
18
23
|
|
|
19
24
|
def _read_token_bytes(self, token: str) -> bytes:
|
|
20
25
|
# Defensa simple contra path traversal
|
|
21
26
|
if not token or "/" in token or "\\" in token or token.startswith("."):
|
|
22
27
|
raise IAToolkitException(IAToolkitException.ErrorType.MAIL_ERROR,
|
|
23
|
-
"attachment_token
|
|
28
|
+
"attachment_token invalid")
|
|
24
29
|
path = TEMP_DIR / token
|
|
25
30
|
if not path.is_file():
|
|
26
31
|
raise IAToolkitException(IAToolkitException.ErrorType.MAIL_ERROR,
|
|
27
|
-
f"
|
|
32
|
+
f"attach file not found: {token}")
|
|
28
33
|
return path.read_bytes()
|
|
29
34
|
|
|
30
35
|
def send_mail(self, **kwargs):
|
|
@@ -59,4 +64,4 @@ class MailService:
|
|
|
59
64
|
body=body,
|
|
60
65
|
attachments=norm_attachments)
|
|
61
66
|
|
|
62
|
-
return '
|
|
67
|
+
return self.i18n_service.t('services.mail_sent')
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
# IAToolkit is open source software.
|
|
5
5
|
|
|
6
6
|
from iatoolkit.repositories.models import Company
|
|
7
|
+
from iatoolkit.services.configuration_service import ConfigurationService
|
|
7
8
|
from typing import List, Dict, Any
|
|
9
|
+
from injector import inject
|
|
8
10
|
|
|
9
11
|
|
|
10
12
|
class OnboardingService:
|
|
@@ -12,11 +14,13 @@ class OnboardingService:
|
|
|
12
14
|
Servicio para gestionar las tarjetas de contenido que se muestran
|
|
13
15
|
durante la pantalla de carga (onboarding).
|
|
14
16
|
"""
|
|
15
|
-
|
|
16
|
-
def __init__(self):
|
|
17
|
+
@inject
|
|
18
|
+
def __init__(self, config_service: ConfigurationService):
|
|
17
19
|
"""
|
|
18
20
|
Define el conjunto de tarjetas de onboarding por defecto.
|
|
19
21
|
"""
|
|
22
|
+
self.config_service = config_service
|
|
23
|
+
|
|
20
24
|
self._default_cards = [
|
|
21
25
|
{'icon': 'fas fa-users', 'title': 'Clientes',
|
|
22
26
|
'text': 'Conozco en detalle a nuestros clientes: antigüedad, contactos, historial de operaciones.<br><br><strong>Ejemplo:</strong> ¿cuántos clientes nuevos se incorporaron a mi cartera este año?'},
|
|
@@ -37,7 +41,9 @@ class OnboardingService:
|
|
|
37
41
|
Si la compañía tiene tarjetas personalizadas, las devuelve.
|
|
38
42
|
De lo contrario, devuelve las tarjetas por defecto.
|
|
39
43
|
"""
|
|
40
|
-
if company
|
|
41
|
-
|
|
44
|
+
if company:
|
|
45
|
+
onboarding_cards = self.config_service.get_company_content(company.short_name, 'onboarding_cards')
|
|
46
|
+
|
|
47
|
+
return onboarding_cards
|
|
42
48
|
|
|
43
49
|
return self._default_cards
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
from injector import inject
|
|
7
7
|
from iatoolkit.repositories.profile_repo import ProfileRepo
|
|
8
|
-
from iatoolkit.services.
|
|
8
|
+
from iatoolkit.services.i18n_service import I18nService
|
|
9
9
|
from iatoolkit.repositories.models import User, Company, ApiKey
|
|
10
10
|
from flask_bcrypt import check_password_hash
|
|
11
11
|
from iatoolkit.common.session_manager import SessionManager
|
|
@@ -16,16 +16,19 @@ import random
|
|
|
16
16
|
import re
|
|
17
17
|
import secrets
|
|
18
18
|
import string
|
|
19
|
+
import logging
|
|
19
20
|
from iatoolkit.services.dispatcher_service import Dispatcher
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
class ProfileService:
|
|
23
24
|
@inject
|
|
24
25
|
def __init__(self,
|
|
26
|
+
i18n_service: I18nService,
|
|
25
27
|
profile_repo: ProfileRepo,
|
|
26
28
|
session_context_service: UserSessionContextService,
|
|
27
29
|
dispatcher: Dispatcher,
|
|
28
30
|
mail_app: MailApp):
|
|
31
|
+
self.i18n_service = i18n_service
|
|
29
32
|
self.profile_repo = profile_repo
|
|
30
33
|
self.dispatcher = dispatcher
|
|
31
34
|
self.session_context = session_context_service
|
|
@@ -38,23 +41,23 @@ class ProfileService:
|
|
|
38
41
|
# check if user exists
|
|
39
42
|
user = self.profile_repo.get_user_by_email(email)
|
|
40
43
|
if not user:
|
|
41
|
-
return {'success': False,
|
|
44
|
+
return {'success': False, 'message': self.i18n_service.t('errors.auth.user_not_found')}
|
|
42
45
|
|
|
43
46
|
# check the encrypted password
|
|
44
47
|
if not check_password_hash(user.password, password):
|
|
45
|
-
return {'success': False,
|
|
48
|
+
return {'success': False, 'message': self.i18n_service.t('errors.auth.invalid_password')}
|
|
46
49
|
|
|
47
50
|
company = self.profile_repo.get_company_by_short_name(company_short_name)
|
|
48
51
|
if not company:
|
|
49
|
-
return {'success': False, "message": "
|
|
52
|
+
return {'success': False, "message": "missing company"}
|
|
50
53
|
|
|
51
54
|
# check that user belongs to company
|
|
52
55
|
if company not in user.companies:
|
|
53
|
-
return {'success': False, "message":
|
|
56
|
+
return {'success': False, "message": self.i18n_service.t('errors.services.user_not_authorized')}
|
|
54
57
|
|
|
55
58
|
if not user.verified:
|
|
56
59
|
return {'success': False,
|
|
57
|
-
"message":
|
|
60
|
+
"message": self.i18n_service.t('errors.services.account_not_verified')}
|
|
58
61
|
|
|
59
62
|
# 1. Build the local user profile dictionary here.
|
|
60
63
|
# the user_profile variables are used on the LLM templates also (see in query_main.prompt)
|
|
@@ -71,8 +74,9 @@ class ProfileService:
|
|
|
71
74
|
|
|
72
75
|
# 3. create the web session
|
|
73
76
|
self.set_session_for_user(company.short_name, user_identifier)
|
|
74
|
-
return {'success': True, "user_identifier": user_identifier, "message": "Login
|
|
77
|
+
return {'success': True, "user_identifier": user_identifier, "message": "Login ok"}
|
|
75
78
|
except Exception as e:
|
|
79
|
+
logging.error(f"Error in login: {e}")
|
|
76
80
|
return {'success': False, "message": str(e)}
|
|
77
81
|
|
|
78
82
|
def create_external_user_profile_context(self, company: Company, user_identifier: str):
|
|
@@ -131,6 +135,24 @@ class ProfileService:
|
|
|
131
135
|
"profile": profile
|
|
132
136
|
}
|
|
133
137
|
|
|
138
|
+
def update_user_language(self, user_identifier: str, new_lang: str) -> dict:
|
|
139
|
+
"""
|
|
140
|
+
Business logic to update a user's preferred language.
|
|
141
|
+
It validates the language and then calls the generic update method.
|
|
142
|
+
"""
|
|
143
|
+
# 1. Validate that the language is supported by checking the loaded translations.
|
|
144
|
+
if new_lang not in self.i18n_service.translations:
|
|
145
|
+
return {'success': False, 'error_message': self.i18n_service.t('errors.general.unsupported_language')}
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
# 2. Call the generic update_user method, passing the specific field to update.
|
|
149
|
+
self.update_user(user_identifier, preferred_language=new_lang)
|
|
150
|
+
return {'success': True, 'message': 'Language updated successfully.'}
|
|
151
|
+
except Exception as e:
|
|
152
|
+
# Log the error and return a generic failure message.
|
|
153
|
+
logging.error(f"Failed to update language for {user_identifier}: {e}")
|
|
154
|
+
return {'success': False, 'error_message': self.i18n_service.t('errors.general.unexpected_error', error=str(e))}
|
|
155
|
+
|
|
134
156
|
|
|
135
157
|
def get_profile_by_identifier(self, company_short_name: str, user_identifier: str) -> dict:
|
|
136
158
|
"""
|
|
@@ -155,7 +177,8 @@ class ProfileService:
|
|
|
155
177
|
# get company info
|
|
156
178
|
company = self.profile_repo.get_company_by_short_name(company_short_name)
|
|
157
179
|
if not company:
|
|
158
|
-
return {
|
|
180
|
+
return {
|
|
181
|
+
"error": self.i18n_service.t('errors.signup.company_not_found', company_name=company_short_name)}
|
|
159
182
|
|
|
160
183
|
# normalize format's
|
|
161
184
|
email = email.lower()
|
|
@@ -165,24 +188,25 @@ class ProfileService:
|
|
|
165
188
|
if existing_user:
|
|
166
189
|
# validate password
|
|
167
190
|
if not self.bcrypt.check_password_hash(existing_user.password, password):
|
|
168
|
-
return {"error":
|
|
191
|
+
return {"error": self.i18n_service.t('errors.signup.incorrect_password_for_existing_user', email=email)}
|
|
169
192
|
|
|
170
193
|
# check if register
|
|
171
194
|
if company in existing_user.companies:
|
|
172
|
-
return {"error":
|
|
195
|
+
return {"error": self.i18n_service.t('errors.signup.user_already_registered', email=email)}
|
|
173
196
|
else:
|
|
174
197
|
# add new company to existing user
|
|
175
198
|
existing_user.companies.append(company)
|
|
176
199
|
self.profile_repo.save_user(existing_user)
|
|
177
|
-
return {"message":
|
|
200
|
+
return {"message": self.i18n_service.t('flash_messages.user_associated_success')}
|
|
178
201
|
|
|
179
202
|
# add the new user
|
|
180
203
|
if password != confirm_password:
|
|
181
|
-
return {"error":
|
|
204
|
+
return {"error": self.i18n_service.t('errors.signup.password_mismatch')}
|
|
182
205
|
|
|
183
206
|
is_valid, message = self.validate_password(password)
|
|
184
207
|
if not is_valid:
|
|
185
|
-
|
|
208
|
+
# Translate the key returned by validate_password
|
|
209
|
+
return {"error": self.i18n_service.t(message)}
|
|
186
210
|
|
|
187
211
|
# encrypt the password
|
|
188
212
|
hashed_password = self.bcrypt.generate_password_hash(password).decode('utf-8')
|
|
@@ -204,9 +228,9 @@ class ProfileService:
|
|
|
204
228
|
# send email with verification
|
|
205
229
|
self.send_verification_email(new_user, company_short_name)
|
|
206
230
|
|
|
207
|
-
return {"message":
|
|
231
|
+
return {"message": self.i18n_service.t('flash_messages.signup_success')}
|
|
208
232
|
except Exception as e:
|
|
209
|
-
return {"error": str(e)}
|
|
233
|
+
return {"error": self.i18n_service.t('errors.general.unexpected_error', error=str(e))}
|
|
210
234
|
|
|
211
235
|
def update_user(self, email: str, **kwargs) -> User:
|
|
212
236
|
return self.profile_repo.update_user(email, **kwargs)
|
|
@@ -216,14 +240,14 @@ class ProfileService:
|
|
|
216
240
|
# check if user exist
|
|
217
241
|
user = self.profile_repo.get_user_by_email(email)
|
|
218
242
|
if not user:
|
|
219
|
-
return {"error":
|
|
243
|
+
return {"error": self.i18n_service.t('errors.verification.user_not_found')}
|
|
220
244
|
|
|
221
245
|
# activate the user account
|
|
222
246
|
self.profile_repo.verify_user(email)
|
|
223
|
-
return {"message":
|
|
247
|
+
return {"message": self.i18n_service.t('flash_messages.account_verified_success')}
|
|
224
248
|
|
|
225
249
|
except Exception as e:
|
|
226
|
-
return {"error":
|
|
250
|
+
return {"error": self.i18n_service.t('errors.general.unexpected_error')}
|
|
227
251
|
|
|
228
252
|
def change_password(self,
|
|
229
253
|
email: str,
|
|
@@ -232,28 +256,28 @@ class ProfileService:
|
|
|
232
256
|
confirm_password: str):
|
|
233
257
|
try:
|
|
234
258
|
if new_password != confirm_password:
|
|
235
|
-
return {"error":
|
|
259
|
+
return {"error": self.i18n_service.t('errors.change_password.password_mismatch')}
|
|
236
260
|
|
|
237
261
|
# check the temporary code
|
|
238
262
|
user = self.profile_repo.get_user_by_email(email)
|
|
239
263
|
if not user or user.temp_code != temp_code:
|
|
240
|
-
return {"error":
|
|
264
|
+
return {"error": self.i18n_service.t('errors.change_password.invalid_temp_code')}
|
|
241
265
|
|
|
242
266
|
# encrypt and save the password, make the temporary code invalid
|
|
243
267
|
hashed_password = self.bcrypt.generate_password_hash(new_password).decode('utf-8')
|
|
244
268
|
self.profile_repo.update_password(email, hashed_password)
|
|
245
269
|
self.profile_repo.reset_temp_code(email)
|
|
246
270
|
|
|
247
|
-
return {"message":
|
|
271
|
+
return {"message": self.i18n_service.t('flash_messages.password_changed_success')}
|
|
248
272
|
except Exception as e:
|
|
249
|
-
return {"error":
|
|
273
|
+
return {"error": self.i18n_service.t('errors.general.unexpected_error')}
|
|
250
274
|
|
|
251
275
|
def forgot_password(self, email: str, reset_url: str):
|
|
252
276
|
try:
|
|
253
277
|
# Verificar si el usuario existe
|
|
254
278
|
user = self.profile_repo.get_user_by_email(email)
|
|
255
279
|
if not user:
|
|
256
|
-
return {"error":
|
|
280
|
+
return {"error": self.i18n_service.t('errors.forgot_password.user_not_registered', email=email)}
|
|
257
281
|
|
|
258
282
|
# Gen a temporary code and store in the repositories
|
|
259
283
|
temp_code = ''.join(random.choices(string.ascii_letters + string.digits, k=6)).upper()
|
|
@@ -262,35 +286,31 @@ class ProfileService:
|
|
|
262
286
|
# send email to the user
|
|
263
287
|
self.send_forgot_password_email(user, reset_url)
|
|
264
288
|
|
|
265
|
-
return {"message":
|
|
289
|
+
return {"message": self.i18n_service.t('flash_messages.forgot_password_success')}
|
|
266
290
|
except Exception as e:
|
|
267
|
-
return {"error":
|
|
291
|
+
return {"error": self.i18n_service.t('errors.general.unexpected_error')}
|
|
268
292
|
|
|
269
293
|
def validate_password(self, password):
|
|
270
294
|
"""
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
- Contiene al menos una letra mayúscula
|
|
274
|
-
- Contiene al menos una letra minúscula
|
|
275
|
-
- Contiene al menos un número
|
|
276
|
-
- Contiene al menos un carácter especial
|
|
295
|
+
Validates that a password meets all requirements.
|
|
296
|
+
Returns (True, "...") on success, or (False, "translation.key") on failure.
|
|
277
297
|
"""
|
|
278
298
|
if len(password) < 8:
|
|
279
|
-
return False, "
|
|
299
|
+
return False, "errors.validation.password_too_short"
|
|
280
300
|
|
|
281
301
|
if not any(char.isupper() for char in password):
|
|
282
|
-
return False, "
|
|
302
|
+
return False, "errors.validation.password_no_uppercase"
|
|
283
303
|
|
|
284
304
|
if not any(char.islower() for char in password):
|
|
285
|
-
return False, "
|
|
305
|
+
return False, "errors.validation.password_no_lowercase"
|
|
286
306
|
|
|
287
307
|
if not any(char.isdigit() for char in password):
|
|
288
|
-
return False, "
|
|
308
|
+
return False, "errors.validation.password_no_digit"
|
|
289
309
|
|
|
290
310
|
if not re.search(r'[!@#$%^&*(),.?":{}|<>]', password):
|
|
291
|
-
return False, "
|
|
311
|
+
return False, "errors.validation.password_no_special_char"
|
|
292
312
|
|
|
293
|
-
return True, "
|
|
313
|
+
return True, "Password is valid."
|
|
294
314
|
|
|
295
315
|
def get_companies(self):
|
|
296
316
|
return self.profile_repo.get_companies()
|
|
@@ -304,7 +324,7 @@ class ProfileService:
|
|
|
304
324
|
def new_api_key(self, company_short_name: str):
|
|
305
325
|
company = self.get_company_by_short_name(company_short_name)
|
|
306
326
|
if not company:
|
|
307
|
-
return {"error":
|
|
327
|
+
return {"error": self.i18n_service.t('errors.company_not_found', company_short_name=company_short_name)}
|
|
308
328
|
|
|
309
329
|
length = 40 # lenght of the api key
|
|
310
330
|
alphabet = string.ascii_letters + string.digits
|