- Modernized authentication with the introduction of TenantProject
- Created a base mail template - Adapt and improve document API to usage of catalogs and processors - Adapt eveai_sync to new authentication mechanism and usage of catalogs and processors
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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"<TenantDomain {self.id}: {self.domain}>"
|
||||
|
||||
|
||||
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"<TenantProject {self.id}: {self.name}>"
|
||||
|
||||
3
common/utils/cache/regions.py
vendored
3
common/utils/cache/regions.py
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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('.')
|
||||
|
||||
@@ -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)
|
||||
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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)}"
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
0
config/type_defs/__init__.py
Normal file
0
config/type_defs/__init__.py
Normal file
11
config/type_defs/service_types.py
Normal file
11
config/type_defs/service_types.py
Normal file
@@ -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.",
|
||||
},
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<form method="POST" action="{{ url_for('document_bp.handle_catalog_selection') }}">
|
||||
{{ 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") }}
|
||||
<div class="form-group mt-3">
|
||||
<button type="submit" name="action" value="set_session_catalog" class="btn btn-primary">Set Session Catalog</button>
|
||||
<button type="submit" name="action" value="edit_catalog" class="btn btn-primary">Edit Catalog</button>
|
||||
|
||||
28
eveai_app/templates/email/api_key_notification.html
Normal file
28
eveai_app/templates/email/api_key_notification.html
Normal file
@@ -0,0 +1,28 @@
|
||||
{% extends "email/base.html" %}
|
||||
{% block content %}
|
||||
<p>Hello,</p>
|
||||
|
||||
<p>A new API project has been created for your Ask Eve AI tenant. Here are the details:</p>
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>Tenant ID:</strong> {{ tenant_id }}</p>
|
||||
<p><strong>Tenant Name:</strong> {{ tenant_name }}</p>
|
||||
<p><strong>Project Name:</strong> {{ project_name }}</p>
|
||||
<p><strong>API Key:</strong> <span style="font-family: monospace; background-color: #f0f0f0; padding: 5px;">{{ api_key }}</span></p>
|
||||
|
||||
<div style="margin-top: 15px;">
|
||||
<p><strong>Enabled Services:</strong></p>
|
||||
<ul style="list-style-type: none; padding-left: 0;">
|
||||
{% for service in services %}
|
||||
<li>✓ {{ service }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="warning-box">
|
||||
<strong>Important:</strong> Please store this API key securely. It cannot be retrieved once this email is gone.
|
||||
</div>
|
||||
|
||||
<p>You can start using this API key right away to interact with our services. For documentation and usage examples, please visit our <a href="https://docs.askeveai.com">documentation</a>.</p>
|
||||
{% endblock %}
|
||||
106
eveai_app/templates/email/base.html
Normal file
106
eveai_app/templates/email/base.html
Normal file
@@ -0,0 +1,106 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ subject|default('Message from Ask Eve AI') }}</title>
|
||||
<style>
|
||||
.email-container {
|
||||
font-family: Tahoma, Geneva, sans-serif;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.header img {
|
||||
max-width: 200px;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.signature {
|
||||
font-style: italic;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.footer-text {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
.footer img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
width: 600px; /* Match the container width */
|
||||
display: block;
|
||||
margin: 20px auto;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.footer img {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.social-links {
|
||||
margin: 20px 0;
|
||||
}
|
||||
.social-links a {
|
||||
margin: 0 10px;
|
||||
color: #0066cc;
|
||||
text-decoration: none;
|
||||
}
|
||||
.info-box {
|
||||
background-color: #f8f9fa;
|
||||
border-left: 4px solid #0066cc;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.warning-box {
|
||||
background-color: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="header">
|
||||
<img src="https://askeveai.com/wp-content/uploads/2024/07/Logo-Square-small.png" alt="Ask Eve AI Logo">
|
||||
</div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div class="signature">
|
||||
Best regards,<br>
|
||||
Evie
|
||||
</div>
|
||||
|
||||
{% if promo_image_url %}
|
||||
<a href="https://www.askeveai.com">
|
||||
<img src="{{ promo_image_url }}" alt="Ask Eve AI Promotion">
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<div class="social-links">
|
||||
<a href="https://twitter.com/askeveai">Twitter</a>
|
||||
<a href="https://linkedin.com/company/ask-eve-ai">LinkedIn</a>
|
||||
</div>
|
||||
|
||||
<div class="footer-text">
|
||||
© {{ year }} Ask Eve AI. All rights reserved.<br>
|
||||
<a href="https://www.askeveai.com/privacy">Privacy Policy</a> |
|
||||
<a href="https://www.askeveai.com/terms">Terms of Service</a>
|
||||
{% if unsubscribe_url %}
|
||||
| <a href="{{ unsubscribe_url }}">Unsubscribe</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
28
eveai_app/templates/entitlements/view_licenses.html
Normal file
28
eveai_app/templates/entitlements/view_licenses.html
Normal file
@@ -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 %}
|
||||
<form action="{{ url_for('entitlements_bp.handle_license_selection') }}" method="POST">
|
||||
{{ render_selectable_table(headers=["License ID", "Name", "Start Date", "End Date", "Active"], rows=rows, selectable=True, id="licensesTable") }}
|
||||
<!-- <div class="form-group mt-3">-->
|
||||
<!-- <button type="submit" name="action" value="edit_user" class="btn btn-primary">Edit Selected User</button>-->
|
||||
<!-- <button type="submit" name="action" value="resend_confirmation_email" class="btn btn-secondary">Resend Confirmation Email</button>-->
|
||||
<!-- <button type="submit" name="action" value="send_password_reset_email" class="btn btn-secondary">Send Password Reset Email</button>-->
|
||||
<!-- <button type="submit" name="action" value="reset_uniquifier" class="btn btn-secondary">Reset Uniquifier</button>-->
|
||||
<!-- <!– Additional buttons can be added here for other actions –>-->
|
||||
<!-- </div>-->
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block content_footer %}
|
||||
{{ render_pagination(pagination, 'entitlements_bp.view_licenses') }}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -7,7 +7,7 @@
|
||||
{% block content_description %}View License Usage{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form action="{{ url_for('user_bp.handle_user_action') }}" method="POST">
|
||||
<form action="{{ url_for('entitlements_bp.handle_usage_selection') }}" method="POST">
|
||||
{{ render_selectable_table(headers=["Usage ID", "Start Date", "End Date", "Storage (MiB)", "Embedding (MiB)", "Interaction (tokens)"], rows=rows, selectable=False, id="usagesTable") }}
|
||||
<!-- <div class="form-group mt-3">-->
|
||||
<!-- <button type="submit" name="action" value="edit_user" class="btn btn-primary">Edit Selected User</button>-->
|
||||
@@ -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 %}
|
||||
|
||||
@@ -1,86 +1,3 @@
|
||||
<!--{% macro render_field(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' %}-->
|
||||
<!-- <div class="form-check">-->
|
||||
<!-- {{ field(class="form-check-input " + class, type="checkbox", id="flexSwitchCheckDefault") }}-->
|
||||
<!-- {{ field.label(class="form-check-label", for="flexSwitchCheckDefault", disabled=disabled) }}-->
|
||||
<!-- </div>-->
|
||||
<!-- {% else %}-->
|
||||
<!-- <div class="form-group">-->
|
||||
<!-- {{ field.label(class="form-label") }}-->
|
||||
<!-- {{ field(class="form-control " + class, disabled=disabled) }}-->
|
||||
<!-- {% if field.errors %}-->
|
||||
<!-- <div class="invalid-feedback">-->
|
||||
<!-- {% for error in field.errors %}-->
|
||||
<!-- {{ error }}-->
|
||||
<!-- {% endfor %}-->
|
||||
<!-- </div>-->
|
||||
<!-- {% endif %}-->
|
||||
<!-- </div>-->
|
||||
<!-- {% endif %}-->
|
||||
<!-- {% endif %}-->
|
||||
<!--{% endmacro %}-->
|
||||
|
||||
{% macro render_field_old(field, disabled_fields=[], exclude_fields=[], class='') %}
|
||||
<!-- Debug info -->
|
||||
<!-- Field name: {{ field.name }}, Field type: {{ field.__class__.__name__ }} -->
|
||||
|
||||
{% 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' %}
|
||||
<div class="form-group">
|
||||
<div class="form-check form-switch">
|
||||
{{ 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 %}
|
||||
</div>
|
||||
{% if field.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in field.errors %}
|
||||
{{ error }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-group">
|
||||
{% 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 %}
|
||||
<div id="{{ field.id }}-editor" class="json-editor-container"></div>
|
||||
{{ field(class="form-control d-none " + class, disabled=disabled) }}
|
||||
{% else %}
|
||||
{{ field(class="form-control " + class, disabled=disabled) }}
|
||||
{% endif %}
|
||||
|
||||
{% if field.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in field.errors %}
|
||||
{{ error }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro render_field(field, disabled_fields=[], exclude_fields=[], class='') %}
|
||||
<!-- Debug info -->
|
||||
<!-- Field name: {{ field.name }}, Field type: {{ field.__class__.__name__ }} -->
|
||||
@@ -97,8 +14,20 @@
|
||||
**{'data-bs-toggle': 'tooltip',
|
||||
'data-bs-placement': 'right',
|
||||
'title': field.description}) }}
|
||||
{% if field.flags.required %}
|
||||
<span class="required-field-indicator" aria-hidden="true">
|
||||
<i class="material-symbols-outlined required-icon">check_circle</i>
|
||||
</span>
|
||||
<span class="visually-hidden">Required field</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{{ field.label(class="form-check-label") }}
|
||||
{% if field.flags.required %}
|
||||
<span class="required-field-indicator" aria-hidden="true">
|
||||
<i class="material-symbols-outlined required-icon">check_circle</i>
|
||||
</span>
|
||||
<span class="visually-hidden">Required field</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if field.errors %}
|
||||
@@ -116,8 +45,20 @@
|
||||
**{'data-bs-toggle': 'tooltip',
|
||||
'data-bs-placement': 'right',
|
||||
'title': field.description}) }}
|
||||
{% if field.flags.required %}
|
||||
<span class="required-field-indicator" aria-hidden="true">
|
||||
<i class="material-symbols-outlined required-icon">check_circle</i>
|
||||
</span>
|
||||
<span class="visually-hidden">Required field</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{{ field.label(class="form-label") }}
|
||||
{% if field.flags.required %}
|
||||
<span class="required-field-indicator" aria-hidden="true">
|
||||
<i class="material-symbols-outlined required-icon">check_circle</i>
|
||||
</span>
|
||||
<span class="visually-hidden">Required field</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if field.type == 'TextAreaField' and 'json-editor' in class %}
|
||||
@@ -147,14 +88,67 @@
|
||||
{% if field.type == 'BooleanField' %}
|
||||
<div class="form-check">
|
||||
{{ 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 %}
|
||||
<span class="required-field-indicator" aria-hidden="true">
|
||||
<i class="material-symbols-outlined required-icon">check_circle</i>
|
||||
</span>
|
||||
<span class="visually-hidden">Required field</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{{ field.label(class="form-check-label", for="flexSwitchCheckDefault", disabled=disabled) }}
|
||||
{% if field.flags.required %}
|
||||
<span class="required-field-indicator" aria-hidden="true">
|
||||
<i class="material-symbols-outlined required-icon">check_circle</i>
|
||||
</span>
|
||||
<span class="visually-hidden">Required field</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-group">
|
||||
{{ field.label(class="form-label") }}
|
||||
{{ field(class="form-control", disabled=disabled) }}
|
||||
{% if field.description %}
|
||||
<div class="field-label-wrapper">
|
||||
{{ field.label(class="form-label",
|
||||
**{'data-bs-toggle': 'tooltip',
|
||||
'data-bs-placement': 'right',
|
||||
'title': field.description}) }}
|
||||
{% if field.flags.required %}
|
||||
<span class="required-field-indicator" aria-hidden="true">
|
||||
<i class="material-symbols-outlined required-icon">check_circle</i>
|
||||
</span>
|
||||
<span class="visually-hidden">Required field</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="field-label-wrapper">
|
||||
{{ field.label(class="form-label") }}
|
||||
{% if field.flags.required %}
|
||||
<span class="required-field-indicator" aria-hidden="true">
|
||||
<i class="material-symbols-outlined required-icon">check_circle</i>
|
||||
</span>
|
||||
<span class="visually-hidden">Required field</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if field.type == 'TextAreaField' and 'json-editor' in field.render_kw.get('class', '') %}
|
||||
<div id="{{ field.id }}-editor" class="json-editor-container"></div>
|
||||
{{ 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 %}
|
||||
<div class="invalid-feedback">
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in field.errors %}
|
||||
{{ error }}
|
||||
{% endfor %}
|
||||
|
||||
@@ -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 %}
|
||||
</ul>
|
||||
{% if current_user.is_authenticated %}
|
||||
<ul class="navbar-nav d-lg-block d-none">
|
||||
<li class="nav-item">
|
||||
<a href="/document/catalogs" class="btn btn-sm bg-gradient-primary mb-0 me-2">
|
||||
{% if 'catalog_name' in session %}
|
||||
CATALOG: {{ session['catalog_name'] }}
|
||||
{% else %}
|
||||
CHOOSE CATALOG
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="navbar-nav d-lg-block d-none">
|
||||
<li class="nav-item">
|
||||
<a href="/session_defaults" class="btn btn-sm bg-gradient-primary mb-0">
|
||||
|
||||
@@ -59,6 +59,78 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Get all forms with tabs
|
||||
const formsWithTabs = document.querySelectorAll('form');
|
||||
|
||||
formsWithTabs.forEach(form => {
|
||||
// Handle the form's submit event
|
||||
form.addEventListener('submit', function(event) {
|
||||
const invalidFields = form.querySelectorAll(':invalid');
|
||||
|
||||
if (invalidFields.length > 0) {
|
||||
// Prevent form submission
|
||||
event.preventDefault();
|
||||
|
||||
// Find which tab contains the first invalid field
|
||||
const firstInvalidField = invalidFields[0];
|
||||
const tabPane = firstInvalidField.closest('.tab-pane');
|
||||
|
||||
if (tabPane) {
|
||||
// Get the tab ID
|
||||
const tabId = tabPane.id;
|
||||
|
||||
// Find and click the corresponding tab button
|
||||
const tabButton = document.querySelector(`[data-bs-toggle="tab"][data-bs-target="#${tabId}"]`);
|
||||
if (tabButton) {
|
||||
const tab = new bootstrap.Tab(tabButton);
|
||||
tab.show();
|
||||
}
|
||||
|
||||
// Scroll the invalid field into view and focus it
|
||||
firstInvalidField.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
firstInvalidField.focus();
|
||||
}
|
||||
|
||||
// Optional: Show a message about validation errors
|
||||
const errorCount = invalidFields.length;
|
||||
const message = `Please fill in all required fields (${errorCount} ${errorCount === 1 ? 'error' : 'errors'} found)`;
|
||||
if (typeof Swal !== 'undefined') {
|
||||
// If SweetAlert2 is available
|
||||
Swal.fire({
|
||||
title: 'Validation Error',
|
||||
text: message,
|
||||
icon: 'error',
|
||||
confirmButtonText: 'OK'
|
||||
});
|
||||
} else {
|
||||
// Fallback to browser alert
|
||||
alert(message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Optional: Real-time validation as user switches tabs
|
||||
const tabButtons = document.querySelectorAll('[data-bs-toggle="tab"]');
|
||||
tabButtons.forEach(button => {
|
||||
button.addEventListener('shown.bs.tab', function() {
|
||||
const previousTabPane = document.querySelector(button.getAttribute('data-bs-target'));
|
||||
if (previousTabPane) {
|
||||
const invalidFields = previousTabPane.querySelectorAll(':invalid');
|
||||
if (invalidFields.length > 0) {
|
||||
// Add visual indicator to tab
|
||||
button.classList.add('has-error');
|
||||
} else {
|
||||
button.classList.remove('has-error');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<style>
|
||||
.json-editor-container {
|
||||
|
||||
28
eveai_app/templates/user/confirm_delete_tenant_project.html
Normal file
28
eveai_app/templates/user/confirm_delete_tenant_project.html
Normal file
@@ -0,0 +1,28 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Delete Tenant Project{% endblock %}
|
||||
|
||||
{% block content_title %}Delete Tenant Project{% endblock %}
|
||||
{% block content_description %}Are you sure you want to delete this tenant project?{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="alert alert-warning">
|
||||
<p><strong>Warning:</strong> You are about to delete the following tenant project:</p>
|
||||
<ul>
|
||||
<li><strong>Name:</strong> {{ tenant_project.name }}</li>
|
||||
<li><strong>API Key:</strong> {{ tenant_project.visual_api_key }}</li>
|
||||
<li><strong>Responsible:</strong> {{ tenant_project.responsible_email or 'Not specified' }}</li>
|
||||
</ul>
|
||||
<p>This action cannot be undone.</p>
|
||||
</div>
|
||||
|
||||
<form method="POST">
|
||||
{{ form.csrf_token if form }}
|
||||
<div class="form-group mt-3">
|
||||
<a href="{{ url_for('user_bp.tenant_projects') }}" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-danger">Confirm Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
26
eveai_app/templates/user/edit_tenant_project.html
Normal file
26
eveai_app/templates/user/edit_tenant_project.html
Normal file
@@ -0,0 +1,26 @@
|
||||
{% extends 'base.html' %}
|
||||
{% from "macros.html" import render_field %}
|
||||
|
||||
{% block title %}Edit Tenant Project{% endblock %}
|
||||
|
||||
{% block content_title %}Edit Tenant Project{% endblock %}
|
||||
{% block content_description %}Edit a Tenant Project. It is impossible to view of renew the existing API key.
|
||||
You need to invalidate the current project, and create a new one.
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post">
|
||||
{{ form.hidden_tag() }}
|
||||
{% set disabled_fields = [] %}
|
||||
{% set exclude_fields = [] %}
|
||||
<!-- Render Static Fields -->
|
||||
{% for field in form %}
|
||||
{{ render_field(field, disabled_fields, exclude_fields) }}
|
||||
{% endfor %}
|
||||
<button type="submit" class="btn btn-primary">Save Tenant Project</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block content_footer %}
|
||||
|
||||
{% endblock %}
|
||||
23
eveai_app/templates/user/tenant_project.html
Normal file
23
eveai_app/templates/user/tenant_project.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{% extends 'base.html' %}
|
||||
{% from "macros.html" import render_field %}
|
||||
|
||||
{% block title %}Tenant Project Registration{% endblock %}
|
||||
|
||||
{% block content_title %}Register Tenant Project{% endblock %}
|
||||
{% block content_description %}Define a new tenant project to enable APIs{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post">
|
||||
{{ form.hidden_tag() }}
|
||||
{% set disabled_fields = [] %}
|
||||
{% set exclude_fields = [] %}
|
||||
{% for field in form %}
|
||||
{{ render_field(field, disabled_fields, exclude_fields) }}
|
||||
{% endfor %}
|
||||
<button type="submit" class="btn btn-primary">Register Tenant Project</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block content_footer %}
|
||||
|
||||
{% endblock %}
|
||||
25
eveai_app/templates/user/tenant_projects.html
Normal file
25
eveai_app/templates/user/tenant_projects.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{% extends 'base.html' %}
|
||||
{% from 'macros.html' import render_selectable_table, render_pagination %}
|
||||
|
||||
{% block title %}Documents{% endblock %}
|
||||
|
||||
{% block content_title %}Tenant Projects{% endblock %}
|
||||
{% block content_description %}View Tenant Projects for Tenant{% endblock %}
|
||||
{% block content_class %}<div class="col-xl-12 col-lg-5 col-md-7 mx-auto"></div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<form method="POST" action="{{ url_for("user_bp.handle_tenant_project_selection") }}">
|
||||
{{ render_selectable_table(headers=["Tenant Project ID", "Name", "API Clue", "Responsible", "Active"], rows=rows, selectable=True, id="catalogsTable") }}
|
||||
<div class="form-group mt-3">
|
||||
<button type="submit" name="action" value="edit_tenant_project" class="btn btn-primary">Edit Tenant Project</button>
|
||||
<button type="submit" name="action" value="invalidate_tenant_project" class="btn btn-primary">Invalidate Tenant Project</button>
|
||||
<button type="submit" name="action" value="delete_tenant_project" class="btn btn-danger">Delete Tenant Project</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content_footer %}
|
||||
{{ render_pagination(pagination, "user_bp.tenant_projects") }}
|
||||
{% endblock %}
|
||||
@@ -2,7 +2,12 @@ from flask import session
|
||||
from flask_security import current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, SelectField
|
||||
from wtforms.validators import DataRequired
|
||||
from wtforms.validators import DataRequired, Optional
|
||||
from wtforms_sqlalchemy.fields import QuerySelectField
|
||||
|
||||
from common.models.document import Catalog
|
||||
from common.models.user import Tenant
|
||||
from common.utils.database import Database
|
||||
|
||||
|
||||
class SessionDefaultsForm(FlaskForm):
|
||||
@@ -13,11 +18,32 @@ class SessionDefaultsForm(FlaskForm):
|
||||
tenant_name = StringField('Tenant Name', validators=[DataRequired()])
|
||||
default_language = SelectField('Default Language', choices=[], validators=[DataRequired()])
|
||||
|
||||
# Default Catalog - initialize as a regular SelectField
|
||||
catalog = SelectField('Catalog', choices=[], validators=[Optional()])
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
# Set basic fields first (these don't require DB access)
|
||||
self.user_name.data = current_user.user_name
|
||||
self.user_email.data = current_user.email
|
||||
self.tenant_name.data = session.get('tenant').get('name')
|
||||
self.default_language.choices = [(lang, lang.lower()) for lang in
|
||||
session.get('tenant').get('allowed_languages')]
|
||||
self.default_language.data = session.get('default_language')
|
||||
self.default_language.data = session.get('default_language')
|
||||
|
||||
# Get a new session for catalog queries
|
||||
tenant_id = session.get('tenant').get('id')
|
||||
tenant_session = Database(tenant_id).get_session()
|
||||
try:
|
||||
# Populate catalog choices using tenant session
|
||||
catalogs = tenant_session.query(Catalog).all()
|
||||
self.catalog.choices = [(str(c.id), c.name) for c in catalogs]
|
||||
self.catalog.choices.insert(0, ('', 'Select a Catalog')) # Add empty choice
|
||||
|
||||
# Set current catalog if exists
|
||||
catalog_id = session.get('catalog_id')
|
||||
if catalog_id:
|
||||
self.catalog.data = str(catalog_id)
|
||||
finally:
|
||||
tenant_session.close()
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
from flask import request, render_template, Blueprint, session, current_app, jsonify
|
||||
from flask import request, render_template, Blueprint, session, current_app, jsonify, flash, redirect
|
||||
from flask_security import roles_required, roles_accepted
|
||||
from flask_wtf.csrf import generate_csrf
|
||||
|
||||
from common.models.document import Catalog
|
||||
from common.models.user import Tenant
|
||||
from common.utils.database import Database
|
||||
from common.utils.nginx_utils import prefixed_url_for
|
||||
from .basic_forms import SessionDefaultsForm
|
||||
|
||||
basic_bp = Blueprint('basic_bp', __name__)
|
||||
@@ -9,7 +13,7 @@ basic_bp = Blueprint('basic_bp', __name__)
|
||||
|
||||
@basic_bp.before_request
|
||||
def log_before_request():
|
||||
pass
|
||||
current_app.logger.debug(f'Before request: {request.path} =====================================')
|
||||
|
||||
|
||||
@basic_bp.after_request
|
||||
@@ -35,12 +39,39 @@ def confirm_email_fail():
|
||||
@basic_bp.route('/session_defaults', methods=['GET', 'POST'])
|
||||
@roles_accepted('Super User', 'Tenant Admin')
|
||||
def session_defaults():
|
||||
form = SessionDefaultsForm()
|
||||
try:
|
||||
# Get tenant session
|
||||
tenant_id = session.get('tenant').get('id')
|
||||
tenant_db = Database(tenant_id)
|
||||
tenant_session = tenant_db.get_session()
|
||||
|
||||
if form.validate_on_submit():
|
||||
session['default_language'] = form.default_language.data
|
||||
try:
|
||||
form = SessionDefaultsForm()
|
||||
|
||||
return render_template('basic/session_defaults.html', form=form)
|
||||
if form.validate_on_submit():
|
||||
session['default_language'] = form.default_language.data
|
||||
if form.catalog.data:
|
||||
catalog_id = int(form.catalog.data)
|
||||
catalog = tenant_session.query(Catalog).get(catalog_id)
|
||||
if catalog:
|
||||
session['catalog_id'] = catalog.id
|
||||
session['catalog_name'] = catalog.name
|
||||
else:
|
||||
session.pop('catalog_id', None)
|
||||
session.pop('catalog_name', None)
|
||||
|
||||
flash('Session defaults updated successfully', 'success')
|
||||
return redirect(prefixed_url_for('basic_bp.index'))
|
||||
|
||||
return render_template('basic/session_defaults.html', form=form)
|
||||
|
||||
finally:
|
||||
tenant_session.close()
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error in session_defaults: {str(e)}")
|
||||
flash('Error accessing catalog data. Please ensure your session is valid.', 'danger')
|
||||
return redirect(prefixed_url_for('security_bp.login'))
|
||||
|
||||
|
||||
@basic_bp.route('/set_user_timezone', methods=['POST'])
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
from flask import session, current_app, request
|
||||
from flask import session, current_app
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import (StringField, BooleanField, SubmitField, DateField, IntegerField, FloatField, SelectMultipleField,
|
||||
SelectField, FieldList, FormField, TextAreaField, URLField)
|
||||
from wtforms import (StringField, BooleanField, SubmitField, DateField, IntegerField, SelectField, TextAreaField, URLField)
|
||||
from wtforms.validators import DataRequired, Length, Optional, URL, ValidationError, NumberRange
|
||||
from flask_wtf.file import FileField, FileAllowed, FileRequired
|
||||
from flask_wtf.file import FileField, FileRequired
|
||||
import json
|
||||
|
||||
from wtforms_sqlalchemy.fields import QuerySelectField
|
||||
|
||||
from common.models.document import Catalog
|
||||
|
||||
from config.catalog_types import CATALOG_TYPES
|
||||
from config.processor_types import PROCESSOR_TYPES
|
||||
from config.retriever_types import RETRIEVER_TYPES
|
||||
from config.type_defs.catalog_types import CATALOG_TYPES
|
||||
from config.type_defs.processor_types import PROCESSOR_TYPES
|
||||
from config.type_defs.retriever_types import RETRIEVER_TYPES
|
||||
from .dynamic_form_base import DynamicFormBase
|
||||
|
||||
|
||||
@@ -179,6 +178,7 @@ class EditRetrieverForm(DynamicFormBase):
|
||||
|
||||
class AddDocumentForm(DynamicFormBase):
|
||||
file = FileField('File', validators=[FileRequired(), allowed_file])
|
||||
catalog = StringField('Catalog', render_kw={'readonly': True})
|
||||
sub_file_type = StringField('Sub File Type', validators=[Optional(), Length(max=50)])
|
||||
name = StringField('Name', validators=[Length(max=100)])
|
||||
language = SelectField('Language', choices=[], validators=[Optional()])
|
||||
@@ -193,9 +193,12 @@ class AddDocumentForm(DynamicFormBase):
|
||||
if not self.language.data:
|
||||
self.language.data = session.get('tenant').get('default_language')
|
||||
|
||||
self.catalog.data = session.get('catalog_name', '')
|
||||
|
||||
|
||||
class AddURLForm(DynamicFormBase):
|
||||
url = URLField('URL', validators=[DataRequired(), URL()])
|
||||
catalog = StringField('Catalog', render_kw={'readonly': True})
|
||||
sub_file_type = StringField('Sub File Type', validators=[Optional(), Length(max=50)])
|
||||
name = StringField('Name', validators=[Length(max=100)])
|
||||
language = SelectField('Language', choices=[], validators=[Optional()])
|
||||
@@ -210,22 +213,7 @@ class AddURLForm(DynamicFormBase):
|
||||
if not self.language.data:
|
||||
self.language.data = session.get('tenant').get('default_language')
|
||||
|
||||
|
||||
class AddURLsForm(FlaskForm):
|
||||
urls = TextAreaField('URL(s) (one per line)', validators=[DataRequired()])
|
||||
name = StringField('Name Prefix', validators=[Length(max=100)])
|
||||
language = SelectField('Language', choices=[], validators=[Optional()])
|
||||
user_context = TextAreaField('User Context', validators=[Optional()])
|
||||
valid_from = DateField('Valid from', id='form-control datepicker', validators=[Optional()])
|
||||
|
||||
submit = SubmitField('Submit')
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.language.choices = [(language, language) for language in
|
||||
session.get('tenant').get('allowed_languages')]
|
||||
if not self.language.data:
|
||||
self.language.data = session.get('tenant').get('default_language')
|
||||
self.catalog.data = session.get('catalog_name', '')
|
||||
|
||||
|
||||
class EditDocumentForm(FlaskForm):
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import ast
|
||||
from datetime import datetime as dt, timezone as tz
|
||||
|
||||
from babel.messages.setuptools_frontend import update_catalog
|
||||
from flask import request, redirect, flash, render_template, Blueprint, session, current_app
|
||||
from flask_security import roles_accepted, current_user
|
||||
from sqlalchemy import desc
|
||||
@@ -10,35 +9,33 @@ from werkzeug.utils import secure_filename
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
import requests
|
||||
from requests.exceptions import SSLError
|
||||
from urllib.parse import urlparse, unquote
|
||||
import io
|
||||
import json
|
||||
|
||||
from common.models.document import Document, DocumentVersion, Catalog, Retriever, Processor
|
||||
from common.extensions import db, minio_client
|
||||
from common.extensions import db
|
||||
from common.utils.document_utils import validate_file_type, create_document_stack, start_embedding_task, process_url, \
|
||||
process_multiple_urls, get_documents_list, edit_document, \
|
||||
edit_document, \
|
||||
edit_document_version, refresh_document
|
||||
from common.utils.eveai_exceptions import EveAIInvalidLanguageException, EveAIUnsupportedFileType, \
|
||||
EveAIDoubleURLException
|
||||
from config.processor_types import PROCESSOR_TYPES
|
||||
from .document_forms import AddDocumentForm, AddURLForm, EditDocumentForm, EditDocumentVersionForm, AddURLsForm, \
|
||||
from config.type_defs.processor_types import PROCESSOR_TYPES
|
||||
from .document_forms import AddDocumentForm, AddURLForm, EditDocumentForm, EditDocumentVersionForm, \
|
||||
CatalogForm, EditCatalogForm, RetrieverForm, EditRetrieverForm, ProcessorForm, EditProcessorForm
|
||||
from common.utils.middleware import mw_before_request
|
||||
from common.utils.celery_utils import current_celery
|
||||
from common.utils.nginx_utils import prefixed_url_for
|
||||
from common.utils.view_assistants import form_validation_failed, prepare_table_for_macro, form_to_dict
|
||||
from common.utils.view_assistants import form_validation_failed, prepare_table_for_macro
|
||||
from .document_list_view import DocumentListView
|
||||
from .document_version_list_view import DocumentVersionListView
|
||||
from config.catalog_types import CATALOG_TYPES
|
||||
from config.retriever_types import RETRIEVER_TYPES
|
||||
from config.type_defs.catalog_types import CATALOG_TYPES
|
||||
from config.type_defs.retriever_types import RETRIEVER_TYPES
|
||||
|
||||
document_bp = Blueprint('document_bp', __name__, url_prefix='/document')
|
||||
|
||||
|
||||
@document_bp.before_request
|
||||
def log_before_request():
|
||||
pass
|
||||
current_app.logger.debug(f'Before request: {request.path} =====================================')
|
||||
|
||||
|
||||
@document_bp.after_request
|
||||
@@ -214,7 +211,7 @@ def edit_processor(processor_id):
|
||||
db.session.add(processor)
|
||||
db.session.commit()
|
||||
flash('Retriever updated successfully!', 'success')
|
||||
current_app.logger.info(f'Retriever {processor.id} updated successfully')
|
||||
current_app.logger.info(f'Processor {processor.id} updated successfully')
|
||||
except SQLAlchemyError as e:
|
||||
db.session.rollback()
|
||||
flash(f'Failed to update processor. Error: {str(e)}', 'danger')
|
||||
@@ -376,7 +373,7 @@ def add_document():
|
||||
form = AddDocumentForm(request.form)
|
||||
catalog_id = session.get('catalog_id', None)
|
||||
if catalog_id is None:
|
||||
flash('You need to set a Session Catalog before adding Documents or URLs')
|
||||
flash('You need to set a Session Catalog before adding Documents or URLs', 'warning')
|
||||
return redirect(prefixed_url_for('document_bp.catalogs'))
|
||||
|
||||
catalog = Catalog.query.get_or_404(catalog_id)
|
||||
@@ -434,7 +431,7 @@ def add_url():
|
||||
form = AddURLForm(request.form)
|
||||
catalog_id = session.get('catalog_id', None)
|
||||
if catalog_id is None:
|
||||
flash('You need to set a Session Catalog before adding Documents or URLs')
|
||||
flash('You need to set a Session Catalog before adding Documents or URLs', 'warning')
|
||||
return redirect(prefixed_url_for('document_bp.catalogs'))
|
||||
|
||||
catalog = Catalog.query.get_or_404(catalog_id)
|
||||
@@ -547,6 +544,7 @@ def edit_document_view(document_id):
|
||||
|
||||
if form.validate_on_submit():
|
||||
updated_doc, error = edit_document(
|
||||
session.get('tenant').get('id', 0),
|
||||
document_id,
|
||||
form.name.data,
|
||||
form.valid_from.data,
|
||||
@@ -569,10 +567,8 @@ def edit_document_version_view(document_version_id):
|
||||
doc_vers = DocumentVersion.query.get_or_404(document_version_id)
|
||||
form = EditDocumentVersionForm(request.form, obj=doc_vers)
|
||||
|
||||
catalog_id = session.get('catalog_id', None)
|
||||
if catalog_id is None:
|
||||
flash('You need to set a Session Catalog before adding Documents or URLs')
|
||||
return redirect(prefixed_url_for('document_bp.catalogs'))
|
||||
doc_vers = DocumentVersion.query.get_or_404(document_version_id)
|
||||
catalog_id = doc_vers.document.catalog_id
|
||||
|
||||
catalog = Catalog.query.get_or_404(catalog_id)
|
||||
if catalog.configuration and len(catalog.configuration) > 0:
|
||||
@@ -587,6 +583,7 @@ def edit_document_version_view(document_version_id):
|
||||
catalog_properties[config] = form.get_dynamic_data(config)
|
||||
|
||||
updated_version, error = edit_document_version(
|
||||
session.get('tenant').get('id', 0),
|
||||
document_version_id,
|
||||
form.user_context.data,
|
||||
catalog_properties,
|
||||
|
||||
@@ -35,7 +35,7 @@ def license_tier():
|
||||
return render_template('entitlements/license_tier.html', form=form)
|
||||
|
||||
current_app.logger.info(f"Successfully created license tier {new_license_tier.id}")
|
||||
flash(f"Successfully created tenant license tier {new_license_tier.id}")
|
||||
flash(f"Successfully created tenant license tier {new_license_tier.id}", 'success')
|
||||
|
||||
return redirect(prefixed_url_for('entitlements_bp.view_license_tiers'))
|
||||
else:
|
||||
@@ -232,3 +232,63 @@ def view_usages():
|
||||
|
||||
# Render the users in a template
|
||||
return render_template('entitlements/view_usages.html', rows=rows, pagination=pagination)
|
||||
|
||||
|
||||
@entitlements_bp.route('/handle_usage_selection', methods=['POST'])
|
||||
@roles_accepted('Super User', 'Tenant Admin')
|
||||
def handle_usage_selection():
|
||||
usage_identification = request.form['selected_row']
|
||||
usage_id = ast.literal_eval(usage_identification).get('value')
|
||||
the_usage = LicenseUsage.query.get_or_404(usage_id)
|
||||
|
||||
action = request.form['action']
|
||||
|
||||
pass # Currently, no actions are defined
|
||||
|
||||
|
||||
@entitlements_bp.route('/view_licenses')
|
||||
@roles_accepted('Super User', 'Tenant Admin')
|
||||
def view_licenses():
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 10, type=int)
|
||||
|
||||
tenant_id = session.get('tenant').get('id')
|
||||
# Get current date in UTC
|
||||
current_date = dt.now(tz=tz.utc).date()
|
||||
|
||||
# Query licenses for the tenant, with ordering and active status
|
||||
query = (
|
||||
License.query.filter_by(tenant_id=tenant_id)
|
||||
.add_columns(
|
||||
License.id,
|
||||
License.start_date,
|
||||
License.end_date,
|
||||
License.license_tier,
|
||||
License.license_tier.name.label('license_tier_name'),
|
||||
((License.start_date <= current_date) &
|
||||
(or_(License.end_date.is_(None), License.end_date >= current_date))).label('active')
|
||||
)
|
||||
.order_by(License.start_date.desc())
|
||||
)
|
||||
|
||||
pagination = query.paginate(page=page, per_page=per_page)
|
||||
lics = pagination.items
|
||||
|
||||
# prepare table data
|
||||
rows = prepare_table_for_macro(lics, [('id', ''), ('license_tier_name', ''), ('start_date', ''), ('end_date', ''),
|
||||
('active', ''),])
|
||||
|
||||
# Render the licenses in a template
|
||||
return render_template('entitlements/view_licenses.html', rows=rows, pagination=pagination)
|
||||
|
||||
|
||||
@entitlements_bp.route('/handle_license_selection', methods=['POST'])
|
||||
@roles_accepted('Super User', 'Tenant Admin')
|
||||
def handle_license_selection():
|
||||
license_identification = request.form['selected_row']
|
||||
license_id = ast.literal_eval(license_identification).get('value')
|
||||
the_license = LicenseUsage.query.get_or_404(license_id)
|
||||
|
||||
action = request.form['action']
|
||||
|
||||
pass # Currently, no actions are defined
|
||||
@@ -1,17 +1,12 @@
|
||||
from flask import session, current_app, request
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import (StringField, BooleanField, SubmitField, DateField, IntegerField, FloatField, SelectMultipleField,
|
||||
SelectField, FieldList, FormField, TextAreaField, URLField)
|
||||
from wtforms.validators import DataRequired, Length, Optional, URL, ValidationError, NumberRange
|
||||
from flask_wtf.file import FileField, FileAllowed, FileRequired
|
||||
import json
|
||||
from wtforms import (StringField, BooleanField, SelectField, TextAreaField)
|
||||
from wtforms.validators import DataRequired, Length
|
||||
|
||||
from wtforms_sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
|
||||
from wtforms_sqlalchemy.fields import QuerySelectMultipleField
|
||||
|
||||
from common.models.document import Retriever
|
||||
|
||||
from config.catalog_types import CATALOG_TYPES
|
||||
from config.specialist_types import SPECIALIST_TYPES
|
||||
from config.type_defs.specialist_types import SPECIALIST_TYPES
|
||||
from .dynamic_form_base import DynamicFormBase
|
||||
|
||||
|
||||
|
||||
@@ -1,28 +1,17 @@
|
||||
import ast
|
||||
import os
|
||||
from datetime import datetime as dt, timezone as tz
|
||||
|
||||
import chardet
|
||||
from flask import request, redirect, flash, render_template, Blueprint, session, current_app
|
||||
from flask_security import roles_accepted, current_user
|
||||
from flask_security import roles_accepted
|
||||
from sqlalchemy import desc
|
||||
from sqlalchemy.orm import joinedload
|
||||
from werkzeug.datastructures import FileStorage
|
||||
from werkzeug.utils import secure_filename
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
import requests
|
||||
from requests.exceptions import SSLError
|
||||
from urllib.parse import urlparse
|
||||
import io
|
||||
|
||||
from common.models.document import Embedding, DocumentVersion, Retriever
|
||||
from common.models.interaction import ChatSession, Interaction, InteractionEmbedding, Specialist, SpecialistRetriever
|
||||
from common.extensions import db
|
||||
from common.utils.document_utils import set_logging_information, update_logging_information
|
||||
from config.specialist_types import SPECIALIST_TYPES
|
||||
from .document_forms import AddDocumentForm, AddURLForm, EditDocumentForm, EditDocumentVersionForm
|
||||
from config.type_defs.specialist_types import SPECIALIST_TYPES
|
||||
from common.utils.middleware import mw_before_request
|
||||
from common.utils.celery_utils import current_celery
|
||||
from common.utils.nginx_utils import prefixed_url_for
|
||||
from common.utils.view_assistants import form_validation_failed, prepare_table_for_macro
|
||||
from .interaction_forms import SpecialistForm, EditSpecialistForm
|
||||
@@ -32,7 +21,7 @@ interaction_bp = Blueprint('interaction_bp', __name__, url_prefix='/interaction'
|
||||
|
||||
@interaction_bp.before_request
|
||||
def log_before_request():
|
||||
pass
|
||||
current_app.logger.debug(f'Before request: {request.path} =====================================')
|
||||
|
||||
|
||||
@interaction_bp.after_request
|
||||
|
||||
@@ -11,11 +11,12 @@ from itsdangerous import URLSafeTimedSerializer
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from common.models.user import User
|
||||
from common.utils.eveai_exceptions import EveAIException
|
||||
from common.utils.nginx_utils import prefixed_url_for
|
||||
from eveai_app.views.security_forms import SetPasswordForm, ResetPasswordForm, RequestResetForm
|
||||
from common.extensions import db
|
||||
from common.utils.security_utils import confirm_token, send_confirmation_email, send_reset_email
|
||||
from common.utils.security import set_tenant_session_data
|
||||
from common.utils.security import set_tenant_session_data, is_valid_tenant
|
||||
|
||||
security_bp = Blueprint('security_bp', __name__)
|
||||
|
||||
@@ -40,11 +41,15 @@ def login():
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
if form.validate_on_submit():
|
||||
user = User.query.filter_by(email=form.email.data).first()
|
||||
if user is None or not verify_and_update_password(form.password.data, user):
|
||||
flash('Invalid username or password', 'danger')
|
||||
current_app.logger.error(f'Failed to login user')
|
||||
return redirect(prefixed_url_for('security_bp.login'))
|
||||
try:
|
||||
user = User.query.filter_by(email=form.email.data).first()
|
||||
if user is None or not verify_and_update_password(form.password.data, user):
|
||||
raise EveAIException('Invalid email or password')
|
||||
is_valid_tenant(user.tenant_id)
|
||||
except EveAIException as e:
|
||||
flash(f'Failed to login user: {str(e)}', 'danger')
|
||||
current_app.logger.error(f'Failed to login user: {str(e)}')
|
||||
abort(401)
|
||||
|
||||
if login_user(user):
|
||||
current_app.logger.info(f'Login successful! Current User is {current_user.email}')
|
||||
@@ -55,7 +60,7 @@ def login():
|
||||
return redirect(prefixed_url_for('user_bp.tenant_overview'))
|
||||
else:
|
||||
flash('Invalid username or password', 'danger')
|
||||
current_app.logger.error(f'Failed to login user {user.email}')
|
||||
current_app.logger.error(f'Invalid username or password for given email: {user.email}')
|
||||
abort(401)
|
||||
else:
|
||||
current_app.logger.error(f'Invalid login form: {form.errors}')
|
||||
|
||||
@@ -6,51 +6,24 @@ from wtforms.validators import DataRequired, Length, Email, NumberRange, Optiona
|
||||
import pytz
|
||||
|
||||
from common.models.user import Role
|
||||
from config.type_defs.service_types import SERVICE_TYPES
|
||||
|
||||
|
||||
class TenantForm(FlaskForm):
|
||||
name = StringField('Name', validators=[DataRequired(), Length(max=80)])
|
||||
type = SelectField('Tenant Type', validators=[Optional()], default='Active')
|
||||
website = StringField('Website', validators=[DataRequired(), Length(max=255)])
|
||||
# language fields
|
||||
default_language = SelectField('Default Language', choices=[], validators=[DataRequired()])
|
||||
allowed_languages = SelectMultipleField('Allowed Languages', choices=[], validators=[DataRequired()])
|
||||
# invoicing fields
|
||||
currency = SelectField('Currency', choices=[], validators=[DataRequired()])
|
||||
usage_email = EmailField('Usage Email', validators=[DataRequired(), Email()])
|
||||
# Timezone
|
||||
timezone = SelectField('Timezone', choices=[], validators=[DataRequired()])
|
||||
# RAG context
|
||||
rag_context = TextAreaField('RAG Context', validators=[Optional()])
|
||||
# Tenant Type
|
||||
type = SelectField('Tenant Type', validators=[Optional()], default='Active')
|
||||
# LLM fields
|
||||
embedding_model = SelectField('Embedding Model', choices=[], validators=[DataRequired()])
|
||||
llm_model = SelectField('Large Language Model', choices=[], validators=[DataRequired()])
|
||||
# Embedding variables
|
||||
html_tags = StringField('HTML Tags', validators=[DataRequired()],
|
||||
default='p, h1, h2, h3, h4, h5, h6, li')
|
||||
html_end_tags = StringField('HTML End Tags', validators=[DataRequired()],
|
||||
default='p, li')
|
||||
html_included_elements = StringField('HTML Included Elements', validators=[Optional()])
|
||||
html_excluded_elements = StringField('HTML Excluded Elements', validators=[Optional()])
|
||||
html_excluded_classes = StringField('HTML Excluded Classes', validators=[Optional()])
|
||||
min_chunk_size = IntegerField('Minimum Chunk Size (2000)', validators=[NumberRange(min=0), Optional()], default=2000)
|
||||
max_chunk_size = IntegerField('Maximum Chunk Size (3000)', validators=[NumberRange(min=0), Optional()], default=3000)
|
||||
# Embedding Search variables
|
||||
es_k = IntegerField('Limit for Searching Embeddings (5)',
|
||||
default=5,
|
||||
validators=[NumberRange(min=0)])
|
||||
es_similarity_threshold = FloatField('Similarity Threshold for Searching Embeddings (0.5)',
|
||||
default=0.5,
|
||||
validators=[NumberRange(min=0, max=1)])
|
||||
# Chat Variables
|
||||
chat_RAG_temperature = FloatField('RAG Temperature', default=0.3, validators=[NumberRange(min=0, max=1)])
|
||||
chat_no_RAG_temperature = FloatField('No RAG Temperature', default=0.5, validators=[NumberRange(min=0, max=1)])
|
||||
fallback_algorithms = SelectMultipleField('Fallback Algorithms', choices=[], validators=[Optional()])
|
||||
# Tuning variables
|
||||
embed_tuning = BooleanField('Enable Embedding Tuning', default=False)
|
||||
rag_tuning = BooleanField('Enable RAG Tuning', default=False)
|
||||
|
||||
submit = SubmitField('Submit')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -66,8 +39,6 @@ class TenantForm(FlaskForm):
|
||||
self.embedding_model.choices = [(model, model) for model in current_app.config['SUPPORTED_EMBEDDINGS']]
|
||||
self.llm_model.choices = [(model, model) for model in current_app.config['SUPPORTED_LLMS']]
|
||||
# Initialize fallback algorithms
|
||||
self.fallback_algorithms.choices = \
|
||||
[(algorithm, algorithm.lower()) for algorithm in current_app.config['FALLBACK_ALGORITHMS']]
|
||||
self.type.choices = [(t, t) for t in current_app.config['TENANT_TYPES']]
|
||||
|
||||
|
||||
@@ -79,6 +50,8 @@ class BaseUserForm(FlaskForm):
|
||||
valid_to = DateField('Valid to', id='form-control datepicker', validators=[Optional()])
|
||||
tenant_id = IntegerField('Tenant ID', validators=[NumberRange(min=0)])
|
||||
roles = SelectMultipleField('Roles', coerce=int)
|
||||
is_primary_contact = BooleanField('Primary Contact')
|
||||
is_financial_contact = BooleanField('Financial Contact')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(BaseUserForm, self).__init__(*args, **kwargs)
|
||||
@@ -90,6 +63,12 @@ class CreateUserForm(BaseUserForm):
|
||||
|
||||
|
||||
class EditUserForm(BaseUserForm):
|
||||
# Some R/O informational fields
|
||||
confirmed_at = DateField('Confirmed At', id='form-control datepicker', validators=[Optional()],
|
||||
render_kw={'readonly': True})
|
||||
last_login_at = DateField('Last Login At', id='form-control datepicker', validators=[Optional()],
|
||||
render_kw={'readonly': True})
|
||||
login_count = IntegerField('Login Count', validators=[Optional()], render_kw={'readonly': True})
|
||||
submit = SubmitField('Save User')
|
||||
|
||||
|
||||
@@ -121,4 +100,30 @@ class TenantSelectionForm(FlaskForm):
|
||||
self.types.choices = [(t, t) for t in current_app.config['TENANT_TYPES']]
|
||||
|
||||
|
||||
class TenantProjectForm(FlaskForm):
|
||||
name = StringField('Name', validators=[DataRequired(), Length(max=50)])
|
||||
description = TextAreaField('Description', validators=[Optional()])
|
||||
services = SelectMultipleField('Allowed Services', choices=[], validators=[DataRequired()])
|
||||
unencrypted_api_key = StringField('Unencrypted API Key', validators=[DataRequired()], render_kw={'readonly': True})
|
||||
visual_api_key = StringField('Visual API Key', validators=[DataRequired()], render_kw={'readonly': True})
|
||||
active = BooleanField('Active', validators=[DataRequired()], default=True)
|
||||
responsible_email = EmailField('Responsible Email', validators=[Optional(), Email()])
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Initialize choices for the services field
|
||||
self.services.choices = [(key, value['description']) for key, value in SERVICE_TYPES.items()]
|
||||
|
||||
|
||||
class EditTenantProjectForm(FlaskForm):
|
||||
name = StringField('Name', validators=[DataRequired(), Length(max=50)])
|
||||
description = TextAreaField('Description', validators=[Optional()])
|
||||
services = SelectMultipleField('Allowed Services', choices=[], validators=[DataRequired()])
|
||||
visual_api_key = StringField('Visual API Key', validators=[DataRequired()], render_kw={'readonly': True})
|
||||
active = BooleanField('Active', validators=[DataRequired()], default=True)
|
||||
responsible_email = EmailField('Responsible Email', validators=[Optional(), Email()])
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Initialize choices for the services field
|
||||
self.services.choices = [(key, value['description']) for key, value in SERVICE_TYPES.items()]
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
import uuid
|
||||
from datetime import datetime as dt, timezone as tz
|
||||
from flask import request, redirect, flash, render_template, Blueprint, session, current_app, jsonify
|
||||
from flask_mailman import EmailMessage
|
||||
from flask_security import hash_password, roles_required, roles_accepted, current_user
|
||||
from itsdangerous import URLSafeTimedSerializer
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
import ast
|
||||
|
||||
from common.models.user import User, Tenant, Role, TenantDomain
|
||||
from common.models.user import User, Tenant, Role, TenantDomain, TenantProject
|
||||
from common.extensions import db, security, minio_client, simple_encryption
|
||||
from common.utils.security_utils import send_confirmation_email, send_reset_email
|
||||
from .user_forms import TenantForm, CreateUserForm, EditUserForm, TenantDomainForm, TenantSelectionForm
|
||||
from config.type_defs.service_types import SERVICE_TYPES
|
||||
from .user_forms import TenantForm, CreateUserForm, EditUserForm, TenantDomainForm, TenantSelectionForm, \
|
||||
TenantProjectForm, EditTenantProjectForm
|
||||
from common.utils.database import Database
|
||||
from common.utils.view_assistants import prepare_table_for_macro, form_validation_failed
|
||||
from common.utils.simple_encryption import generate_api_key
|
||||
@@ -21,7 +24,7 @@ user_bp = Blueprint('user_bp', __name__, url_prefix='/user')
|
||||
|
||||
@user_bp.before_request
|
||||
def log_before_request():
|
||||
pass
|
||||
current_app.logger.debug(f'Before request: {request.path} =====================================')
|
||||
|
||||
|
||||
@user_bp.after_request
|
||||
@@ -33,7 +36,9 @@ def log_after_request(response):
|
||||
@roles_required('Super User')
|
||||
def tenant():
|
||||
form = TenantForm()
|
||||
current_app.logger.debug(f'Tenant form: {form}')
|
||||
if form.validate_on_submit():
|
||||
current_app.logger.debug(f'Tenant form submitted: {form.data}')
|
||||
# Handle the required attributes
|
||||
new_tenant = Tenant()
|
||||
form.populate_obj(new_tenant)
|
||||
@@ -53,7 +58,7 @@ def tenant():
|
||||
return render_template('user/tenant.html', form=form)
|
||||
|
||||
current_app.logger.info(f"Successfully created tenant {new_tenant.id} in Database")
|
||||
flash(f"Successfully created tenant {new_tenant.id} in Database")
|
||||
flash(f"Successfully created tenant {new_tenant.id} in Database", 'success')
|
||||
|
||||
# Create schema for new tenant
|
||||
current_app.logger.info(f"Creating schema for tenant {new_tenant.id}")
|
||||
@@ -442,6 +447,163 @@ def tenant_overview():
|
||||
return render_template('user/tenant_overview.html', form=form)
|
||||
|
||||
|
||||
@user_bp.route('/tenant_project', methods=['GET', 'POST'])
|
||||
@roles_accepted('Super User', 'Tenant Admin')
|
||||
def tenant_project():
|
||||
form = TenantProjectForm()
|
||||
if request.method == 'GET':
|
||||
# Initialize the API key
|
||||
new_api_key = generate_api_key(prefix="EveAI")
|
||||
form.unencrypted_api_key.data = new_api_key
|
||||
form.visual_api_key.data = f"EVEAI-...{new_api_key[-4:]}"
|
||||
|
||||
if form.validate_on_submit():
|
||||
new_tenant_project = TenantProject()
|
||||
form.populate_obj(new_tenant_project)
|
||||
new_tenant_project.tenant_id = session['tenant']['id']
|
||||
new_tenant_project.encrypted_api_key = simple_encryption.encrypt_api_key(new_tenant_project.unencrypted_api_key)
|
||||
set_logging_information(new_tenant_project, dt.now(tz.utc))
|
||||
|
||||
# Add new Tenant Project to the database
|
||||
try:
|
||||
db.session.add(new_tenant_project)
|
||||
db.session.commit()
|
||||
|
||||
# Send email notification
|
||||
services = [SERVICE_TYPES[service]['name']
|
||||
for service in form.services.data
|
||||
if service in SERVICE_TYPES]
|
||||
|
||||
email_sent = send_api_key_notification(
|
||||
tenant_id=session['tenant']['id'],
|
||||
tenant_name=session['tenant']['name'],
|
||||
project_name=new_tenant_project.name,
|
||||
api_key=new_tenant_project.unencrypted_api_key,
|
||||
services=services,
|
||||
responsible_email=form.responsible_email.data
|
||||
)
|
||||
|
||||
if email_sent:
|
||||
flash('Tenant Project created successfully and notification email sent.', 'success')
|
||||
else:
|
||||
flash('Tenant Project created successfully but failed to send notification email.', 'warning')
|
||||
|
||||
current_app.logger.info(f'Tenant Project {new_tenant_project.name} added for tenant '
|
||||
f'{session['tenant']['id']}.')
|
||||
return redirect(prefixed_url_for('user_bp.tenant_projects'))
|
||||
except SQLAlchemyError as e:
|
||||
db.session.rollback()
|
||||
flash(f'Failed to create Tenant Project. Error: {str(e)}', 'danger')
|
||||
current_app.logger.error(f"Failed to create Tenant Project for tenant {session['tenant']['id']}. "
|
||||
f"Error: {str(e)}")
|
||||
|
||||
return render_template('user/tenant_project.html', form=form)
|
||||
|
||||
|
||||
@user_bp.route('/tenant_projects', methods=['GET', 'POST'])
|
||||
@roles_accepted('Super User', 'Tenant Admin')
|
||||
def tenant_projects():
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 10, type=int)
|
||||
|
||||
tenant_id = session['tenant']['id']
|
||||
query = TenantProject.query.filter_by(tenant_id=tenant_id).order_by(TenantProject.id)
|
||||
|
||||
pagination = query.paginate(page=page, per_page=per_page)
|
||||
the_tenant_projects = pagination.items
|
||||
|
||||
# prepare table data
|
||||
rows = prepare_table_for_macro(the_tenant_projects, [('id', ''), ('name', ''), ('visual_api_key', ''),
|
||||
('responsible_email', ''), ('active', '')])
|
||||
|
||||
# Render the catalogs in a template
|
||||
return render_template('user/tenant_projects.html', rows=rows, pagination=pagination)
|
||||
|
||||
|
||||
@user_bp.route('/handle_tenant_project_selection', methods=['POST'])
|
||||
@roles_accepted('Super User', 'Tenant Admin')
|
||||
def handle_tenant_project_selection():
|
||||
tenant_project_identification = request.form.get('selected_row')
|
||||
tenant_project_id = ast.literal_eval(tenant_project_identification).get('value')
|
||||
action = request.form.get('action')
|
||||
tenant_project = TenantProject.query.get_or_404(tenant_project_id)
|
||||
|
||||
if action == 'edit_tenant_project':
|
||||
return redirect(prefixed_url_for('user_bp.edit_tenant_project', tenant_project_id=tenant_project_id))
|
||||
elif action == 'invalidate_tenant_project':
|
||||
tenant_project.active = False
|
||||
try:
|
||||
db.session.add(tenant_project)
|
||||
db.session.commit()
|
||||
flash('Tenant Project invalidated successfully.', 'success')
|
||||
current_app.logger.info(f'Tenant Project {tenant_project.name} invalidated for tenant '
|
||||
f'{session['tenant']['id']}.')
|
||||
except SQLAlchemyError as e:
|
||||
db.session.rollback()
|
||||
flash(f'Failed to invalidate Tenant Project {tenant_project.name}. Error: {str(e)}', 'danger')
|
||||
current_app.logger.error(f"Failed to invalidate Tenant Project for tenant {session['tenant']['id']}. "
|
||||
f"Error: {str(e)}")
|
||||
elif action == 'delete_tenant_project':
|
||||
return redirect(prefixed_url_for('user_bp.delete_tenant_project', tenant_project_id=tenant_project_id))
|
||||
|
||||
return redirect(prefixed_url_for('user_bp.tenant_projects'))
|
||||
|
||||
|
||||
@user_bp.route('/tenant_project/<int:tenant_project_id>', methods=['GET','POST'])
|
||||
@roles_accepted('Super User', 'Tenant Admin')
|
||||
def edit_tenant_project(tenant_project_id):
|
||||
tenant_project = TenantProject.query.get_or_404(tenant_project_id)
|
||||
tenant_id = session['tenant']['id']
|
||||
|
||||
form = EditTenantProjectForm(obj=tenant_project)
|
||||
|
||||
if form.validate_on_submit():
|
||||
form.populate_obj(tenant_project)
|
||||
update_logging_information(tenant_project, dt.now(tz.utc))
|
||||
|
||||
try:
|
||||
db.session.add(tenant_project)
|
||||
db.session.commit()
|
||||
flash('Tenant Project updated successfully.', 'success')
|
||||
current_app.logger.info(f'Tenant Project {tenant_project.name} updated for tenant {tenant_id}.')
|
||||
return redirect(prefixed_url_for('user_bp.tenant_projects'))
|
||||
except SQLAlchemyError as e:
|
||||
db.session.rollback()
|
||||
flash(f'Failed to update Tenant Project. Error: {str(e)}', 'danger')
|
||||
current_app.logger.error(f"Failed to update Tenant Project {tenant_project.name} for tenant {tenant_id}. ")
|
||||
|
||||
return render_template('user/edit_tenant.html', form=form, tenant_project_id=tenant_project_id)
|
||||
|
||||
|
||||
@user_bp.route('/tenant_project/delete/<int:tenant_project_id>', methods=['GET', 'POST'])
|
||||
@roles_accepted('Super User', 'Tenant Admin')
|
||||
def delete_tenant_project(tenant_project_id):
|
||||
tenant_id = session['tenant']['id']
|
||||
tenant_project = TenantProject.query.get_or_404(tenant_project_id)
|
||||
|
||||
# Ensure project belongs to current tenant
|
||||
if tenant_project.tenant_id != tenant_id:
|
||||
flash('You do not have permission to delete this project.', 'danger')
|
||||
return redirect(prefixed_url_for('user_bp.tenant_projects'))
|
||||
|
||||
if request.method == 'GET':
|
||||
return render_template('user/confirm_delete_tenant_project.html',
|
||||
tenant_project=tenant_project)
|
||||
|
||||
try:
|
||||
project_name = tenant_project.name
|
||||
db.session.delete(tenant_project)
|
||||
db.session.commit()
|
||||
flash(f'Tenant Project "{project_name}" successfully deleted.', 'success')
|
||||
current_app.logger.info(f'Tenant Project {project_name} deleted for tenant {tenant_id}')
|
||||
except SQLAlchemyError as e:
|
||||
db.session.rollback()
|
||||
flash(f'Failed to delete Tenant Project. Error: {str(e)}', 'danger')
|
||||
current_app.logger.error(f'Failed to delete Tenant Project {tenant_project_id}. Error: {str(e)}')
|
||||
|
||||
return redirect(prefixed_url_for('user_bp.tenant_projects'))
|
||||
|
||||
|
||||
def reset_uniquifier(user):
|
||||
security.datastore.set_uniquifier(user)
|
||||
db.session.add(user)
|
||||
@@ -459,3 +621,64 @@ def set_logging_information(obj, timestamp):
|
||||
def update_logging_information(obj, timestamp):
|
||||
obj.updated_at = timestamp
|
||||
obj.updated_by = current_user.id
|
||||
|
||||
|
||||
def get_notification_email(tenant_id, user_email=None):
|
||||
"""
|
||||
Determine which email address to use for notification.
|
||||
Priority: Provided email > Primary contact > Default email
|
||||
"""
|
||||
if user_email:
|
||||
return user_email
|
||||
|
||||
# Try to find primary contact
|
||||
primary_contact = User.query.filter_by(
|
||||
tenant_id=tenant_id,
|
||||
is_primary_contact=True
|
||||
).first()
|
||||
|
||||
if primary_contact:
|
||||
return primary_contact.email
|
||||
|
||||
return "pieter@askeveai.com"
|
||||
|
||||
|
||||
def send_api_key_notification(tenant_id, tenant_name, project_name, api_key, services, responsible_email=None):
|
||||
"""
|
||||
Send API key notification email
|
||||
"""
|
||||
recipient_email = get_notification_email(tenant_id, responsible_email)
|
||||
|
||||
# Prepare email content
|
||||
context = {
|
||||
'tenant_id': tenant_id,
|
||||
'tenant_name': tenant_name,
|
||||
'project_name': project_name,
|
||||
'api_key': api_key,
|
||||
'services': services,
|
||||
'year': dt.now(tz.utc).year,
|
||||
'promo_image_url': current_app.config.get('PROMOTIONAL_IMAGE_URL',
|
||||
'https://static.askeveai.com/promo/default.jpg')
|
||||
}
|
||||
|
||||
try:
|
||||
# Create email message
|
||||
msg = EmailMessage(
|
||||
subject='Your new API-key from Ask Eve AI (Evie)',
|
||||
body=render_template('email/api_key_notification.html', **context),
|
||||
from_email=current_app.config['MAIL_DEFAULT_SENDER'],
|
||||
to=[recipient_email]
|
||||
)
|
||||
|
||||
# Set HTML content type
|
||||
msg.content_subtype = "html"
|
||||
|
||||
# Send email
|
||||
msg.send()
|
||||
|
||||
current_app.logger.info(f"API key notification sent to {recipient_email} for tenant {tenant_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Failed to send API key notification email: {str(e)}")
|
||||
return False
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from typing import List, Dict, Any
|
||||
from typing import Dict, Any
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from common.utils.config_field_types import ArgumentDefinition, TaggingFields
|
||||
from config.retriever_types import RETRIEVER_TYPES
|
||||
from config.type_defs.retriever_types import RETRIEVER_TYPES
|
||||
|
||||
|
||||
class RetrieverMetadata(BaseModel):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import List, Dict, Any, Optional
|
||||
from typing import Dict, Any
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from config.specialist_types import SPECIALIST_TYPES
|
||||
from config.type_defs.specialist_types import SPECIALIST_TYPES
|
||||
from eveai_chat_workers.retrievers.retriever_typing import RetrieverArguments
|
||||
|
||||
|
||||
|
||||
@@ -6,17 +6,16 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from common.utils.config_field_types import TaggingFields
|
||||
from common.utils.database import Database
|
||||
from common.models.document import Embedding, Catalog
|
||||
from common.models.document import Catalog
|
||||
from common.models.user import Tenant
|
||||
from common.models.interaction import ChatSession, Interaction, InteractionEmbedding, Specialist, SpecialistRetriever
|
||||
from common.models.interaction import Interaction, Specialist, SpecialistRetriever
|
||||
from common.extensions import db, cache_manager
|
||||
from common.utils.celery_utils import current_celery
|
||||
from common.utils.business_event import BusinessEvent
|
||||
from common.utils.business_event_context import current_event
|
||||
from config.specialist_types import SPECIALIST_TYPES
|
||||
from eveai_chat_workers.chat_session_cache import get_chat_history
|
||||
from config.type_defs.specialist_types import SPECIALIST_TYPES
|
||||
from eveai_chat_workers.specialists.registry import SpecialistRegistry
|
||||
from config.retriever_types import RETRIEVER_TYPES
|
||||
from config.type_defs.retriever_types import RETRIEVER_TYPES
|
||||
from eveai_chat_workers.specialists.specialist_typing import SpecialistArguments
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from common.extensions import db
|
||||
from common.models.user import Tenant
|
||||
from common.models.entitlements import BusinessEventLog, LicenseUsage, License
|
||||
from common.utils.celery_utils import current_celery
|
||||
from common.utils.eveai_exceptions import EveAINoLicenseForTenant, EveAIException
|
||||
from common.utils.eveai_exceptions import EveAINoLicenseForTenant, EveAIException, EveAINoActiveLicense
|
||||
from common.utils.database import Database
|
||||
|
||||
|
||||
@@ -85,8 +85,7 @@ def check_and_create_license_usage_for_tenant(tenant_id):
|
||||
if not active_license:
|
||||
current_app.logger.error(f"No License defined for {tenant_id}. "
|
||||
f"Impossible to calculate license usage.")
|
||||
raise EveAINoLicenseForTenant(message=f"No License defined for {tenant_id}. "
|
||||
f"Impossible to calculate license usage.")
|
||||
raise EveAINoActiveLicense(tenant_id)
|
||||
|
||||
start_date, end_date = calculate_valid_period(current_date, active_license.start_date)
|
||||
new_license_usage = LicenseUsage(period_start_date=start_date,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from typing import Dict, Type, Optional
|
||||
from flask import current_app
|
||||
from config.processor_types import PROCESSOR_TYPES
|
||||
from typing import Dict, Type
|
||||
from config.type_defs.processor_types import PROCESSOR_TYPES
|
||||
from .base_processor import BaseProcessor
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import io
|
||||
import os
|
||||
from datetime import datetime as dt, timezone as tz
|
||||
|
||||
from celery import states
|
||||
@@ -13,7 +11,7 @@ from langchain_core.runnables import RunnablePassthrough
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from common.extensions import db, minio_client, template_manager
|
||||
from common.extensions import db, minio_client
|
||||
from common.models.document import DocumentVersion, Embedding, Document, Processor, Catalog
|
||||
from common.models.user import Tenant
|
||||
from common.utils.celery_utils import current_celery
|
||||
@@ -22,7 +20,7 @@ from common.utils.model_utils import create_language_template, get_model_variabl
|
||||
|
||||
from common.utils.business_event import BusinessEvent
|
||||
from common.utils.business_event_context import current_event
|
||||
from config.processor_types import PROCESSOR_TYPES
|
||||
from config.type_defs.processor_types import PROCESSOR_TYPES
|
||||
from eveai_workers.processors.processor_registry import ProcessorRegistry
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
class EveAI_Chat_Admin {
|
||||
private $version;
|
||||
|
||||
public function __construct($version) {
|
||||
$this->version = $version;
|
||||
}
|
||||
|
||||
public function add_plugin_admin_menu() {
|
||||
add_options_page(
|
||||
'EveAI Chat Settings', // Page title
|
||||
'EveAI Chat', // Menu title
|
||||
'manage_options', // Capability required
|
||||
'eveai-chat-settings', // Menu slug
|
||||
array($this, 'display_plugin_settings_page') // Callback function
|
||||
);
|
||||
}
|
||||
|
||||
public function register_settings() {
|
||||
register_setting(
|
||||
'eveai_chat_settings', // Option group
|
||||
'eveai_chat_settings', // Option name
|
||||
array($this, 'validate_settings') // Sanitization callback
|
||||
);
|
||||
|
||||
add_settings_section(
|
||||
'eveai_chat_general', // ID
|
||||
'General Settings', // Title
|
||||
array($this, 'section_info'), // Callback
|
||||
'eveai-chat-settings' // Page
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
'api_key', // ID
|
||||
'API Key', // Title
|
||||
array($this, 'api_key_callback'), // Callback
|
||||
'eveai-chat-settings', // Page
|
||||
'eveai_chat_general' // Section
|
||||
);
|
||||
|
||||
// Add more settings fields as needed
|
||||
}
|
||||
|
||||
public function section_info() {
|
||||
echo 'Enter your EveAI Chat configuration settings below:';
|
||||
}
|
||||
|
||||
public function api_key_callback() {
|
||||
$options = get_option('eveai_chat_settings');
|
||||
$api_key = isset($options['api_key']) ? $options['api_key'] : '';
|
||||
?>
|
||||
<input type="password"
|
||||
id="api_key"
|
||||
name="eveai_chat_settings[api_key]"
|
||||
value="<?php echo esc_attr($api_key); ?>"
|
||||
class="regular-text">
|
||||
<p class="description">Enter your EveAI API key. You can find this in your EveAI dashboard.</p>
|
||||
<?php
|
||||
}
|
||||
|
||||
public function validate_settings($input) {
|
||||
$new_input = array();
|
||||
|
||||
if(isset($input['api_key']))
|
||||
$new_input['api_key'] = sanitize_text_field($input['api_key']);
|
||||
|
||||
return $new_input;
|
||||
}
|
||||
|
||||
public function display_plugin_settings_page() {
|
||||
// Load the settings page template
|
||||
require_once plugin_dir_path(__FILE__) . 'views/settings-page.php';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<div class="wrap">
|
||||
<h1><?php echo esc_html(get_admin_page_title()); ?></h1>
|
||||
|
||||
<form action="options.php" method="post">
|
||||
<?php
|
||||
// Output security fields
|
||||
settings_fields('eveai_chat_settings');
|
||||
|
||||
// Output setting sections and their fields
|
||||
do_settings_sections('eveai-chat-settings');
|
||||
|
||||
// Output save settings button
|
||||
submit_button('Save Settings');
|
||||
?>
|
||||
</form>
|
||||
|
||||
<div class="eveai-chat-help">
|
||||
<h2>How to Use EveAI Chat</h2>
|
||||
<p>To add the chat widget to your pages or posts, use the following shortcode:</p>
|
||||
<code>[eveai_chat tenant_id="YOUR_TENANT_ID" language="en" supported_languages="en,fr,de,es"]</code>
|
||||
|
||||
<h3>Available Shortcode Parameters:</h3>
|
||||
<ul>
|
||||
<li><strong>tenant_id</strong> (required): Your EveAI tenant ID</li>
|
||||
<li><strong>language</strong> (optional): Default language for the chat widget (default: en)</li>
|
||||
<li><strong>supported_languages</strong> (optional): Comma-separated list of supported languages (default: en,fr,de,es)</li>
|
||||
<li><strong>server_url</strong> (optional): EveAI server URL (default: https://evie.askeveai.com)</li>
|
||||
<li><strong>specialist_id</strong> (optional): ID of the specialist to use (default: 1)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin Name: EveAI Chat Widget
|
||||
* Description: Integrates the EveAI chat interface into your WordPress site.
|
||||
* Version: 2.0.0
|
||||
*/
|
||||
|
||||
if (!defined('WPINC')) {
|
||||
die;
|
||||
}
|
||||
|
||||
// Define plugin constants
|
||||
define('EVEAI_CHAT_VERSION', '2.0.0');
|
||||
define('EVEAI_CHAT_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||
define('EVEAI_CHAT_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||
|
||||
// Require the loader class
|
||||
require_once EVEAI_CHAT_PLUGIN_DIR . 'includes/class-eveai-loader.php';
|
||||
|
||||
// Initialize the plugin
|
||||
function run_eveai_chat() {
|
||||
$plugin = new EveAI_Chat_Loader();
|
||||
$plugin->run();
|
||||
}
|
||||
|
||||
run_eveai_chat();
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
class EveAI_Chat_API {
|
||||
private $security;
|
||||
private $eveai_api_url;
|
||||
|
||||
public function __construct() {
|
||||
$this->security = new EveAI_Chat_Security();
|
||||
$this->eveai_api_url = 'https://api.eveai.com'; // Should come from settings
|
||||
}
|
||||
|
||||
public function register_routes() {
|
||||
register_rest_route('eveai/v1', '/session-token', array(
|
||||
'methods' => 'POST',
|
||||
'callback' => array($this, 'get_session_token'),
|
||||
'permission_callback' => array($this, 'verify_request'),
|
||||
'args' => array(
|
||||
'tenant_id' => array(
|
||||
'required' => true,
|
||||
'validate_callback' => function($param) {
|
||||
return is_numeric($param);
|
||||
}
|
||||
),
|
||||
'domain' => array(
|
||||
'required' => true,
|
||||
'validate_callback' => function($param) {
|
||||
return is_string($param) && !empty($param);
|
||||
}
|
||||
)
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
public function verify_request($request) {
|
||||
// Origin verification
|
||||
$origin = $request->get_header('origin');
|
||||
if (!$this->security->verify_origin($origin)) {
|
||||
return new WP_Error(
|
||||
'invalid_origin',
|
||||
'Invalid request origin',
|
||||
array('status' => 403)
|
||||
);
|
||||
}
|
||||
|
||||
// Nonce verification
|
||||
$nonce = $request->get_header('X-WP-Nonce');
|
||||
if (!wp_verify_nonce($nonce, 'wp_rest')) {
|
||||
return new WP_Error(
|
||||
'invalid_nonce',
|
||||
'Invalid nonce',
|
||||
array('status' => 403)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function get_session_token($request) {
|
||||
try {
|
||||
// Get the API key from WordPress options and decrypt it
|
||||
$settings = get_option('eveai_chat_settings');
|
||||
$encrypted_api_key = $settings['api_key'] ?? '';
|
||||
|
||||
if (empty($encrypted_api_key)) {
|
||||
return new WP_Error(
|
||||
'no_api_key',
|
||||
'API key not configured',
|
||||
array('status' => 500)
|
||||
);
|
||||
}
|
||||
|
||||
$api_key = $this->security->decrypt_sensitive_data($encrypted_api_key);
|
||||
|
||||
// Get parameters from request
|
||||
$tenant_id = $request->get_param('tenant_id');
|
||||
$domain = $request->get_param('domain');
|
||||
|
||||
// Request a session token from EveAI server
|
||||
$response = wp_remote_post(
|
||||
$this->eveai_api_url . '/session',
|
||||
array(
|
||||
'headers' => array(
|
||||
'Authorization' => 'Bearer ' . $api_key,
|
||||
'Content-Type' => 'application/json'
|
||||
),
|
||||
'body' => json_encode(array(
|
||||
'tenant_id' => $tenant_id,
|
||||
'domain' => $domain,
|
||||
'origin' => get_site_url()
|
||||
)),
|
||||
'timeout' => 15,
|
||||
'data_format' => 'body'
|
||||
)
|
||||
);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
throw new Exception($response->get_error_message());
|
||||
}
|
||||
|
||||
$response_code = wp_remote_retrieve_response_code($response);
|
||||
if ($response_code !== 200) {
|
||||
throw new Exception('Invalid response from EveAI server: ' . $response_code);
|
||||
}
|
||||
|
||||
$body = json_decode(wp_remote_retrieve_body($response), true);
|
||||
|
||||
if (empty($body['token'])) {
|
||||
throw new Exception('No token received from EveAI server');
|
||||
}
|
||||
|
||||
// Log the token generation (optional, for debugging)
|
||||
error_log(sprintf(
|
||||
'Generated session token for tenant %d from domain %s',
|
||||
$tenant_id,
|
||||
$domain
|
||||
));
|
||||
|
||||
return array(
|
||||
'success' => true,
|
||||
'session_token' => $body['token']
|
||||
);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log('EveAI session token generation failed: ' . $e->getMessage());
|
||||
|
||||
return new WP_Error(
|
||||
'token_generation_failed',
|
||||
'Failed to generate session token: ' . $e->getMessage(),
|
||||
array('status' => 500)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the session token with EveAI server
|
||||
* Can be used for additional security checks
|
||||
*/
|
||||
public function validate_session_token($token) {
|
||||
try {
|
||||
$response = wp_remote_post(
|
||||
$this->eveai_api_url . '/validate-token',
|
||||
array(
|
||||
'headers' => array(
|
||||
'Content-Type' => 'application/json'
|
||||
),
|
||||
'body' => json_encode(array(
|
||||
'token' => $token
|
||||
)),
|
||||
'timeout' => 15
|
||||
)
|
||||
);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$body = json_decode(wp_remote_retrieve_body($response), true);
|
||||
return isset($body['valid']) && $body['valid'] === true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log('Token validation failed: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
class EveAI_Chat_Loader {
|
||||
private $version;
|
||||
|
||||
public function __construct() {
|
||||
$this->version = EVEAI_CHAT_VERSION;
|
||||
$this->load_dependencies();
|
||||
}
|
||||
|
||||
private function load_dependencies() {
|
||||
// Load required files
|
||||
require_once EVEAI_CHAT_PLUGIN_DIR . 'includes/class-eveai-api.php';
|
||||
require_once EVEAI_CHAT_PLUGIN_DIR . 'includes/class-eveai-security.php';
|
||||
|
||||
// Load admin if in admin area
|
||||
if (is_admin()) {
|
||||
require_once EVEAI_CHAT_PLUGIN_DIR . 'admin/class-eveai-admin.php';
|
||||
}
|
||||
}
|
||||
|
||||
public function run() {
|
||||
// Initialize components
|
||||
$this->define_admin_hooks();
|
||||
$this->define_public_hooks();
|
||||
$this->define_shortcodes();
|
||||
}
|
||||
|
||||
private function define_admin_hooks() {
|
||||
if (is_admin()) {
|
||||
$admin = new EveAI_Chat_Admin($this->version);
|
||||
add_action('admin_menu', array($admin, 'add_plugin_admin_menu'));
|
||||
add_action('admin_init', array($admin, 'register_settings'));
|
||||
}
|
||||
}
|
||||
|
||||
private function define_public_hooks() {
|
||||
// Enqueue scripts and styles
|
||||
add_action('wp_enqueue_scripts', array($this, 'enqueue_assets'));
|
||||
|
||||
// Register REST API endpoints
|
||||
add_action('rest_api_init', array($this, 'register_rest_routes'));
|
||||
}
|
||||
|
||||
private function define_shortcodes() {
|
||||
add_shortcode('eveai_chat', array($this, 'render_chat_widget'));
|
||||
}
|
||||
|
||||
public function enqueue_assets() {
|
||||
// Enqueue required scripts
|
||||
wp_enqueue_script('socket-io', 'https://cdn.socket.io/4.0.1/socket.io.min.js', array(), '4.0.1', true);
|
||||
wp_enqueue_script('marked', 'https://cdn.jsdelivr.net/npm/marked/marked.min.js', array(), '1.0.0', true);
|
||||
|
||||
// Enqueue our scripts
|
||||
wp_enqueue_script(
|
||||
'eveai-sdk',
|
||||
EVEAI_CHAT_PLUGIN_URL . 'public/js/eveai-sdk.js',
|
||||
array('socket-io', 'marked'),
|
||||
$this->version,
|
||||
true
|
||||
);
|
||||
|
||||
wp_enqueue_script(
|
||||
'eveai-chat-widget',
|
||||
EVEAI_CHAT_PLUGIN_URL . 'public/js/eveai-chat-widget.js',
|
||||
array('eveai-sdk'),
|
||||
$this->version,
|
||||
true
|
||||
);
|
||||
|
||||
// Enqueue styles
|
||||
wp_enqueue_style('material-icons', 'https://fonts.googleapis.com/icon?family=Material+Icons');
|
||||
wp_enqueue_style(
|
||||
'eveai-chat-style',
|
||||
EVEAI_CHAT_PLUGIN_URL . 'public/css/eveai-chat-style.css',
|
||||
array(),
|
||||
$this->version
|
||||
);
|
||||
|
||||
// Add WordPress-specific configuration
|
||||
wp_localize_script('eveai-sdk', 'eveaiWP', array(
|
||||
'nonce' => wp_create_nonce('wp_rest'),
|
||||
'ajaxUrl' => admin_url('admin-ajax.php'),
|
||||
'restUrl' => rest_url('eveai/v1/')
|
||||
));
|
||||
}
|
||||
|
||||
public function register_rest_routes() {
|
||||
$api = new EveAI_Chat_API();
|
||||
$api->register_routes();
|
||||
}
|
||||
|
||||
public function render_chat_widget($atts) {
|
||||
$defaults = array(
|
||||
'tenant_id' => '',
|
||||
'language' => 'en',
|
||||
'supported_languages' => 'en,fr,de,es',
|
||||
'server_url' => 'https://evie.askeveai.com',
|
||||
'specialist_id' => '1'
|
||||
);
|
||||
|
||||
$atts = shortcode_atts($defaults, $atts, 'eveai_chat');
|
||||
$chat_id = 'chat-container-' . uniqid();
|
||||
|
||||
return sprintf(
|
||||
'<div id="%s"></div>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
const eveAI = new EveAI({
|
||||
tenantId: "%s",
|
||||
language: "%s",
|
||||
languages: "%s",
|
||||
serverUrl: "%s",
|
||||
specialistId: "%s",
|
||||
proxyUrl: "%s"
|
||||
});
|
||||
eveAI.initializeChat("%s");
|
||||
});
|
||||
</script>',
|
||||
$chat_id,
|
||||
esc_js($atts['tenant_id']),
|
||||
esc_js($atts['language']),
|
||||
esc_js($atts['supported_languages']),
|
||||
esc_js($atts['server_url']),
|
||||
esc_js($atts['specialist_id']),
|
||||
esc_js(rest_url('eveai/v1/session-token')),
|
||||
esc_js($chat_id)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
class EveAI_Chat_Security {
|
||||
public function verify_request($request) {
|
||||
// Verify nonce
|
||||
$nonce = $request->get_header('X-WP-Nonce');
|
||||
if (!wp_verify_nonce($nonce, 'wp_rest')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify origin
|
||||
$origin = $request->get_header('origin');
|
||||
if (!$this->verify_origin($origin)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function verify_origin($origin) {
|
||||
// Get the site URL
|
||||
$site_url = parse_url(get_site_url(), PHP_URL_HOST);
|
||||
$origin_host = parse_url($origin, PHP_URL_HOST);
|
||||
|
||||
// Check if origin matches site URL or is a subdomain
|
||||
return $origin_host === $site_url ||
|
||||
strpos($origin_host, '.' . $site_url) !== false;
|
||||
}
|
||||
|
||||
public function encrypt_sensitive_data($data) {
|
||||
if (empty($data)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$encryption_key = $this->get_encryption_key();
|
||||
$iv = openssl_random_pseudo_bytes(16);
|
||||
$encrypted = openssl_encrypt(
|
||||
$data,
|
||||
'AES-256-CBC',
|
||||
$encryption_key,
|
||||
0,
|
||||
$iv
|
||||
);
|
||||
|
||||
return base64_encode($iv . $encrypted);
|
||||
}
|
||||
|
||||
public function decrypt_sensitive_data($encrypted_data) {
|
||||
if (empty($encrypted_data)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$encryption_key = $this->get_encryption_key();
|
||||
$data = base64_decode($encrypted_data);
|
||||
$iv = substr($data, 0, 16);
|
||||
$encrypted = substr($data, 16);
|
||||
|
||||
return openssl_decrypt(
|
||||
$encrypted,
|
||||
'AES-256-CBC',
|
||||
$encryption_key,
|
||||
0,
|
||||
$iv
|
||||
);
|
||||
}
|
||||
|
||||
private function get_encryption_key() {
|
||||
$key = get_option('eveai_chat_encryption_key');
|
||||
if (!$key) {
|
||||
$key = bin2hex(random_bytes(32));
|
||||
update_option('eveai_chat_encryption_key', $key);
|
||||
}
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a local temporary token for additional security
|
||||
*/
|
||||
public function generate_local_token($tenant_id, $domain) {
|
||||
$data = array(
|
||||
'tenant_id' => $tenant_id,
|
||||
'domain' => $domain,
|
||||
'timestamp' => time(),
|
||||
'site_url' => get_site_url()
|
||||
);
|
||||
|
||||
return $this->encrypt_sensitive_data(json_encode($data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies if the domain is allowed for the given tenant
|
||||
*/
|
||||
public function verify_tenant_domain($tenant_id, $domain) {
|
||||
// This could be enhanced with a database check of allowed domains per tenant
|
||||
$allowed_domains = array(
|
||||
parse_url(get_site_url(), PHP_URL_HOST),
|
||||
'localhost',
|
||||
// Add other allowed domains as needed
|
||||
);
|
||||
|
||||
$domain_host = parse_url($domain, PHP_URL_HOST);
|
||||
return in_array($domain_host, $allowed_domains);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced origin verification
|
||||
*/
|
||||
public function verify_origin($origin) {
|
||||
if (empty($origin)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the allowed origins
|
||||
$site_url = parse_url(get_site_url(), PHP_URL_HOST);
|
||||
$allowed_origins = array(
|
||||
$site_url,
|
||||
'www.' . $site_url,
|
||||
'localhost',
|
||||
// Add any additional allowed origins
|
||||
);
|
||||
|
||||
$origin_host = parse_url($origin, PHP_URL_HOST);
|
||||
|
||||
// Check if origin matches allowed origins or is a subdomain
|
||||
foreach ($allowed_origins as $allowed_origin) {
|
||||
if ($origin_host === $allowed_origin ||
|
||||
strpos($origin_host, '.' . $allowed_origin) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
2
integrations/Wordpress/eveai-chat-widget/index.php
Normal file
2
integrations/Wordpress/eveai-chat-widget/index.php
Normal file
@@ -0,0 +1,2 @@
|
||||
<?php
|
||||
// Silence is golden.
|
||||
@@ -1,33 +0,0 @@
|
||||
// static/js/eveai-sdk.js
|
||||
class EveAI {
|
||||
constructor(tenantId, apiKey, domain, language, languages, serverUrl, specialistId) {
|
||||
this.tenantId = tenantId;
|
||||
this.apiKey = apiKey;
|
||||
this.domain = domain;
|
||||
this.language = language;
|
||||
this.languages = languages;
|
||||
this.serverUrl = serverUrl;
|
||||
this.specialistId = specialistId;
|
||||
|
||||
console.log('EveAI constructor:', { tenantId, apiKey, domain, language, languages, serverUrl, specialistId });
|
||||
}
|
||||
|
||||
initializeChat(containerId) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (container) {
|
||||
container.innerHTML = '<eveai-chat-widget></eveai-chat-widget>';
|
||||
customElements.whenDefined('eveai-chat-widget').then(() => {
|
||||
const chatWidget = container.querySelector('eveai-chat-widget');
|
||||
chatWidget.setAttribute('tenant-id', this.tenantId);
|
||||
chatWidget.setAttribute('api-key', this.apiKey);
|
||||
chatWidget.setAttribute('domain', this.domain);
|
||||
chatWidget.setAttribute('language', this.language);
|
||||
chatWidget.setAttribute('languages', this.languages);
|
||||
chatWidget.setAttribute('server-url', this.serverUrl);
|
||||
chatWidget.setAttribute('specialist-id', this.specialistId);
|
||||
});
|
||||
} else {
|
||||
console.error('Container not found');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,39 @@
|
||||
class EveAIChatWidget extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ['tenant-id', 'api-key', 'domain', 'language', 'languages', 'server-url', 'specialist-id'];
|
||||
return ['tenant-id', 'session-token', 'domain', 'language', 'languages', 'server-url', 'specialist-id'];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.socket = null; // Initialize socket to null
|
||||
this.attributesSet = false; // Flag to check if all attributes are set
|
||||
this.jwtToken = null; // Initialize jwtToken to null
|
||||
this.room = null;
|
||||
this.userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; // Detect user's timezone
|
||||
this.heartbeatInterval = null;
|
||||
this.idleTime = 0; // in milliseconds
|
||||
this.maxConnectionIdleTime = 1 * 60 * 60 * 1000; // 1 hours in milliseconds
|
||||
this.maxConnectionIdleTime = 1 * 60 * 60 * 1000; // 1 hour in milliseconds
|
||||
this.languages = []
|
||||
this.room = null;
|
||||
this.specialistId = null;
|
||||
console.log('EveAIChatWidget constructor called');
|
||||
// Bind methods to ensure correct 'this' context
|
||||
this.handleSendMessage = this.handleSendMessage.bind(this);
|
||||
this.updateAttributes = this.updateAttributes.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
console.log('connectedCallback called');
|
||||
this.innerHTML = this.getTemplate();
|
||||
this.setupElements()
|
||||
this.addEventListeners()
|
||||
|
||||
if (this.areAllAttributesSet() && !this.socket) {
|
||||
console.log('Attributes already set in connectedCallback, initializing socket');
|
||||
this.initializeSocket();
|
||||
}
|
||||
}
|
||||
|
||||
setupElements() {
|
||||
// Centralizes element querying
|
||||
this.messagesArea = this.querySelector('.messages-area');
|
||||
this.questionInput = this.querySelector('.question-area textarea');
|
||||
this.sendButton = this.querySelector('.send-icon');
|
||||
@@ -28,19 +41,17 @@ class EveAIChatWidget extends HTMLElement {
|
||||
this.statusLine = this.querySelector('.status-line');
|
||||
this.statusMessage = this.querySelector('.status-message');
|
||||
this.connectionStatusIcon = this.querySelector('.connection-status-icon');
|
||||
}
|
||||
|
||||
this.sendButton.addEventListener('click', () => this.handleSendMessage());
|
||||
addEventListeners() {
|
||||
// Centralized event listener setup
|
||||
this.sendButton.addEventListener('click', this.handleSendMessage);
|
||||
this.questionInput.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault(); // Prevent adding a new line
|
||||
this.handleSendMessage();
|
||||
}
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
this.handleSendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
if (this.areAllAttributesSet() && !this.socket) {
|
||||
console.log('Attributes already set in connectedCallback, initializing socket');
|
||||
this.initializeSocket();
|
||||
}
|
||||
}
|
||||
|
||||
populateLanguageDropdown() {
|
||||
@@ -68,22 +79,19 @@ class EveAIChatWidget extends HTMLElement {
|
||||
}
|
||||
|
||||
attributeChangedCallback(name, oldValue, newValue) {
|
||||
console.log(`attributeChangedCallback called: ${name} changed from ${oldValue} to ${newValue}`);
|
||||
console.log(`Attribute ${name} changed from ${oldValue} to ${newValue}`);
|
||||
this.updateAttributes();
|
||||
|
||||
if (this.areAllAttributesSet() && !this.socket) {
|
||||
console.log('All attributes set in attributeChangedCallback, initializing socket');
|
||||
this.attributesSet = true;
|
||||
console.log('All attributes are set, populating language dropdown');
|
||||
this.populateLanguageDropdown();
|
||||
console.log('All attributes are set, initializing socket')
|
||||
this.initializeSocket();
|
||||
}
|
||||
}
|
||||
|
||||
updateAttributes() {
|
||||
this.tenantId = parseInt(this.getAttribute('tenant-id'));
|
||||
this.apiKey = this.getAttribute('api-key');
|
||||
this.sessionToken = this.getAttribute('session_token');
|
||||
this.domain = this.getAttribute('domain');
|
||||
this.language = this.getAttribute('language');
|
||||
const languageAttr = this.getAttribute('languages');
|
||||
@@ -93,7 +101,7 @@ class EveAIChatWidget extends HTMLElement {
|
||||
this.specialistId = this.getAttribute('specialist-id');
|
||||
console.log('Updated attributes:', {
|
||||
tenantId: this.tenantId,
|
||||
apiKey: this.apiKey,
|
||||
sessionToken: this.sessionToken,
|
||||
domain: this.domain,
|
||||
language: this.language,
|
||||
currentLanguage: this.currentLanguage,
|
||||
@@ -105,7 +113,7 @@ class EveAIChatWidget extends HTMLElement {
|
||||
|
||||
areAllAttributesSet() {
|
||||
const tenantId = this.getAttribute('tenant-id');
|
||||
const apiKey = this.getAttribute('api-key');
|
||||
const sessionToken = this.getAttribute('session-token');
|
||||
const domain = this.getAttribute('domain');
|
||||
const language = this.getAttribute('language');
|
||||
const languages = this.getAttribute('languages');
|
||||
@@ -113,14 +121,14 @@ class EveAIChatWidget extends HTMLElement {
|
||||
const specialistId = this.getAttribute('specialist-id')
|
||||
console.log('Checking if all attributes are set:', {
|
||||
tenantId,
|
||||
apiKey,
|
||||
sessionToken,
|
||||
domain,
|
||||
language,
|
||||
languages,
|
||||
serverUrl,
|
||||
specialistId
|
||||
});
|
||||
return tenantId && apiKey && domain && language && languages && serverUrl && specialistId;
|
||||
return tenantId && sessionToken && domain && language && languages && serverUrl && specialistId;
|
||||
}
|
||||
|
||||
createLanguageDropdown() {
|
||||
@@ -156,27 +164,28 @@ class EveAIChatWidget extends HTMLElement {
|
||||
transports: ['websocket', 'polling'],
|
||||
query: {
|
||||
tenantId: this.tenantId,
|
||||
apiKey: this.apiKey // Ensure apiKey is included here
|
||||
sessionToken: this.sessionToken
|
||||
},
|
||||
auth: {
|
||||
token: 'Bearer ' + this.apiKey // Ensure token is included here
|
||||
token: this.sessionToken // Ensure token is included here
|
||||
// token: 'Bearer ' + this.sessionToken // Old setup - remove if everything works fine without Bearer
|
||||
},
|
||||
reconnectionAttempts: Infinity, // Infinite reconnection attempts
|
||||
reconnectionDelay: 5000, // Delay between reconnections
|
||||
timeout: 20000, // Connection timeout
|
||||
debug: true
|
||||
});
|
||||
|
||||
console.log(`Finished initializing socket connection to Evie`);
|
||||
this.setupSocketEventHandlers();
|
||||
}
|
||||
|
||||
setupSocketEventHandlers() {
|
||||
// connect handler --------------------------------------------------------
|
||||
this.socket.on('connect', (data) => {
|
||||
console.log('Socket connected OK');
|
||||
console.log('Connect event data:', data);
|
||||
console.log('Connect event this:', this);
|
||||
this.setStatusMessage('Connected to EveAI.');
|
||||
this.updateConnectionStatus(true);
|
||||
this.startHeartbeat();
|
||||
if (data && data.room) {
|
||||
if (data?.room) {
|
||||
this.room = data.room;
|
||||
console.log(`Joined room: ${this.room}`);
|
||||
} else {
|
||||
@@ -184,15 +193,11 @@ class EveAIChatWidget extends HTMLElement {
|
||||
}
|
||||
});
|
||||
|
||||
// authenticated handler --------------------------------------------------
|
||||
this.socket.on('authenticated', (data) => {
|
||||
console.log('Authenticated event received');
|
||||
console.log('Authentication event data:', data);
|
||||
console.log('Authentication event this:', this);
|
||||
this.setStatusMessage('Authenticated.');
|
||||
if (data && data.token) {
|
||||
this.jwtToken = data.token;
|
||||
}
|
||||
if (data && data.room) {
|
||||
if (data?.room) {
|
||||
this.room = data.room;
|
||||
console.log(`Confirmed room: ${this.room}`);
|
||||
} else {
|
||||
@@ -200,18 +205,21 @@ class EveAIChatWidget extends HTMLElement {
|
||||
}
|
||||
});
|
||||
|
||||
// connect-error handler --------------------------------------------------
|
||||
this.socket.on('connect_error', (err) => {
|
||||
console.error('Socket connection error:', err);
|
||||
this.setStatusMessage('EveAI Chat Widget needs further configuration by site administrator.');
|
||||
this.setStatusMessage('Connection Error: EveAI Chat Widget needs further configuration by site administrator.');
|
||||
this.updateConnectionStatus(false);
|
||||
});
|
||||
|
||||
// connect-timeout handler ------------------------------------------------
|
||||
this.socket.on('connect_timeout', () => {
|
||||
console.error('Socket connection timeout');
|
||||
this.setStatusMessage('EveAI Chat Widget needs further configuration by site administrator.');
|
||||
this.setStatusMessage('Connection Timeout: EveAI Chat Widget needs further configuration by site administrator.');
|
||||
this.updateConnectionStatus(false);
|
||||
});
|
||||
|
||||
// disconnect handler -----------------------------------------------------
|
||||
this.socket.on('disconnect', (reason) => {
|
||||
console.log('Socket disconnected: ', reason);
|
||||
if (reason === 'io server disconnect') {
|
||||
@@ -223,11 +231,13 @@ class EveAIChatWidget extends HTMLElement {
|
||||
this.stopHeartbeat();
|
||||
});
|
||||
|
||||
// reconnect_attempt handler ----------------------------------------------
|
||||
this.socket.on('reconnect_attempt', () => {
|
||||
console.log('Attempting to reconnect to the server...');
|
||||
this.setStatusMessage('Attempting to reconnect...');
|
||||
});
|
||||
|
||||
// reconnect handler ------------------------------------------------------
|
||||
this.socket.on('reconnect', () => {
|
||||
console.log('Successfully reconnected to the server');
|
||||
this.setStatusMessage('Reconnected to EveAI.');
|
||||
@@ -235,17 +245,16 @@ class EveAIChatWidget extends HTMLElement {
|
||||
this.startHeartbeat();
|
||||
});
|
||||
|
||||
// bot_response handler ---------------------------------------------------
|
||||
this.socket.on('bot_response', (data) => {
|
||||
console.log('Bot response received: ', data);
|
||||
console.log('data tenantId: ', data.tenantId)
|
||||
console.log('this tenantId: ', this.tenantId)
|
||||
if (data.tenantId === this.tenantId) {
|
||||
console.log('Starting task status check for:', data.taskId);
|
||||
setTimeout(() => this.startTaskCheck(data.taskId), 1000);
|
||||
this.setStatusMessage('Processing...');
|
||||
}
|
||||
});
|
||||
|
||||
// task_status handler ----------------------------------------------------
|
||||
this.socket.on('task_status', (data) => {
|
||||
console.log('Task status received:', data);
|
||||
this.handleTaskStatus(data);
|
||||
@@ -444,11 +453,8 @@ toggleFeedback(thumbsUp, thumbsDown, feedback, interactionId) {
|
||||
}
|
||||
|
||||
handleTaskStatus(data) {
|
||||
console.log('Handling task status:', data);
|
||||
|
||||
if (data.status === 'pending') {
|
||||
this.updateProgress();
|
||||
// Continue checking
|
||||
setTimeout(() => this.startTaskCheck(data.taskId), 1000);
|
||||
} else if (data.status === 'success') {
|
||||
if (data.results) {
|
||||
@@ -473,14 +479,9 @@ toggleFeedback(thumbsUp, thumbsDown, feedback, interactionId) {
|
||||
console.error('Socket is not initialized');
|
||||
return;
|
||||
}
|
||||
if (!this.jwtToken) {
|
||||
console.error('JWT token is not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedLanguage = this.languageSelect.value;
|
||||
|
||||
// Updated message structure to match specialist execution format
|
||||
const messageData = {
|
||||
tenantId: parseInt(this.tenantId),
|
||||
token: this.jwtToken,
|
||||
@@ -0,0 +1,87 @@
|
||||
class EveAI {
|
||||
constructor(config) {
|
||||
// Required parameters
|
||||
this.tenantId = config.tenantId;
|
||||
this.serverUrl = config.serverUrl;
|
||||
this.specialistId = config.specialistId;
|
||||
this.proxyUrl = config.proxyUrl;
|
||||
|
||||
// Optional parameters with defaults
|
||||
this.language = config.language || 'en';
|
||||
this.languages = config.languages?.split(',') || ['en'];
|
||||
this.domain = config.domain || window.location.origin;
|
||||
|
||||
// Internal state
|
||||
this.sessionToken = null;
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
async getSessionToken() {
|
||||
try {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
// Add WordPress-specific headers if they exist
|
||||
if (window.eveaiWP?.nonce) {
|
||||
headers['X-WP-Nonce'] = window.eveaiWP.nonce;
|
||||
}
|
||||
|
||||
const response = await fetch(this.proxyUrl, {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: JSON.stringify({
|
||||
tenant_id: this.tenantId,
|
||||
domain: this.domain
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get session token');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.session_token;
|
||||
} catch (error) {
|
||||
console.error('Error getting session token:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async initializeChat(containerId) {
|
||||
try {
|
||||
// Get session token before initializing chat
|
||||
this.sessionToken = await this.getSessionToken();
|
||||
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) {
|
||||
throw new Error('Container not found');
|
||||
}
|
||||
|
||||
const chatWidget = document.createElement('eveai-chat-widget');
|
||||
Object.entries({
|
||||
'tenant-id': this.tenantId,
|
||||
'session-token': this.sessionToken,
|
||||
'domain': this.domain,
|
||||
'language': this.language,
|
||||
'languages': this.languages.join(','),
|
||||
'server-url': this.serverUrl,
|
||||
'specialist-id': this.specialistId
|
||||
}).forEach(([attr, value]) => {
|
||||
chatWidget.setAttribute(attr, value);
|
||||
});
|
||||
|
||||
container.appendChild(chatWidget);
|
||||
this.initialized = true;
|
||||
|
||||
return chatWidget;
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize chat:', error);
|
||||
// Re-throw to allow custom error handling
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Make available globally
|
||||
window.EveAI = EveAI;
|
||||
27
integrations/Wordpress/eveai-chat-widget/uninstall.php
Normal file
27
integrations/Wordpress/eveai-chat-widget/uninstall.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
// If uninstall not called from WordPress, exit
|
||||
if (!defined('WP_UNINSTALL_PLUGIN')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Delete plugin options
|
||||
delete_option('eveai_chat_settings');
|
||||
delete_option('eveai_chat_encryption_key');
|
||||
|
||||
// Clean up any additional options or data that your plugin may have created
|
||||
// For example, if you've created any custom tables, you might want to drop them here
|
||||
|
||||
// If using multisite, you might want to loop through all sites
|
||||
if (is_multisite()) {
|
||||
global $wpdb;
|
||||
$blog_ids = $wpdb->get_col("SELECT blog_id FROM $wpdb->blogs");
|
||||
foreach ($blog_ids as $blog_id) {
|
||||
switch_to_blog($blog_id);
|
||||
|
||||
// Delete options for each site
|
||||
delete_option('eveai_chat_settings');
|
||||
delete_option('eveai_chat_encryption_key');
|
||||
|
||||
restore_current_blog();
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,10 @@ No additional configuration is needed; the plugin will automatically detect the
|
||||
|
||||
## Versions
|
||||
|
||||
### 2.0.x - Bug fixing of 2.0 release
|
||||
|
||||
### 2.0.0 - Allow for new API - introduced dynamic possibilities
|
||||
|
||||
### 1.1.1 - Add Reinitialisation functionality
|
||||
|
||||
### 1.1.0 - Add Catalog Functionality
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Plugin Name: EveAI Sync
|
||||
* Plugin URI: https://askeveai.com/
|
||||
* Description: Synchronizes WordPress content with EveAI API.
|
||||
* Version: 1.1.1
|
||||
* Version: 2.0.3
|
||||
* Author: Josako, Pieter Laroy
|
||||
* Author URI: https://askeveai.com/about/
|
||||
* License: GPL v2 or later
|
||||
@@ -17,7 +17,7 @@ if (!defined('ABSPATH')) {
|
||||
}
|
||||
|
||||
// Define plugin constants
|
||||
define('EVEAI_SYNC_VERSION', '1.1.1');
|
||||
define('EVEAI_SYNC_VERSION', '2.0.3');
|
||||
define('EVEAI_SYNC_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||
define('EVEAI_SYNC_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ class EveAI_API {
|
||||
private $api_key;
|
||||
private $access_token;
|
||||
private $token_expiry;
|
||||
private $catalog_id;
|
||||
|
||||
public function __construct() {
|
||||
$this->api_url = get_option('eveai_api_url');
|
||||
@@ -58,57 +59,70 @@ class EveAI_API {
|
||||
}
|
||||
|
||||
private function make_request($method, $endpoint, $data = null) {
|
||||
$this->ensure_valid_token();
|
||||
try {
|
||||
$this->ensure_valid_token();
|
||||
|
||||
error_log('EveAI API Request: ' . $method . ' ' . $this->api_url . $endpoint);
|
||||
error_log('EveAI API Request: ' . $method . ' ' . $this->api_url . $endpoint);
|
||||
|
||||
$url = $this->api_url . $endpoint;
|
||||
$url = $this->api_url . $endpoint;
|
||||
|
||||
$args = array(
|
||||
'method' => $method,
|
||||
'headers' => array(
|
||||
'Content-Type' => 'application/json',
|
||||
'Authorization' => 'Bearer ' . $this->access_token,
|
||||
)
|
||||
);
|
||||
$args = array(
|
||||
'method' => $method,
|
||||
'headers' => array(
|
||||
'Content-Type' => 'application/json',
|
||||
'Authorization' => 'Bearer ' . $this->access_token,
|
||||
)
|
||||
);
|
||||
|
||||
if ($data !== null) {
|
||||
$args['body'] = json_encode($data);
|
||||
if ($data !== null) {
|
||||
$args['body'] = json_encode($data);
|
||||
}
|
||||
|
||||
$response = wp_remote_request($url, $args);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
$error_message = $response->get_error_message();
|
||||
error_log('EveAI API Error: ' . $error_message);
|
||||
throw new Exception('API request failed: ' . $error_message);
|
||||
}
|
||||
|
||||
$body = wp_remote_retrieve_body($response);
|
||||
$status_code = wp_remote_retrieve_response_code($response);
|
||||
|
||||
error_log('EveAI API Response: ' . print_r($body, true));
|
||||
error_log('EveAI API Status Code: ' . $status_code);
|
||||
|
||||
// Check if the body is already an array (decoded JSON)
|
||||
if (!is_array($body)) {
|
||||
$body = json_decode($body, true);
|
||||
}
|
||||
|
||||
if ($status_code == 401) {
|
||||
// Token might have expired, try to get a new one and retry the request
|
||||
error_log('Token expired, trying to get a new one...');
|
||||
$this->get_new_token();
|
||||
return $this->make_request($method, $endpoint, $data);
|
||||
}
|
||||
|
||||
if ($status_code >= 400) {
|
||||
$error_type = isset($response_data['type']) ? $response_data['type'] : 'Unknown';
|
||||
$error_message = isset($response_data['message']) ? $response_data['message'] : 'Unknown error';
|
||||
$error_details = isset($response_data['debug']) ? json_encode($response_data['debug']) : '';
|
||||
|
||||
error_log("EveAI API Error ({$error_type}): {$error_message}");
|
||||
if ($error_details) {
|
||||
error_log("EveAI API Error Details: {$error_details}");
|
||||
}
|
||||
|
||||
throw new Exception("API error ({$error_type}): {$error_message}");
|
||||
}
|
||||
|
||||
return $response_data
|
||||
// return $body;
|
||||
} catch (Exception $e) {
|
||||
error_log("EveAI API Exception: " . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$response = wp_remote_request($url, $args);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
$error_message = $response->get_error_message();
|
||||
error_log('EveAI API Error: ' . $error_message);
|
||||
throw new Exception('API request failed: ' . $error_message);
|
||||
}
|
||||
|
||||
$body = wp_remote_retrieve_body($response);
|
||||
$status_code = wp_remote_retrieve_response_code($response);
|
||||
|
||||
error_log('EveAI API Response: ' . print_r($body, true));
|
||||
error_log('EveAI API Status Code: ' . $status_code);
|
||||
|
||||
// Check if the body is already an array (decoded JSON)
|
||||
if (!is_array($body)) {
|
||||
$body = json_decode($body, true);
|
||||
}
|
||||
|
||||
if ($status_code == 401) {
|
||||
// Token might have expired, try to get a new one and retry the request
|
||||
error_log('Token expired, trying to get a new one...');
|
||||
$this->get_new_token();
|
||||
return $this->make_request($method, $endpoint, $data);
|
||||
}
|
||||
|
||||
if ($status_code >= 400) {
|
||||
$error_message = isset($body['message']) ? $body['message'] : 'Unknown error';
|
||||
error_log('EveAI API Error: ' . $error_message);
|
||||
throw new Exception('API error: ' . $error_message);
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
|
||||
public function add_url($data) {
|
||||
@@ -121,10 +135,19 @@ class EveAI_API {
|
||||
}
|
||||
|
||||
public function invalidate_document($document_id) {
|
||||
$data = array(
|
||||
'valid_to' => gmdate('Y-m-d\TH:i:s\Z') // Current UTC time in ISO 8601 format
|
||||
);
|
||||
return $this->make_request('PUT', "/api/v1/documents/{$document_id}", $data);
|
||||
error_log("EveAI API: Attempting to invalidate document {$document_id}");
|
||||
|
||||
try {
|
||||
$result = $this->make_request('PUT', "/api/v1/documents/{$document_id}", [
|
||||
'valid_to' => gmdate('Y-m-d\TH:i:s\Z') // Current UTC time in ISO 8601 format
|
||||
]);
|
||||
|
||||
error_log("EveAI API: Successfully invalidated document {$document_id}");
|
||||
return $result;
|
||||
} catch (Exception $e) {
|
||||
error_log("EveAI API: Error invalidating document {$document_id}: " . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function refresh_document($document_id) {
|
||||
|
||||
@@ -100,19 +100,20 @@ class EveAI_Post_Handler {
|
||||
$post = get_post($post_id);
|
||||
return array(
|
||||
'name' => $post->post_title,
|
||||
'system_metadata' => json_encode([
|
||||
'user_metadata' => json_encode([
|
||||
'post_id' => $post_id,
|
||||
'type' => $post->post_type,
|
||||
'author' => get_the_author_meta('display_name', $post->post_author),
|
||||
'categories' => $post->post_type === 'post' ? wp_get_post_categories($post_id, array('fields' => 'names')) : [],
|
||||
'tags' => $post->post_type === 'post' ? wp_get_post_tags($post_id, array('fields' => 'names')) : [],
|
||||
]),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
private function has_metadata_changed($old_data, $new_data) {
|
||||
return $old_data['name'] !== $new_data['name'] ||
|
||||
$old_data['user_metadata'] !== $new_data['user_metadata'];
|
||||
(isset($old_data['user_metadata']) && isset($new_data['user_metadata']) &&
|
||||
$old_data['user_metadata'] !== $new_data['user_metadata']);
|
||||
}
|
||||
|
||||
private function refresh_document_with_info($evie_id, $data) {
|
||||
@@ -139,17 +140,27 @@ class EveAI_Post_Handler {
|
||||
}
|
||||
|
||||
public function handle_post_delete($post_id) {
|
||||
// First check if we have an EveAI document ID for this post
|
||||
$evie_id = get_post_meta($post_id, '_eveai_document_id', true);
|
||||
|
||||
if ($evie_id) {
|
||||
try {
|
||||
$this->api->invalidate_document($evie_id);
|
||||
error_log("EveAI: Attempting to invalidate document {$evie_id} for post {$post_id}");
|
||||
$result = $this->api->invalidate_document($evie_id);
|
||||
error_log("EveAI: Successfully invalidated document {$evie_id}");
|
||||
|
||||
// Clean up post meta
|
||||
delete_post_meta($post_id, '_eveai_document_id');
|
||||
delete_post_meta($post_id, '_eveai_document_version_id');
|
||||
|
||||
return true;
|
||||
} catch (Exception $e) {
|
||||
error_log('EveAI invalidate error: ' . $e->getMessage());
|
||||
add_action('admin_notices', function() use ($e) {
|
||||
echo '<div class="notice notice-error is-dismissible">';
|
||||
echo '<p>EveAI Sync Error: ' . esc_html($e->getMessage()) . '</p>';
|
||||
echo '</div>';
|
||||
});
|
||||
error_log("EveAI: Error invalidating document {$evie_id}: " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
error_log("EveAI: No document ID found for post {$post_id}, skipping invalidation");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,8 +206,10 @@ class EveAI_Post_Handler {
|
||||
|
||||
private function prepare_post_data($post_id) {
|
||||
$post = get_post($post_id);
|
||||
// Get the permalink but replace localhost with the FQDN, keeping the port
|
||||
$url = get_permalink($post_id);
|
||||
$data = array(
|
||||
'url' => get_permalink($post_id),
|
||||
'url' => $url,
|
||||
'name' => $post->post_title,
|
||||
'language' => get_option('eveai_default_language', 'en'),
|
||||
'valid_from' => get_gmt_from_date($post->post_date, 'Y-m-d\TH:i:s\Z'),
|
||||
|
||||
@@ -29,5 +29,19 @@ class EveAI_Sync {
|
||||
add_action('add_meta_boxes', array($this->admin, 'add_sync_meta_box'));
|
||||
add_action('eveai_sync_post', array($this->post_handler, 'sync_post'), 10, 2);
|
||||
add_action('wp_ajax_eveai_bulk_sync', array($this->admin, 'handle_bulk_sync_ajax'));
|
||||
// Additional delete hooks
|
||||
add_action('trashed_post', array($this->post_handler, 'handle_post_delete'));
|
||||
add_action('wp_trash_post', array($this->post_handler, 'handle_post_delete'));
|
||||
|
||||
// Add debug logging for delete actions
|
||||
add_action('before_delete_post', function($post_id) {
|
||||
error_log("EveAI Debug: before_delete_post triggered for post {$post_id}");
|
||||
});
|
||||
add_action('trashed_post', function($post_id) {
|
||||
error_log("EveAI Debug: trashed_post triggered for post {$post_id}");
|
||||
});
|
||||
add_action('wp_trash_post', function($post_id) {
|
||||
error_log("EveAI Debug: wp_trash_post triggered for post {$post_id}");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Updating Tenant, User and TenantProject to cope with new authentication mechanism
|
||||
|
||||
Revision ID: 392d48aa045e
|
||||
Revises: 51aba07aac6b
|
||||
Create Date: 2024-11-20 14:23:28.742766
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '392d48aa045e'
|
||||
down_revision = '51aba07aac6b'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('tenant', schema=None) as batch_op:
|
||||
batch_op.drop_column('encrypted_chat_api_key')
|
||||
batch_op.drop_column('encrypted_api_key')
|
||||
batch_op.drop_column('fallback_algorithms')
|
||||
batch_op.drop_column('usage_email')
|
||||
|
||||
with op.batch_alter_table('tenant_project', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('responsible_email', sa.String(length=255), nullable=True))
|
||||
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('is_primary_contact', sa.Boolean(), nullable=True))
|
||||
batch_op.add_column(sa.Column('is_financial_contact', sa.Boolean(), nullable=True))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.drop_column('is_financial_contact')
|
||||
batch_op.drop_column('is_primary_contact')
|
||||
|
||||
with op.batch_alter_table('tenant_project', schema=None) as batch_op:
|
||||
batch_op.drop_column('responsible_email')
|
||||
|
||||
with op.batch_alter_table('tenant', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('usage_email', sa.VARCHAR(length=255), autoincrement=False, nullable=True))
|
||||
batch_op.add_column(sa.Column('fallback_algorithms', postgresql.ARRAY(sa.VARCHAR(length=50)), autoincrement=False, nullable=True))
|
||||
batch_op.add_column(sa.Column('encrypted_api_key', sa.VARCHAR(length=500), autoincrement=False, nullable=True))
|
||||
batch_op.add_column(sa.Column('encrypted_chat_api_key', sa.VARCHAR(length=500), autoincrement=False, nullable=True))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Added TenantProject for API-key management
|
||||
|
||||
Revision ID: 51aba07aac6b
|
||||
Revises: 741bb5dac7f3
|
||||
Create Date: 2024-11-20 14:03:02.917769
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '51aba07aac6b'
|
||||
down_revision = '741bb5dac7f3'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('tenant_project',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('tenant_id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=50), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('services', postgresql.ARRAY(sa.String(length=50)), nullable=False),
|
||||
sa.Column('encrypted_api_key', sa.String(length=500), nullable=True),
|
||||
sa.Column('visual_api_key', sa.String(length=20), nullable=True),
|
||||
sa.Column('active', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('created_by', sa.Integer(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_by', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['created_by'], ['public.user.id'], ),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['public.tenant.id'], ),
|
||||
sa.ForeignKeyConstraint(['updated_by'], ['public.user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
schema='public'
|
||||
)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('tenant_project', schema='public')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Remove obsolete fields from tenants due to Catalog introduction
|
||||
|
||||
Revision ID: 741bb5dac7f3
|
||||
Revises: a678c84d5633
|
||||
Create Date: 2024-10-16 11:34:59.194992
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '741bb5dac7f3'
|
||||
down_revision = 'a678c84d5633'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('tenant', schema=None) as batch_op:
|
||||
batch_op.drop_column('rag_tuning')
|
||||
batch_op.drop_column('html_excluded_classes')
|
||||
batch_op.drop_column('html_tags')
|
||||
batch_op.drop_column('chat_RAG_temperature')
|
||||
batch_op.drop_column('html_excluded_elements')
|
||||
batch_op.drop_column('embed_tuning')
|
||||
batch_op.drop_column('es_similarity_threshold')
|
||||
batch_op.drop_column('chat_no_RAG_temperature')
|
||||
batch_op.drop_column('html_end_tags')
|
||||
batch_op.drop_column('html_included_elements')
|
||||
batch_op.drop_column('max_chunk_size')
|
||||
batch_op.drop_column('min_chunk_size')
|
||||
batch_op.drop_column('es_k')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('tenant', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('es_k', sa.INTEGER(), autoincrement=False, nullable=True))
|
||||
batch_op.add_column(sa.Column('min_chunk_size', sa.INTEGER(), autoincrement=False, nullable=True))
|
||||
batch_op.add_column(sa.Column('max_chunk_size', sa.INTEGER(), autoincrement=False, nullable=True))
|
||||
batch_op.add_column(sa.Column('html_included_elements', postgresql.ARRAY(sa.VARCHAR(length=50)), autoincrement=False, nullable=True))
|
||||
batch_op.add_column(sa.Column('html_end_tags', postgresql.ARRAY(sa.VARCHAR(length=10)), autoincrement=False, nullable=True))
|
||||
batch_op.add_column(sa.Column('chat_no_RAG_temperature', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True))
|
||||
batch_op.add_column(sa.Column('es_similarity_threshold', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True))
|
||||
batch_op.add_column(sa.Column('embed_tuning', sa.BOOLEAN(), autoincrement=False, nullable=True))
|
||||
batch_op.add_column(sa.Column('html_excluded_elements', postgresql.ARRAY(sa.VARCHAR(length=50)), autoincrement=False, nullable=True))
|
||||
batch_op.add_column(sa.Column('chat_RAG_temperature', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True))
|
||||
batch_op.add_column(sa.Column('html_tags', postgresql.ARRAY(sa.VARCHAR(length=10)), autoincrement=False, nullable=True))
|
||||
batch_op.add_column(sa.Column('html_excluded_classes', postgresql.ARRAY(sa.VARCHAR(length=200)), autoincrement=False, nullable=True))
|
||||
batch_op.add_column(sa.Column('rag_tuning', sa.BOOLEAN(), autoincrement=False, nullable=True))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Add Financial Role
|
||||
|
||||
Revision ID: f0ab991a6411
|
||||
Revises: 392d48aa045e
|
||||
Create Date: 2024-11-20 14:30:19.068984
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'f0ab991a6411'
|
||||
down_revision = '392d48aa045e'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.execute("""
|
||||
INSERT INTO public.role (name, description)
|
||||
VALUES ('Tenant Financial', 'Role for managing tenant financial operations');
|
||||
""")
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.execute("""
|
||||
DELETE FROM public.role
|
||||
WHERE name = 'Tenant Financial';
|
||||
""")
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -51,6 +51,7 @@ http {
|
||||
location = / {
|
||||
return 301 /admin/;
|
||||
}
|
||||
|
||||
location / {
|
||||
root /etc/nginx/public;
|
||||
index index.html index.htm;
|
||||
|
||||
@@ -539,3 +539,101 @@ select.select2[multiple] {
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
/* REQUIRED FIELD SETTINGS ---------------------------------------------------- */
|
||||
/* Required field indicator styling */
|
||||
.field-label-wrapper {
|
||||
display: flex;
|
||||
align-items: baseline; /* Changed from center to baseline for proper text alignment */
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.required-field-indicator {
|
||||
display: inline-flex;
|
||||
align-items: baseline; /* Match the wrapper's alignment */
|
||||
}
|
||||
|
||||
.required-field-indicator .required-icon {
|
||||
font-size: 0.7rem;
|
||||
transition: transform 0.2s ease-in-out;
|
||||
opacity: 0.8;
|
||||
line-height: 1; /* Ensure proper vertical alignment */
|
||||
}
|
||||
|
||||
/* Hover animation */
|
||||
.field-label-wrapper:hover .required-icon {
|
||||
transform: scale(1.2);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Animation when field is invalid */
|
||||
.is-invalid + .field-label-wrapper .required-icon {
|
||||
color: var(--bs-danger);
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide visually but maintain accessibility */
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* TAB ERROR STYLES ----------------------------------------------------------- */
|
||||
/* Style for tabs with errors */
|
||||
.nav-link.has-error {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-link.has-error::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--bs-danger);
|
||||
border-radius: 50%;
|
||||
transform: translate(50%, -50%);
|
||||
}
|
||||
|
||||
/* Enhance the invalid field visualization */
|
||||
.form-control:invalid {
|
||||
border-color: var(--bs-danger);
|
||||
box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);
|
||||
}
|
||||
|
||||
/* Add smooth transition for tab changes */
|
||||
.tab-pane {
|
||||
transition: opacity 0.15s linear;
|
||||
}
|
||||
|
||||
.tab-pane.fade {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.tab-pane.fade.show {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user