- 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:
Josako
2024-11-21 17:24:33 +01:00
parent 4c009949b3
commit 7702a6dfcc
72 changed files with 2338 additions and 503 deletions

1
.gitignore vendored
View File

@@ -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

View File

@@ -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}>"

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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('.')

View File

@@ -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

View File

@@ -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

View File

@@ -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)}"

View File

@@ -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

View File

View 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.",
},
}

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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))

View File

@@ -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:

View File

@@ -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>

View 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 %}

View 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>

View 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>-->
<!-- &lt;!&ndash; Additional buttons can be added here for other actions &ndash;&gt;-->
<!-- </div>-->
</form>
{% endblock %}
{% block content_footer %}
{{ render_pagination(pagination, 'entitlements_bp.view_licenses') }}
{% endblock %}
{% block scripts %}
{% endblock %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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">

View File

@@ -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 {

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View File

@@ -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()

View File

@@ -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'])

View File

@@ -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):

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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}')

View File

@@ -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()]

View File

@@ -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

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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';
}
}

View File

@@ -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>

View File

@@ -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();

View File

@@ -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;
}
}
}

View File

@@ -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)
);
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,2 @@
<?php
// Silence is golden.

View File

@@ -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');
}
}
}

View File

@@ -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,

View File

@@ -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;

View 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();
}
}

View File

@@ -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

View File

@@ -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__));

View 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) {

View File

@@ -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'),

View File

@@ -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}");
});
}
}

View File

@@ -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 ###

View File

@@ -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 ###

View File

@@ -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 ###

View File

@@ -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 ###

View File

@@ -51,6 +51,7 @@ http {
location = / {
return 301 /admin/;
}
location / {
root /etc/nginx/public;
index index.html index.htm;

View File

@@ -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;
}