diff --git a/.gitignore b/.gitignore index 1d6f441..b75533c 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ scripts/__pycache__/run_eveai_app.cpython-312.pyc *repo.txt /docker/eveai_logs/ /common/utils/model_utils_orig.py +/integrations/Wordpress/eveai_sync.zip diff --git a/common/models/user.py b/common/models/user.py index ef3ead3..6602d31 100644 --- a/common/models/user.py +++ b/common/models/user.py @@ -34,36 +34,8 @@ class Tenant(db.Model): embedding_model = db.Column(db.String(50), nullable=True) llm_model = db.Column(db.String(50), nullable=True) - # # Embedding variables ==> To be removed once all migrations (dev + prod) have been done - # html_tags = db.Column(ARRAY(sa.String(10)), nullable=True, default=['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li']) - # html_end_tags = db.Column(ARRAY(sa.String(10)), nullable=True, default=['p', 'li']) - # html_included_elements = db.Column(ARRAY(sa.String(50)), nullable=True) - # html_excluded_elements = db.Column(ARRAY(sa.String(50)), nullable=True) - # html_excluded_classes = db.Column(ARRAY(sa.String(200)), nullable=True) - # - # min_chunk_size = db.Column(db.Integer, nullable=True, default=2000) - # max_chunk_size = db.Column(db.Integer, nullable=True, default=3000) - # - # # Embedding search variables - # es_k = db.Column(db.Integer, nullable=True, default=5) - # es_similarity_threshold = db.Column(db.Float, nullable=True, default=0.7) - # - # # Chat variables - # chat_RAG_temperature = db.Column(db.Float, nullable=True, default=0.3) - # chat_no_RAG_temperature = db.Column(db.Float, nullable=True, default=0.5) - fallback_algorithms = db.Column(ARRAY(sa.String(50)), nullable=True) - - # Licensing Information - encrypted_chat_api_key = db.Column(db.String(500), nullable=True) - encrypted_api_key = db.Column(db.String(500), nullable=True) - - # # Tuning enablers - # embed_tuning = db.Column(db.Boolean, nullable=True, default=False) - # rag_tuning = db.Column(db.Boolean, nullable=True, default=False) - # Entitlements currency = db.Column(db.String(20), nullable=True) - usage_email = db.Column(db.String(255), nullable=True) storage_dirty = db.Column(db.Boolean, nullable=True, default=False) # Relations @@ -96,9 +68,7 @@ class Tenant(db.Model): 'allowed_languages': self.allowed_languages, 'embedding_model': self.embedding_model, 'llm_model': self.llm_model, - 'fallback_algorithms': self.fallback_algorithms, 'currency': self.currency, - 'usage_email': self.usage_email, } @@ -140,6 +110,8 @@ class User(db.Model, UserMixin): fs_uniquifier = db.Column(db.String(255), unique=True, nullable=False) confirmed_at = db.Column(db.DateTime, nullable=True) valid_to = db.Column(db.Date, nullable=True) + is_primary_contact = db.Column(db.Boolean, nullable=True, default=False) + is_financial_contact = db.Column(db.Boolean, nullable=True, default=False) # Security Trackable Information last_login_at = db.Column(db.DateTime, nullable=True) @@ -180,3 +152,29 @@ class TenantDomain(db.Model): def __repr__(self): return f"" + +class TenantProject(db.Model): + __bind_key__ = 'public' + __table_args__ = {'schema': 'public'} + + id = db.Column(db.Integer, primary_key=True) + tenant_id = db.Column(db.Integer, db.ForeignKey('public.tenant.id'), nullable=False) + name = db.Column(db.String(50), nullable=False) + description = db.Column(db.Text, nullable=True) + services = db.Column(ARRAY(sa.String(50)), nullable=False) + encrypted_api_key = db.Column(db.String(500), nullable=True) + visual_api_key = db.Column(db.String(20), nullable=True) + active = db.Column(db.Boolean, nullable=False, default=True) + responsible_email = db.Column(db.String(255), nullable=True) + + # Versioning Information + created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now()) + created_by = db.Column(db.Integer, db.ForeignKey('public.user.id'), nullable=True) + updated_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now(), onupdate=db.func.now()) + updated_by = db.Column(db.Integer, db.ForeignKey('public.user.id')) + + # Relations + tenant = db.relationship('Tenant', backref='projects') + + def __repr__(self): + return f"" diff --git a/common/utils/cache/regions.py b/common/utils/cache/regions.py index 7828fd5..d4c20f9 100644 --- a/common/utils/cache/regions.py +++ b/common/utils/cache/regions.py @@ -19,7 +19,8 @@ def get_redis_config(app): 'port': int(redis_uri.port or 6379), 'db': 4, # Keep this for later use 'redis_expiration_time': 3600, - 'distributed_lock': True + 'distributed_lock': True, + 'thread_local_lock': False, } # Add authentication if provided diff --git a/common/utils/document_utils.py b/common/utils/document_utils.py index 54a80ae..1a6ed1f 100644 --- a/common/utils/document_utils.py +++ b/common/utils/document_utils.py @@ -3,27 +3,35 @@ from datetime import datetime as dt, timezone as tz from sqlalchemy import desc from sqlalchemy.exc import SQLAlchemyError from werkzeug.utils import secure_filename -from common.models.document import Document, DocumentVersion +from common.models.document import Document, DocumentVersion, Catalog from common.extensions import db, minio_client from common.utils.celery_utils import current_celery from flask import current_app from flask_security import current_user import requests -from urllib.parse import urlparse, unquote +from urllib.parse import urlparse, unquote, urlunparse import os -from .eveai_exceptions import EveAIInvalidLanguageException, EveAIDoubleURLException, EveAIUnsupportedFileType +from .eveai_exceptions import (EveAIInvalidLanguageException, EveAIDoubleURLException, EveAIUnsupportedFileType, + EveAIInvalidCatalog, EveAIInvalidDocument, EveAIInvalidDocumentVersion) from ..models.user import Tenant def create_document_stack(api_input, file, filename, extension, tenant_id): # Create the Document catalog_id = int(api_input.get('catalog_id')) + catalog = Catalog.query.get(catalog_id) + if not catalog: + raise EveAIInvalidCatalog(tenant_id, catalog_id) new_doc = create_document(api_input, filename, catalog_id) db.session.add(new_doc) + url = api_input.get('url', '') + if url != '': + url = cope_with_local_url(api_input.get('url', '')) + # Create the DocumentVersion new_doc_vers = create_version_for_document(new_doc, tenant_id, - api_input.get('url', ''), + url, api_input.get('sub_file_type', ''), api_input.get('language', 'en'), api_input.get('user_context', ''), @@ -65,7 +73,8 @@ def create_document(form, filename, catalog_id): return new_doc -def create_version_for_document(document, tenant_id, url, sub_file_type, language, user_context, user_metadata, catalog_properties): +def create_version_for_document(document, tenant_id, url, sub_file_type, language, user_context, user_metadata, + catalog_properties): new_doc_vers = DocumentVersion() if url != '': new_doc_vers.url = url @@ -167,6 +176,8 @@ def get_extension_from_content_type(content_type): def process_url(url, tenant_id): + url = cope_with_local_url(url) + response = requests.head(url, allow_redirects=True) content_type = response.headers.get('Content-Type', '').split(';')[0] @@ -198,38 +209,6 @@ def process_url(url, tenant_id): return file_content, filename, extension -def process_multiple_urls(urls, tenant_id, api_input): - results = [] - for url in urls: - try: - file_content, filename, extension = process_url(url, tenant_id) - - url_input = api_input.copy() - url_input.update({ - 'url': url, - 'name': f"{api_input['name']}-{filename}" if api_input['name'] else filename - }) - - new_doc, new_doc_vers = create_document_stack(url_input, file_content, filename, extension, tenant_id) - task_id = start_embedding_task(tenant_id, new_doc_vers.id) - - results.append({ - 'url': url, - 'document_id': new_doc.id, - 'document_version_id': new_doc_vers.id, - 'task_id': task_id, - 'status': 'success' - }) - except Exception as e: - current_app.logger.error(f"Error processing URL {url}: {str(e)}") - results.append({ - 'url': url, - 'status': 'error', - 'message': str(e) - }) - return results - - def start_embedding_task(tenant_id, doc_vers_id): task = current_celery.send_task('create_embeddings', args=[tenant_id, doc_vers_id,], @@ -263,11 +242,16 @@ def get_documents_list(page, per_page): return pagination -def edit_document(document_id, name, valid_from, valid_to): - doc = Document.query.get_or_404(document_id) - doc.name = name - doc.valid_from = valid_from - doc.valid_to = valid_to +def edit_document(tenant_id, document_id, name, valid_from, valid_to): + doc = Document.query.get(document_id) + if not doc: + raise EveAIInvalidDocument(tenant_id, document_id) + if name: + doc.name = name + if valid_from: + doc.valid_from = valid_from + if valid_to: + doc.valid_to = valid_to update_logging_information(doc, dt.now(tz.utc)) try: @@ -279,8 +263,10 @@ def edit_document(document_id, name, valid_from, valid_to): return None, str(e) -def edit_document_version(version_id, user_context, catalog_properties): - doc_vers = DocumentVersion.query.get_or_404(version_id) +def edit_document_version(tenant_id, version_id, user_context, catalog_properties): + doc_vers = DocumentVersion.query.get(version_id) + if not doc_vers: + raise EveAIInvalidDocumentVersion(tenant_id, version_id) doc_vers.user_context = user_context doc_vers.catalog_properties = catalog_properties update_logging_information(doc_vers, dt.now(tz.utc)) @@ -295,15 +281,17 @@ def edit_document_version(version_id, user_context, catalog_properties): def refresh_document_with_info(doc_id, tenant_id, api_input): - doc = Document.query.get_or_404(doc_id) + doc = Document.query.get(doc_id) + if not doc: + raise EveAIInvalidDocument(tenant_id, doc_id) old_doc_vers = DocumentVersion.query.filter_by(doc_id=doc_id).order_by(desc(DocumentVersion.id)).first() - if not old_doc_vers.url: return None, "This document has no URL. Only documents with a URL can be refreshed." new_doc_vers = create_version_for_document( doc, tenant_id, old_doc_vers.url, + old_doc_vers.sub_file_type, api_input.get('language', old_doc_vers.language), api_input.get('user_context', old_doc_vers.user_context), api_input.get('user_metadata', old_doc_vers.user_metadata), @@ -319,11 +307,12 @@ def refresh_document_with_info(doc_id, tenant_id, api_input): db.session.rollback() return None, str(e) - response = requests.head(old_doc_vers.url, allow_redirects=True) + url = cope_with_local_url(old_doc_vers.url) + response = requests.head(url, allow_redirects=True) content_type = response.headers.get('Content-Type', '').split(';')[0] extension = get_extension_from_content_type(content_type) - response = requests.get(old_doc_vers.url) + response = requests.get(url) response.raise_for_status() file_content = response.content @@ -359,3 +348,18 @@ def mark_tenant_storage_dirty(tenant_id): db.session.commit() +def cope_with_local_url(url): + current_app.logger.debug(f'Incomming URL: {url}') + parsed_url = urlparse(url) + # Check if this is an internal WordPress URL (TESTING) and rewrite it + if parsed_url.netloc in [current_app.config['EXTERNAL_WORDPRESS_BASE_URL']]: + parsed_url = parsed_url._replace( + scheme=current_app.config['WORDPRESS_PROTOCOL'], + netloc=f"{current_app.config['WORDPRESS_HOST']}:{current_app.config['WORDPRESS_PORT']}" + ) + url = urlunparse(parsed_url) + current_app.logger.debug(f'Translated Wordpress URL to: {url}') + + return url + + diff --git a/common/utils/eveai_exceptions.py b/common/utils/eveai_exceptions.py index b9937e4..286ed6d 100644 --- a/common/utils/eveai_exceptions.py +++ b/common/utils/eveai_exceptions.py @@ -13,6 +13,9 @@ class EveAIException(Exception): rv['error'] = self.__class__.__name__ return rv + def __str__(self): + return self.message # Return the message when the exception is converted to a string + class EveAIInvalidLanguageException(EveAIException): """Raised when an invalid language is provided""" @@ -45,6 +48,73 @@ class EveAINoLicenseForTenant(EveAIException): class EveAITenantNotFound(EveAIException): """Raised when a tenant is not found""" - def __init__(self, message="Tenant not found", status_code=400, payload=None): + def __init__(self, tenant_id, status_code=400, payload=None): + self.tenant_id = tenant_id + message = f"Tenant {tenant_id} not found" super().__init__(message, status_code, payload) + +class EveAITenantInvalid(EveAIException): + """Raised when a tenant is invalid""" + + def __init__(self, tenant_id, status_code=400, payload=None): + self.tenant_id = tenant_id + # Construct the message dynamically + message = f"Tenant with ID '{tenant_id}' is not valid. Please contact the System Administrator." + super().__init__(message, status_code, payload) + + +class EveAINoActiveLicense(EveAIException): + """Raised when a tenant has no active licenses""" + + def __init__(self, tenant_id, status_code=400, payload=None): + self.tenant_id = tenant_id + # Construct the message dynamically + message = f"Tenant with ID '{tenant_id}' has no active licenses. Please contact the System Administrator." + super().__init__(message, status_code, payload) + + +class EveAIInvalidCatalog(EveAIException): + """Raised when a catalog cannot be found""" + + def __init__(self, tenant_id, catalog_id, status_code=400, payload=None): + self.tenant_id = tenant_id + self.catalog_id = catalog_id + # Construct the message dynamically + message = f"Tenant with ID '{tenant_id}' has no valid catalog with ID {catalog_id}. Please contact the System Administrator." + super().__init__(message, status_code, payload) + + +class EveAIInvalidProcessor(EveAIException): + """Raised when no valid processor can be found for a given Catalog ID""" + + def __init__(self, tenant_id, catalog_id, file_type, status_code=400, payload=None): + self.tenant_id = tenant_id + self.catalog_id = catalog_id + self.file_type = file_type + # Construct the message dynamically + message = (f"Tenant with ID '{tenant_id}' has no valid {file_type} processor for catalog with ID {catalog_id}. " + f"Please contact the System Administrator.") + super().__init__(message, status_code, payload) + + +class EveAIInvalidDocument(EveAIException): + """Raised when a tenant has no document with given ID""" + + def __init__(self, tenant_id, document_id, status_code=400, payload=None): + self.tenant_id = tenant_id + self.document_id = document_id + # Construct the message dynamically + message = f"Tenant with ID '{tenant_id}' has no document with ID {document_id}." + super().__init__(message, status_code, payload) + + +class EveAIInvalidDocumentVersion(EveAIException): + """Raised when a tenant has no document version with given ID""" + + def __init__(self, tenant_id, document_version_id, status_code=400, payload=None): + self.tenant_id = tenant_id + self.document_version_id = document_version_id + # Construct the message dynamically + message = f"Tenant with ID '{tenant_id}' has no document version with ID {document_version_id}." + super().__init__(message, status_code, payload) diff --git a/common/utils/model_utils.py b/common/utils/model_utils.py index ac2f4a0..0c8f5b9 100644 --- a/common/utils/model_utils.py +++ b/common/utils/model_utils.py @@ -82,7 +82,7 @@ class ModelVariables: tenant = Tenant.query.get(self.tenant_id) if not tenant: - raise EveAITenantNotFound(f"Tenant {self.tenant_id} not found") + raise EveAITenantNotFound(self.tenant_id) # Set model providers variables['embedding_provider'], variables['embedding_model'] = tenant.embedding_model.split('.') diff --git a/common/utils/security.py b/common/utils/security.py index b453421..51b86bb 100644 --- a/common/utils/security.py +++ b/common/utils/security.py @@ -1,5 +1,11 @@ from flask import session, current_app +from sqlalchemy import and_ + from common.models.user import Tenant +from common.models.entitlements import License +from common.utils.database import Database +from common.utils.eveai_exceptions import EveAITenantNotFound, EveAITenantInvalid, EveAINoActiveLicense +from datetime import datetime as dt, timezone as tz # Definition of Trigger Handlers @@ -15,4 +21,25 @@ def clear_tenant_session_data(sender, user, **kwargs): session.pop('tenant', None) session.pop('default_language', None) session.pop('default_embedding_model', None) - session.pop('default_llm_model', None) \ No newline at end of file + session.pop('default_llm_model', None) + + +def is_valid_tenant(tenant_id): + if tenant_id == 1: # The 'root' tenant, is always valid + return True + tenant = Tenant.query.get(tenant_id) + Database(tenant).switch_schema() + if tenant is None: + raise EveAITenantNotFound() + elif tenant.type == 'Inactive': + raise EveAITenantInvalid(tenant_id) + else: + current_date = dt.now(tz=tz.utc).date() + active_license = (License.query.filter_by(tenant_id=tenant_id) + .filter(and_(License.start_date <= current_date, + License.end_date >= current_date)) + .one_or_none()) + if not active_license: + raise EveAINoActiveLicense(tenant_id) + + return True \ No newline at end of file diff --git a/common/utils/security_utils.py b/common/utils/security_utils.py index 43198a0..ef43e70 100644 --- a/common/utils/security_utils.py +++ b/common/utils/security_utils.py @@ -93,4 +93,3 @@ def test_smtp_connection(): except Exception as e: current_app.logger.error(f"Failed to connect to SMTP server: {str(e)}") return False - diff --git a/common/utils/simple_encryption.py b/common/utils/simple_encryption.py index 5cfc51b..76c2e8e 100644 --- a/common/utils/simple_encryption.py +++ b/common/utils/simple_encryption.py @@ -4,7 +4,7 @@ from flask import Flask def generate_api_key(prefix="EveAI-Chat"): - parts = [str(random.randint(1000, 9999)) for _ in range(5)] + parts = [str(random.randint(1000, 9999)) for _ in range(8)] return f"{prefix}-{'-'.join(parts)}" diff --git a/config/config.py b/config/config.py index 0adab65..48650cf 100644 --- a/config/config.py +++ b/config/config.py @@ -1,3 +1,4 @@ +import os from os import environ, path from datetime import timedelta import redis @@ -132,7 +133,10 @@ class Config(object): MAIL_USE_SSL = True MAIL_USERNAME = environ.get('MAIL_USERNAME') MAIL_PASSWORD = environ.get('MAIL_PASSWORD') - MAIL_DEFAULT_SENDER = ('eveAI Admin', MAIL_USERNAME) + MAIL_DEFAULT_SENDER = ('Evie', MAIL_USERNAME) + + # Email settings for API key notifications + PROMOTIONAL_IMAGE_URL = 'https://askeveai.com/wp-content/uploads/2024/07/Evie-Call-scaled.jpg' # Replace with your actual URL # Langsmith settings LANGCHAIN_TRACING_V2 = True @@ -142,7 +146,7 @@ class Config(object): SUPPORTED_FILE_TYPES = ['pdf', 'html', 'md', 'txt', 'mp3', 'mp4', 'ogg', 'srt'] - TENANT_TYPES = ['Active', 'Demo', 'Inactive', 'Test'] + TENANT_TYPES = ['Active', 'Demo', 'Inactive', 'Test', 'Wordpress Starter'] # The maximum number of seconds allowed for audio compression (to save resources) MAX_COMPRESSION_DURATION = 60*10 # 10 minutes @@ -153,6 +157,13 @@ class Config(object): # Delay between compressing chunks in seconds COMPRESSION_PROCESS_DELAY = 1 + # WordPress Integration Settings + WORDPRESS_PROTOCOL = os.environ.get('WORDPRESS_PROTOCOL', 'http') + WORDPRESS_HOST = os.environ.get('WORDPRESS_HOST', 'host.docker.internal') + WORDPRESS_PORT = os.environ.get('WORDPRESS_PORT', '10003') + WORDPRESS_BASE_URL = f"{WORDPRESS_PROTOCOL}://{WORDPRESS_HOST}:{WORDPRESS_PORT}" + EXTERNAL_WORDPRESS_BASE_URL = 'localhost:10003' + class DevConfig(Config): DEVELOPMENT = True diff --git a/config/type_defs/__init__.py b/config/type_defs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/catalog_types.py b/config/type_defs/catalog_types.py similarity index 100% rename from config/catalog_types.py rename to config/type_defs/catalog_types.py diff --git a/config/processor_types.py b/config/type_defs/processor_types.py similarity index 100% rename from config/processor_types.py rename to config/type_defs/processor_types.py diff --git a/config/retriever_types.py b/config/type_defs/retriever_types.py similarity index 100% rename from config/retriever_types.py rename to config/type_defs/retriever_types.py diff --git a/config/type_defs/service_types.py b/config/type_defs/service_types.py new file mode 100644 index 0000000..31ed711 --- /dev/null +++ b/config/type_defs/service_types.py @@ -0,0 +1,11 @@ +# Specialist Types +SERVICE_TYPES = { + "CHAT": { + "name": "CHAT", + "description": "Service allows to use CHAT functionality.", + }, + "DOCAPI": { + "name": "DOCAPI", + "description": "Service allows to use document API functionality.", + }, +} diff --git a/config/specialist_types.py b/config/type_defs/specialist_types.py similarity index 100% rename from config/specialist_types.py rename to config/type_defs/specialist_types.py diff --git a/docker/compose_dev.yaml b/docker/compose_dev.yaml index 6b88f1c..d82d89b 100644 --- a/docker/compose_dev.yaml +++ b/docker/compose_dev.yaml @@ -18,8 +18,8 @@ x-common-variables: &common-variables FLASK_DEBUG: true SECRET_KEY: '97867c1491bea5ee6a8e8436eb11bf2ba6a69ff53ab1b17ecba450d0f2e572e1' SECURITY_PASSWORD_SALT: '228614859439123264035565568761433607235' - MAIL_USERNAME: eveai_super@flow-it.net - MAIL_PASSWORD: '$$6xsWGbNtx$$CFMQZqc*' + MAIL_USERNAME: evie@askeveai.com + MAIL_PASSWORD: 'D**0z@UGfJOI@yv3eC5' MAIL_SERVER: mail.flow-it.net MAIL_PORT: 465 REDIS_URL: redis @@ -35,11 +35,6 @@ x-common-variables: &common-variables NGINX_SERVER_NAME: 'localhost http://macstudio.ask-eve-ai-local.com/' LANGCHAIN_API_KEY: "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" - -networks: - eveai-network: - driver: bridge - services: nginx: image: josakola/nginx:latest @@ -59,9 +54,9 @@ services: - ../nginx/sites-enabled:/etc/nginx/sites-enabled - ../nginx/static:/etc/nginx/static - ../nginx/public:/etc/nginx/public - - ../integrations/Wordpress/eveai-chat-widget/css/eveai-chat-style.css:/etc/nginx/static/css/eveai-chat-style.css - - ../integrations/Wordpress/eveai-chat-widget/js/eveai-chat-widget.js:/etc/nginx/static/js/eveai-chat-widget.js - - ../integrations/Wordpress/eveai-chat-widget/js/eveai-sdk.js:/etc/nginx/static/js/eveai-sdk.js + - ../integrations/Wordpress/eveai-chat-widget/public/css/eveai-chat-style.css:/etc/nginx/static/css/eveai-chat-style.css + - ../integrations/Wordpress/eveai-chat-widget/public/js/eveai-chat-widget.js:/etc/nginx/static/js/eveai-chat-widget.js + - ../integrations/Wordpress/eveai-chat-widget/public/js/eveai-sdk.js:/etc/nginx/static/js/eveai-sdk.js - ./logs/nginx:/var/log/nginx depends_on: - eveai_app @@ -207,6 +202,9 @@ services: environment: <<: *common-variables COMPONENT_NAME: eveai_api + WORDPRESS_HOST: host.docker.internal + WORDPRESS_PORT: 10003 + WORDPRESS_PROTOCOL: http volumes: - ../eveai_api:/app/eveai_api - ../common:/app/common @@ -282,7 +280,6 @@ services: networks: - eveai-network - db: hostname: db image: ankane/pgvector @@ -358,6 +355,13 @@ services: networks: - eveai-network +networks: + eveai-network: + driver: bridge + # This enables the containers to access the host network + driver_opts: + com.docker.network.bridge.host_ipc: "true" + volumes: minio_data: eveai_logs: diff --git a/docker/nginx/Dockerfile b/docker/nginx/Dockerfile index 608e80d..77801f5 100644 --- a/docker/nginx/Dockerfile +++ b/docker/nginx/Dockerfile @@ -10,9 +10,9 @@ COPY ../../nginx/mime.types /etc/nginx/mime.types # Copy static & public files RUN mkdir -p /etc/nginx/static /etc/nginx/public COPY ../../nginx/static /etc/nginx/static -COPY ../../integrations/Wordpress/eveai-chat-widget/css/eveai-chat-style.css /etc/nginx/static/css/ -COPY ../../integrations/Wordpress/eveai-chat-widget/js/eveai-chat-widget.js /etc/nginx/static/js/ -COPY ../../integrations/Wordpress/eveai-chat-widget/js/eveai-sdk.js /etc/nginx/static/js +COPY ../../integrations/Wordpress/eveai-chat-widget/public/css/eveai-chat-style.css /etc/nginx/static/css/ +COPY ../../integrations/Wordpress/eveai-chat-widget/public/js/eveai-chat-widget.js /etc/nginx/static/js/ +COPY ../../integrations/Wordpress/eveai-chat-widget/public/js/eveai-sdk.js /etc/nginx/static/js COPY ../../nginx/public /etc/nginx/public # Copy site-specific configurations diff --git a/eveai_api/__init__.py b/eveai_api/__init__.py index 2d6cb53..b968a87 100644 --- a/eveai_api/__init__.py +++ b/eveai_api/__init__.py @@ -1,5 +1,10 @@ +import traceback + from flask import Flask, jsonify, request from flask_jwt_extended import get_jwt_identity, verify_jwt_in_request +from sqlalchemy.exc import SQLAlchemyError +from werkzeug.exceptions import HTTPException + from common.extensions import db, api_rest, jwt, minio_client, simple_encryption import os import logging.config @@ -45,10 +50,8 @@ def create_app(config_file=None): # Register Blueprints register_blueprints(app) - # Error handler for the API - @app.errorhandler(EveAIException) - def handle_eveai_exception(error): - return {'message': str(error)}, error.status_code + # Register Error Handlers + register_error_handlers(app) @app.before_request def before_request(): @@ -91,3 +94,61 @@ def register_blueprints(app): from .views.healthz_views import healthz_bp app.register_blueprint(healthz_bp) + +def register_error_handlers(app): + @app.errorhandler(Exception) + def handle_exception(e): + """Handle all unhandled exceptions with detailed error responses""" + # Get the current exception info + exc_info = traceback.format_exc() + + # Log the full exception details + app.logger.error(f"Unhandled exception: {str(e)}\n{exc_info}") + + # Start with a default error response + response = { + "error": "Internal Server Error", + "message": str(e), + "type": e.__class__.__name__ + } + + status_code = 500 + + # Handle specific types of exceptions + if isinstance(e, HTTPException): + status_code = e.code + response["error"] = e.name + + elif isinstance(e, SQLAlchemyError): + response["error"] = "Database Error" + response["details"] = str(e.__cause__ or e) + + elif isinstance(e, ValueError): + status_code = 400 + response["error"] = "Invalid Input" + + # In development, include additional debug information + if app.debug: + response["debug"] = { + "exception": exc_info, + "class": e.__class__.__name__, + "module": e.__class__.__module__ + } + + return jsonify(response), status_code + + @app.errorhandler(404) + def not_found_error(e): + return jsonify({ + "error": "Not Found", + "message": str(e), + "type": "NotFoundError" + }), 404 + + @app.errorhandler(400) + def bad_request_error(e): + return jsonify({ + "error": "Bad Request", + "message": str(e), + "type": "BadRequestError" + }), 400 diff --git a/eveai_api/api/auth.py b/eveai_api/api/auth.py index 21c3a90..84ca56c 100644 --- a/eveai_api/api/auth.py +++ b/eveai_api/api/auth.py @@ -2,7 +2,7 @@ from datetime import timedelta from flask_restx import Namespace, Resource, fields from flask_jwt_extended import create_access_token -from common.models.user import Tenant +from common.models.user import Tenant, TenantProject from common.extensions import simple_encryption from flask import current_app, request @@ -30,8 +30,9 @@ class Token(Resource): """ Get JWT token """ + current_app.logger.debug(f'Token Requested {auth_ns.payload}') try: - tenant_id = auth_ns.payload['tenant_id'] + tenant_id = int(auth_ns.payload['tenant_id']) api_key = auth_ns.payload['api_key'] except KeyError as e: current_app.logger.error(f"Missing required field: {e}") @@ -41,18 +42,34 @@ class Token(Resource): if not tenant: current_app.logger.error(f"Tenant not found: {tenant_id}") - return {'message': "Tenant not found"}, 404 + return {'message': f"Authentication invalid for tenant {tenant_id}"}, 404 - try: - decrypted_api_key = simple_encryption.decrypt_api_key(tenant.encrypted_api_key) - except Exception as e: - current_app.logger.error(f"Error decrypting API key: {e}") - return {'message': "Internal server error"}, 500 + projects = TenantProject.query.filter_by( + tenant_id=tenant_id, + active=True + ).all() - if api_key != decrypted_api_key: - current_app.logger.error(f"Invalid API key for tenant: {tenant_id}") + # Find project with matching API key + matching_project = None + for project in projects: + try: + decrypted_key = simple_encryption.decrypt_api_key(project.encrypted_api_key) + if decrypted_key == api_key: + matching_project = project + break + except Exception as e: + current_app.logger.error(f"Error decrypting API key for project {project.id}: {e}") + continue + + if not matching_project: + current_app.logger.error(f"Project for given API key not found for Tenant: {tenant_id}") return {'message': "Invalid API key"}, 401 + if "DOCAPI" not in matching_project.services: + current_app.logger.error(f"Service DOCAPI not authorized for Project {matching_project.name} " + f"for Tenant: {tenant_id}") + return {'message': f"Service DOCAPI not authorized for Project {matching_project.name}"}, 403 + # Get the JWT_ACCESS_TOKEN_EXPIRES setting from the app config expires_delta = current_app.config.get('JWT_ACCESS_TOKEN_EXPIRES', timedelta(minutes=15)) diff --git a/eveai_api/api/document_api.py b/eveai_api/api/document_api.py index 01e7aab..3e5e004 100644 --- a/eveai_api/api/document_api.py +++ b/eveai_api/api/document_api.py @@ -10,9 +10,10 @@ from werkzeug.utils import secure_filename from common.utils.document_utils import ( create_document_stack, process_url, start_embedding_task, validate_file_type, EveAIInvalidLanguageException, EveAIDoubleURLException, EveAIUnsupportedFileType, - process_multiple_urls, get_documents_list, edit_document, refresh_document, edit_document_version, + get_documents_list, edit_document, refresh_document, edit_document_version, refresh_document_with_info ) +from common.utils.eveai_exceptions import EveAIException def validate_date(date_str): @@ -212,14 +213,23 @@ class DocumentResource(Resource): @document_ns.doc('edit_document') @document_ns.expect(edit_document_model) @document_ns.response(200, 'Document updated successfully') + @document_ns.response(400, 'Validation Error') + @document_ns.response(404, 'Document not found') + @document_ns.response(500, 'Internal Server Error') def put(self, document_id): """Edit a document""" - data = request.json - updated_doc, error = edit_document(document_id, data['name'], data.get('valid_from'), data.get('valid_to')) - if updated_doc: - return {'message': f'Document {updated_doc.id} updated successfully'}, 200 - else: - return {'message': f'Error updating document: {error}'}, 400 + try: + current_app.logger.debug(f'Editing document {document_id}') + data = request.json + tenant_id = get_jwt_identity() + updated_doc, error = edit_document(tenant_id, document_id, data.get('name', None), + data.get('valid_from', None), data.get('valid_to', None)) + if updated_doc: + return {'message': f'Document {updated_doc.id} updated successfully'}, 200 + else: + return {'message': f'Error updating document: {error}'}, 400 + except EveAIException as e: + return e.to_dict(), e.status_code @jwt_required() @document_ns.doc('refresh_document') @@ -249,7 +259,8 @@ class DocumentVersionResource(Resource): def put(self, version_id): """Edit a document version""" data = request.json - updated_version, error = edit_document_version(version_id, data['user_context'], data.get('catalog_properties')) + tenant_id = get_jwt_identity() + updated_version, error = edit_document_version(tenant_id, version_id, data['user_context'], data.get('catalog_properties')) if updated_version: return {'message': f'Document Version {updated_version.id} updated successfully'}, 200 else: diff --git a/eveai_app/templates/document/catalogs.html b/eveai_app/templates/document/catalogs.html index a81daa4..5827e43 100644 --- a/eveai_app/templates/document/catalogs.html +++ b/eveai_app/templates/document/catalogs.html @@ -10,7 +10,7 @@ {% block content %}
- {{ render_selectable_table(headers=["Catalog ID", "Name"], rows=rows, selectable=True, id="catalogsTable") }} + {{ render_selectable_table(headers=["Catalog ID", "Name", "Type"], rows=rows, selectable=True, id="catalogsTable") }}
diff --git a/eveai_app/templates/email/api_key_notification.html b/eveai_app/templates/email/api_key_notification.html new file mode 100644 index 0000000..ba9223e --- /dev/null +++ b/eveai_app/templates/email/api_key_notification.html @@ -0,0 +1,28 @@ +{% extends "email/base.html" %} +{% block content %} +

Hello,

+ +

A new API project has been created for your Ask Eve AI tenant. Here are the details:

+ +
+

Tenant ID: {{ tenant_id }}

+

Tenant Name: {{ tenant_name }}

+

Project Name: {{ project_name }}

+

API Key: {{ api_key }}

+ +
+

Enabled Services:

+
    + {% for service in services %} +
  • ✓ {{ service }}
  • + {% endfor %} +
+
+
+ +
+ Important: Please store this API key securely. It cannot be retrieved once this email is gone. +
+ +

You can start using this API key right away to interact with our services. For documentation and usage examples, please visit our documentation.

+{% endblock %} \ No newline at end of file diff --git a/eveai_app/templates/email/base.html b/eveai_app/templates/email/base.html new file mode 100644 index 0000000..d1109b9 --- /dev/null +++ b/eveai_app/templates/email/base.html @@ -0,0 +1,106 @@ + + + + + + {{ subject|default('Message from Ask Eve AI') }} + + + + + + \ No newline at end of file diff --git a/eveai_app/templates/entitlements/view_licenses.html b/eveai_app/templates/entitlements/view_licenses.html new file mode 100644 index 0000000..f76534d --- /dev/null +++ b/eveai_app/templates/entitlements/view_licenses.html @@ -0,0 +1,28 @@ +{% extends 'base.html' %} +{% from "macros.html" import render_selectable_table, render_pagination %} + +{% block title %}View Licenses{% endblock %} + +{% block content_title %}View Licenses{% endblock %} +{% block content_description %}View Licenses{% endblock %} + +{% block content %} + + {{ render_selectable_table(headers=["License ID", "Name", "Start Date", "End Date", "Active"], rows=rows, selectable=True, id="licensesTable") }} + + + + + + + + +{% endblock %} + +{% block content_footer %} + {{ render_pagination(pagination, 'entitlements_bp.view_licenses') }} +{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/eveai_app/templates/entitlements/view_usages.html b/eveai_app/templates/entitlements/view_usages.html index 1bfb432..5003615 100644 --- a/eveai_app/templates/entitlements/view_usages.html +++ b/eveai_app/templates/entitlements/view_usages.html @@ -7,7 +7,7 @@ {% block content_description %}View License Usage{% endblock %} {% block content %} -
+ {{ render_selectable_table(headers=["Usage ID", "Start Date", "End Date", "Storage (MiB)", "Embedding (MiB)", "Interaction (tokens)"], rows=rows, selectable=False, id="usagesTable") }} @@ -20,7 +20,7 @@ {% endblock %} {% block content_footer %} - {{ render_pagination(pagination, 'user_bp.select_tenant') }} + {{ render_pagination(pagination, 'entitlements_bp.view_usages') }} {% endblock %} {% block scripts %} diff --git a/eveai_app/templates/macros.html b/eveai_app/templates/macros.html index 49ba40d..5538426 100644 --- a/eveai_app/templates/macros.html +++ b/eveai_app/templates/macros.html @@ -1,86 +1,3 @@ - - - - - - - - - - - - - - - - - - - - - - - - - -{% macro render_field_old(field, disabled_fields=[], exclude_fields=[], class='') %} - - - - {% set disabled = field.name in disabled_fields %} - {% set exclude_fields = exclude_fields + ['csrf_token', 'submit'] %} - {% if field.name not in exclude_fields %} - {% if field.type == 'BooleanField' %} -
-
- {{ field(class="form-check-input " + class, disabled=disabled) }} - {% if field.description %} - {{ field.label(class="form-check-label", - **{'data-bs-toggle': 'tooltip', - 'data-bs-placement': 'right', - 'title': field.description}) }} - {% else %} - {{ field.label(class="form-check-label") }} - {% endif %} -
- {% if field.errors %} -
- {% for error in field.errors %} - {{ error }} - {% endfor %} -
- {% endif %} -
- {% else %} -
- {% if field.description %} - {{ field.label(class="form-label", - **{'data-bs-toggle': 'tooltip', - 'data-bs-placement': 'right', - 'title': field.description}) }} - {% else %} - {{ field.label(class="form-label") }} - {% endif %} - - {% if field.type == 'TextAreaField' and 'json-editor' in class %} -
- {{ field(class="form-control d-none " + class, disabled=disabled) }} - {% else %} - {{ field(class="form-control " + class, disabled=disabled) }} - {% endif %} - - {% if field.errors %} -
- {% for error in field.errors %} - {{ error }} - {% endfor %} -
- {% endif %} -
- {% endif %} - {% endif %} -{% endmacro %} - {% macro render_field(field, disabled_fields=[], exclude_fields=[], class='') %} @@ -97,8 +14,20 @@ **{'data-bs-toggle': 'tooltip', 'data-bs-placement': 'right', 'title': field.description}) }} + {% if field.flags.required %} + + Required field + {% endif %} {% else %} {{ field.label(class="form-check-label") }} + {% if field.flags.required %} + + Required field + {% endif %} {% endif %}
{% if field.errors %} @@ -116,8 +45,20 @@ **{'data-bs-toggle': 'tooltip', 'data-bs-placement': 'right', 'title': field.description}) }} + {% if field.flags.required %} + + Required field + {% endif %} {% else %} {{ field.label(class="form-label") }} + {% if field.flags.required %} + + Required field + {% endif %} {% endif %} {% if field.type == 'TextAreaField' and 'json-editor' in class %} @@ -147,14 +88,67 @@ {% if field.type == 'BooleanField' %}
{{ field(class="form-check-input", type="checkbox", id="flexSwitchCheckDefault") }} - {{ field.label(class="form-check-label", for="flexSwitchCheckDefault", disabled=disabled) }} + {% if field.description %} + {{ field.label(class="form-check-label", + for="flexSwitchCheckDefault", + disabled=disabled, + **{'data-bs-toggle': 'tooltip', + 'data-bs-placement': 'right', + 'title': field.description}) }} + {% if field.flags.required %} + + Required field + {% endif %} + {% else %} + {{ field.label(class="form-check-label", for="flexSwitchCheckDefault", disabled=disabled) }} + {% if field.flags.required %} + + Required field + {% endif %} + {% endif %}
{% else %}
- {{ field.label(class="form-label") }} - {{ field(class="form-control", disabled=disabled) }} + {% if field.description %} +
+ {{ field.label(class="form-label", + **{'data-bs-toggle': 'tooltip', + 'data-bs-placement': 'right', + 'title': field.description}) }} + {% if field.flags.required %} + + Required field + {% endif %} +
+ {% else %} +
+ {{ field.label(class="form-label") }} + {% if field.flags.required %} + + Required field + {% endif %} +
+ {% endif %} + + {% if field.type == 'TextAreaField' and 'json-editor' in field.render_kw.get('class', '') %} +
+ {{ field(class="form-control d-none", disabled=disabled) }} + {% elif field.type == 'SelectField' %} + {{ field(class="form-control form-select", disabled=disabled) }} + {% else %} + {{ field(class="form-control", disabled=disabled) }} + {% endif %} + {% if field.errors %} -
+
{% for error in field.errors %} {{ error }} {% endfor %} diff --git a/eveai_app/templates/navbar.html b/eveai_app/templates/navbar.html index 548e793..8515f32 100644 --- a/eveai_app/templates/navbar.html +++ b/eveai_app/templates/navbar.html @@ -75,6 +75,8 @@ {'name': 'Edit Tenant', 'url': '/user/tenant/' ~ session['tenant'].get('id'), 'roles': ['Super User', 'Tenant Admin']}, {'name': 'Tenant Domains', 'url': '/user/view_tenant_domains', 'roles': ['Super User', 'Tenant Admin']}, {'name': 'Tenant Domain Registration', 'url': '/user/tenant_domain', 'roles': ['Super User', 'Tenant Admin']}, + {'name': 'Tenant Projects', 'url': '/user/tenant_projects', 'roles': ['Super User', 'Tenant Admin']}, + {'name': 'Tenant Project Registration', 'url': '/user/tenant_project', 'roles': ['Super User', 'Tenant Admin']}, {'name': 'User List', 'url': '/user/view_users', 'roles': ['Super User', 'Tenant Admin']}, {'name': 'User Registration', 'url': '/user/user', 'roles': ['Super User', 'Tenant Admin']}, ]) }} @@ -107,6 +109,7 @@ {'name': 'License Tier Registration', 'url': '/entitlements/license_tier', 'roles': ['Super User']}, {'name': 'All License Tiers', 'url': '/entitlements/view_license_tiers', 'roles': ['Super User']}, {'name': 'Trigger Actions', 'url': '/administration/trigger_actions', 'roles': ['Super User']}, + {'name': 'All Licenses', 'url': 'entitlements/view_licenses', 'roles': ['Super User', 'Tenant Admin']}, {'name': 'Usage', 'url': '/entitlements/view_usages', 'roles': ['Super User', 'Tenant Admin']}, ]) }} {% endif %} @@ -122,17 +125,6 @@ {% endif %} {% if current_user.is_authenticated %} -