Compare commits
3 Commits
v2.3.1-alf
...
v2.3.2-alf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57c0e7a1ba | ||
|
|
0d05499d2b | ||
|
|
b4e58659a8 |
@@ -11,6 +11,7 @@ from flask_restx import Api
|
|||||||
from prometheus_flask_exporter import PrometheusMetrics
|
from prometheus_flask_exporter import PrometheusMetrics
|
||||||
|
|
||||||
from .utils.cache.eveai_cache_manager import EveAICacheManager
|
from .utils.cache.eveai_cache_manager import EveAICacheManager
|
||||||
|
from .utils.content_utils import ContentManager
|
||||||
from .utils.simple_encryption import SimpleEncryption
|
from .utils.simple_encryption import SimpleEncryption
|
||||||
from .utils.minio_utils import MinioClient
|
from .utils.minio_utils import MinioClient
|
||||||
|
|
||||||
@@ -30,4 +31,5 @@ simple_encryption = SimpleEncryption()
|
|||||||
minio_client = MinioClient()
|
minio_client = MinioClient()
|
||||||
metrics = PrometheusMetrics.for_app_factory()
|
metrics = PrometheusMetrics.for_app_factory()
|
||||||
cache_manager = EveAICacheManager()
|
cache_manager = EveAICacheManager()
|
||||||
|
content_manager = ContentManager()
|
||||||
|
|
||||||
|
|||||||
@@ -215,3 +215,24 @@ class SpecialistDispatcher(db.Model):
|
|||||||
dispatcher_id = db.Column(db.Integer, db.ForeignKey(Dispatcher.id, ondelete='CASCADE'), primary_key=True)
|
dispatcher_id = db.Column(db.Integer, db.ForeignKey(Dispatcher.id, ondelete='CASCADE'), primary_key=True)
|
||||||
|
|
||||||
dispatcher = db.relationship("Dispatcher", backref="specialist_dispatchers")
|
dispatcher = db.relationship("Dispatcher", backref="specialist_dispatchers")
|
||||||
|
|
||||||
|
|
||||||
|
class SpecialistMagicLink(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(50), nullable=False)
|
||||||
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
specialist_id = db.Column(db.Integer, db.ForeignKey(Specialist.id, ondelete='CASCADE'), nullable=False)
|
||||||
|
magic_link_code = db.Column(db.String(55), nullable=False, unique=True)
|
||||||
|
|
||||||
|
valid_from = db.Column(db.DateTime, nullable=True)
|
||||||
|
valid_to = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
|
specialist_args = db.Column(JSONB, nullable=True)
|
||||||
|
|
||||||
|
created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now())
|
||||||
|
created_by = db.Column(db.Integer, db.ForeignKey(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(User.id))
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<SpecialistMagicLink {self.specialist_id} {self.magic_link_code}>"
|
||||||
|
|||||||
@@ -271,3 +271,13 @@ class PartnerTenant(db.Model):
|
|||||||
created_by = db.Column(db.Integer, db.ForeignKey('public.user.id'), nullable=True)
|
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_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'), nullable=True)
|
updated_by = db.Column(db.Integer, db.ForeignKey('public.user.id'), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SpecialistMagicLinkTenant(db.Model):
|
||||||
|
__bind_key__ = 'public'
|
||||||
|
__table_args__ = {'schema': 'public'}
|
||||||
|
|
||||||
|
magic_link_code = db.Column(db.String(55), primary_key=True)
|
||||||
|
tenant_id = db.Column(db.Integer, db.ForeignKey('public.tenant.id'), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from sqlalchemy.exc import SQLAlchemyError
|
|||||||
from common.extensions import db
|
from common.extensions import db
|
||||||
from common.models.entitlements import PartnerServiceLicenseTier
|
from common.models.entitlements import PartnerServiceLicenseTier
|
||||||
from common.models.user import Partner
|
from common.models.user import Partner
|
||||||
from common.utils.eveai_exceptions import EveAINoManagementPartnerService
|
from common.utils.eveai_exceptions import EveAINoManagementPartnerService, EveAINoSessionPartner
|
||||||
from common.utils.model_logging_utils import set_logging_information
|
from common.utils.model_logging_utils import set_logging_information
|
||||||
|
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ class LicenseTierServices:
|
|||||||
# Get partner service (MANAGEMENT_SERVICE type)
|
# Get partner service (MANAGEMENT_SERVICE type)
|
||||||
partner = Partner.query.get(partner_id)
|
partner = Partner.query.get(partner_id)
|
||||||
if not partner:
|
if not partner:
|
||||||
return
|
raise EveAINoSessionPartner()
|
||||||
|
|
||||||
# Find a management service for this partner
|
# Find a management service for this partner
|
||||||
management_service = next((service for service in session['partner']['services']
|
management_service = next((service for service in session['partner']['services']
|
||||||
|
|||||||
215
common/utils/content_utils.py
Normal file
215
common/utils/content_utils.py
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
from packaging import version
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class ContentManager:
|
||||||
|
def __init__(self, app=None):
|
||||||
|
self.app = app
|
||||||
|
if app:
|
||||||
|
self.init_app(app)
|
||||||
|
|
||||||
|
def init_app(self, app):
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
# Controleer of het pad bestaat
|
||||||
|
if not os.path.exists(app.config['CONTENT_DIR']):
|
||||||
|
logger.warning(f"Content directory not found at: {app.config['CONTENT_DIR']}")
|
||||||
|
else:
|
||||||
|
logger.info(f"Content directory configured at: {app.config['CONTENT_DIR']}")
|
||||||
|
|
||||||
|
def get_content_path(self, content_type, major_minor=None, patch=None):
|
||||||
|
"""
|
||||||
|
Geef het volledige pad naar een contentbestand
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_type (str): Type content (bv. 'changelog', 'terms')
|
||||||
|
major_minor (str, optional): Major.Minor versie (bv. '1.0')
|
||||||
|
patch (str, optional): Patchnummer (bv. '5')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Volledige pad naar de content map of bestand
|
||||||
|
"""
|
||||||
|
content_path = os.path.join(self.app.config['CONTENT_DIR'], content_type)
|
||||||
|
|
||||||
|
if major_minor:
|
||||||
|
content_path = os.path.join(content_path, major_minor)
|
||||||
|
|
||||||
|
if patch:
|
||||||
|
content_path = os.path.join(content_path, f"{major_minor}.{patch}.md")
|
||||||
|
|
||||||
|
return content_path
|
||||||
|
|
||||||
|
def _parse_version(self, filename):
|
||||||
|
"""Parse een versienummer uit een bestandsnaam"""
|
||||||
|
match = re.match(r'(\d+\.\d+)\.(\d+)\.md', filename)
|
||||||
|
if match:
|
||||||
|
return match.group(1), match.group(2)
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def get_latest_version(self, content_type, major_minor=None):
|
||||||
|
"""
|
||||||
|
Verkrijg de laatste versie van een bepaald contenttype
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_type (str): Type content (bv. 'changelog', 'terms')
|
||||||
|
major_minor (str, optional): Specifieke major.minor versie, anders de hoogste
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (major_minor, patch, full_version) of None als niet gevonden
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Basispad voor dit contenttype
|
||||||
|
content_path = os.path.join(self.app.config['CONTENT_DIR'], content_type)
|
||||||
|
|
||||||
|
if not os.path.exists(content_path):
|
||||||
|
logger.error(f"Content path does not exist: {content_path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Als geen major_minor opgegeven, vind de hoogste
|
||||||
|
if not major_minor:
|
||||||
|
available_versions = os.listdir(content_path)
|
||||||
|
if not available_versions:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Sorteer op versienummer (major.minor)
|
||||||
|
available_versions.sort(key=lambda v: version.parse(v))
|
||||||
|
major_minor = available_versions[-1]
|
||||||
|
|
||||||
|
# Nu we major_minor hebben, zoek de hoogste patch
|
||||||
|
major_minor_path = os.path.join(content_path, major_minor)
|
||||||
|
|
||||||
|
if not os.path.exists(major_minor_path):
|
||||||
|
logger.error(f"Version path does not exist: {major_minor_path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
files = os.listdir(major_minor_path)
|
||||||
|
version_files = []
|
||||||
|
|
||||||
|
for file in files:
|
||||||
|
mm, p = self._parse_version(file)
|
||||||
|
if mm == major_minor and p:
|
||||||
|
version_files.append((mm, p, f"{mm}.{p}"))
|
||||||
|
|
||||||
|
if not version_files:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Sorteer op patch nummer
|
||||||
|
version_files.sort(key=lambda v: int(v[1]))
|
||||||
|
return version_files[-1]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error finding latest version for {content_type}: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def read_content(self, content_type, major_minor=None, patch=None):
|
||||||
|
"""
|
||||||
|
Lees content met versieondersteuning
|
||||||
|
|
||||||
|
Als major_minor en patch niet zijn opgegeven, wordt de laatste versie gebruikt.
|
||||||
|
Als alleen major_minor is opgegeven, wordt de laatste patch van die versie gebruikt.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_type (str): Type content (bv. 'changelog', 'terms')
|
||||||
|
major_minor (str, optional): Major.Minor versie (bv. '1.0')
|
||||||
|
patch (str, optional): Patchnummer (bv. '5')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {
|
||||||
|
'content': str,
|
||||||
|
'version': str,
|
||||||
|
'content_type': str
|
||||||
|
} of None bij fout
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Als geen versie opgegeven, vind de laatste
|
||||||
|
if not major_minor:
|
||||||
|
version_info = self.get_latest_version(content_type)
|
||||||
|
if not version_info:
|
||||||
|
logger.error(f"No versions found for {content_type}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
major_minor, patch, full_version = version_info
|
||||||
|
|
||||||
|
# Als geen patch opgegeven, vind de laatste patch voor deze major_minor
|
||||||
|
elif not patch:
|
||||||
|
version_info = self.get_latest_version(content_type, major_minor)
|
||||||
|
if not version_info:
|
||||||
|
logger.error(f"No versions found for {content_type} {major_minor}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
major_minor, patch, full_version = version_info
|
||||||
|
else:
|
||||||
|
full_version = f"{major_minor}.{patch}"
|
||||||
|
|
||||||
|
# Nu hebben we major_minor en patch, lees het bestand
|
||||||
|
file_path = self.get_content_path(content_type, major_minor, patch)
|
||||||
|
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
logger.error(f"Content file does not exist: {file_path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as file:
|
||||||
|
content = file.read()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'content': content,
|
||||||
|
'version': full_version,
|
||||||
|
'content_type': content_type
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error reading content {content_type} {major_minor}.{patch}: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def list_content_types(self):
|
||||||
|
"""Lijst alle beschikbare contenttypes op"""
|
||||||
|
try:
|
||||||
|
return [d for d in os.listdir(self.app.config['CONTENT_DIR'])
|
||||||
|
if os.path.isdir(os.path.join(self.app.config['CONTENT_DIR'], d))]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing content types: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def list_versions(self, content_type):
|
||||||
|
"""
|
||||||
|
Lijst alle beschikbare versies voor een contenttype
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: Lijst van dicts met versie-informatie
|
||||||
|
[{'version': '1.0.0', 'path': '/path/to/file', 'date_modified': datetime}]
|
||||||
|
"""
|
||||||
|
versions = []
|
||||||
|
try:
|
||||||
|
content_path = os.path.join(self.app.config['CONTENT_DIR'], content_type)
|
||||||
|
|
||||||
|
if not os.path.exists(content_path):
|
||||||
|
return []
|
||||||
|
|
||||||
|
for major_minor in os.listdir(content_path):
|
||||||
|
major_minor_path = os.path.join(content_path, major_minor)
|
||||||
|
|
||||||
|
if not os.path.isdir(major_minor_path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for file in os.listdir(major_minor_path):
|
||||||
|
mm, p = self._parse_version(file)
|
||||||
|
if mm and p:
|
||||||
|
file_path = os.path.join(major_minor_path, file)
|
||||||
|
mod_time = os.path.getmtime(file_path)
|
||||||
|
versions.append({
|
||||||
|
'version': f"{mm}.{p}",
|
||||||
|
'path': file_path,
|
||||||
|
'date_modified': mod_time
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sorteer op versienummer
|
||||||
|
versions.sort(key=lambda v: version.parse(v['version']))
|
||||||
|
return versions
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing versions for {content_type}: {str(e)}")
|
||||||
|
return []
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
import jinja2
|
import jinja2
|
||||||
@@ -12,6 +13,7 @@ def not_found_error(error):
|
|||||||
if not current_user.is_authenticated:
|
if not current_user.is_authenticated:
|
||||||
return redirect(prefixed_url_for('security.login'))
|
return redirect(prefixed_url_for('security.login'))
|
||||||
current_app.logger.error(f"Not Found Error: {error}")
|
current_app.logger.error(f"Not Found Error: {error}")
|
||||||
|
current_app.logger.error(traceback.format_exc())
|
||||||
return render_template('error/404.html'), 404
|
return render_template('error/404.html'), 404
|
||||||
|
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ def internal_server_error(error):
|
|||||||
if not current_user.is_authenticated:
|
if not current_user.is_authenticated:
|
||||||
return redirect(prefixed_url_for('security.login'))
|
return redirect(prefixed_url_for('security.login'))
|
||||||
current_app.logger.error(f"Internal Server Error: {error}")
|
current_app.logger.error(f"Internal Server Error: {error}")
|
||||||
|
current_app.logger.error(traceback.format_exc())
|
||||||
return render_template('error/500.html'), 500
|
return render_template('error/500.html'), 500
|
||||||
|
|
||||||
|
|
||||||
@@ -26,6 +29,7 @@ def not_authorised_error(error):
|
|||||||
if not current_user.is_authenticated:
|
if not current_user.is_authenticated:
|
||||||
return redirect(prefixed_url_for('security.login'))
|
return redirect(prefixed_url_for('security.login'))
|
||||||
current_app.logger.error(f"Not Authorised Error: {error}")
|
current_app.logger.error(f"Not Authorised Error: {error}")
|
||||||
|
current_app.logger.error(traceback.format_exc())
|
||||||
return render_template('error/401.html')
|
return render_template('error/401.html')
|
||||||
|
|
||||||
|
|
||||||
@@ -33,6 +37,7 @@ def access_forbidden(error):
|
|||||||
if not current_user.is_authenticated:
|
if not current_user.is_authenticated:
|
||||||
return redirect(prefixed_url_for('security.login'))
|
return redirect(prefixed_url_for('security.login'))
|
||||||
current_app.logger.error(f"Access Forbidden: {error}")
|
current_app.logger.error(f"Access Forbidden: {error}")
|
||||||
|
current_app.logger.error(traceback.format_exc())
|
||||||
return render_template('error/403.html')
|
return render_template('error/403.html')
|
||||||
|
|
||||||
|
|
||||||
@@ -42,6 +47,7 @@ def key_error_handler(error):
|
|||||||
return redirect(prefixed_url_for('security.login'))
|
return redirect(prefixed_url_for('security.login'))
|
||||||
# For other KeyErrors, you might want to log the error and return a generic error page
|
# For other KeyErrors, you might want to log the error and return a generic error page
|
||||||
current_app.logger.error(f"Key Error: {error}")
|
current_app.logger.error(f"Key Error: {error}")
|
||||||
|
current_app.logger.error(traceback.format_exc())
|
||||||
return render_template('error/generic.html', error_message="An unexpected error occurred"), 500
|
return render_template('error/generic.html', error_message="An unexpected error occurred"), 500
|
||||||
|
|
||||||
|
|
||||||
@@ -76,6 +82,7 @@ def no_tenant_selected_error(error):
|
|||||||
a long period of inactivity. The user will be redirected to the login page.
|
a long period of inactivity. The user will be redirected to the login page.
|
||||||
"""
|
"""
|
||||||
current_app.logger.error(f"No Session Tenant Error: {error}")
|
current_app.logger.error(f"No Session Tenant Error: {error}")
|
||||||
|
current_app.logger.error(traceback.format_exc())
|
||||||
flash('Your session expired. You will have to re-enter your credentials', 'warning')
|
flash('Your session expired. You will have to re-enter your credentials', 'warning')
|
||||||
|
|
||||||
# Perform logout if user is authenticated
|
# Perform logout if user is authenticated
|
||||||
@@ -95,6 +102,26 @@ def general_exception(e):
|
|||||||
error_details=str(e)), 500
|
error_details=str(e)), 500
|
||||||
|
|
||||||
|
|
||||||
|
def template_not_found_error(error):
|
||||||
|
"""Handle Jinja2 TemplateNotFound exceptions."""
|
||||||
|
current_app.logger.error(f'Template not found: {error.name}')
|
||||||
|
current_app.logger.error(f'Search Paths: {current_app.jinja_loader.list_templates()}')
|
||||||
|
current_app.logger.error(traceback.format_exc())
|
||||||
|
return render_template('error/500.html',
|
||||||
|
error_type="Template Not Found",
|
||||||
|
error_details=f"Template '{error.name}' could not be found."), 404
|
||||||
|
|
||||||
|
|
||||||
|
def template_syntax_error(error):
|
||||||
|
"""Handle Jinja2 TemplateSyntaxError exceptions."""
|
||||||
|
current_app.logger.error(f'Template syntax error: {error.message}')
|
||||||
|
current_app.logger.error(f'In template {error.filename}, line {error.lineno}')
|
||||||
|
current_app.logger.error(traceback.format_exc())
|
||||||
|
return render_template('error/500.html',
|
||||||
|
error_type="Template Syntax Error",
|
||||||
|
error_details=f"Error in template '{error.filename}' at line {error.lineno}: {error.message}"), 500
|
||||||
|
|
||||||
|
|
||||||
def register_error_handlers(app):
|
def register_error_handlers(app):
|
||||||
app.register_error_handler(404, not_found_error)
|
app.register_error_handler(404, not_found_error)
|
||||||
app.register_error_handler(500, internal_server_error)
|
app.register_error_handler(500, internal_server_error)
|
||||||
@@ -103,17 +130,6 @@ def register_error_handlers(app):
|
|||||||
app.register_error_handler(EveAINoSessionTenant, no_tenant_selected_error)
|
app.register_error_handler(EveAINoSessionTenant, no_tenant_selected_error)
|
||||||
app.register_error_handler(KeyError, key_error_handler)
|
app.register_error_handler(KeyError, key_error_handler)
|
||||||
app.register_error_handler(AttributeError, attribute_error_handler)
|
app.register_error_handler(AttributeError, attribute_error_handler)
|
||||||
|
app.register_error_handler(jinja2.TemplateNotFound, template_not_found_error)
|
||||||
|
app.register_error_handler(jinja2.TemplateSyntaxError, template_syntax_error)
|
||||||
app.register_error_handler(Exception, general_exception)
|
app.register_error_handler(Exception, general_exception)
|
||||||
|
|
||||||
@app.errorhandler(jinja2.TemplateNotFound)
|
|
||||||
def template_not_found(error):
|
|
||||||
app.logger.error(f'Template not found: {error.name}')
|
|
||||||
app.logger.error(f'Search Paths: {app.jinja_loader.list_templates()}')
|
|
||||||
return f'Template not found: {error.name}. Check logs for details.', 404
|
|
||||||
|
|
||||||
@app.errorhandler(jinja2.TemplateSyntaxError)
|
|
||||||
def template_syntax_error(error):
|
|
||||||
app.logger.error(f'Template syntax error: {error.message}')
|
|
||||||
app.logger.error(f'In template {error.filename}, line {error.lineno}')
|
|
||||||
return f'Template syntax error: {error.message}', 500
|
|
||||||
|
|
||||||
@@ -172,6 +172,9 @@ class Config(object):
|
|||||||
# Entitlement Constants
|
# Entitlement Constants
|
||||||
ENTITLEMENTS_MAX_PENDING_DAYS = 5 # Defines the maximum number of days a pending entitlement can be active
|
ENTITLEMENTS_MAX_PENDING_DAYS = 5 # Defines the maximum number of days a pending entitlement can be active
|
||||||
|
|
||||||
|
# Content Directory for static content like the changelog, terms & conditions, privacy statement, ...
|
||||||
|
CONTENT_DIR = '/app/content'
|
||||||
|
|
||||||
|
|
||||||
class DevConfig(Config):
|
class DevConfig(Config):
|
||||||
DEVELOPMENT = True
|
DEVELOPMENT = True
|
||||||
|
|||||||
@@ -68,11 +68,27 @@ competency_details:
|
|||||||
required: true
|
required: true
|
||||||
default: true
|
default: true
|
||||||
arguments:
|
arguments:
|
||||||
vacancy_text:
|
region:
|
||||||
name: "vacancy_text"
|
name: "Region"
|
||||||
type: "text"
|
type: "str"
|
||||||
description: "The Vacancy Text"
|
description: "The region of the specific vacancy"
|
||||||
|
required: false
|
||||||
|
working_schedule:
|
||||||
|
name: "Work Schedule"
|
||||||
|
type: "str"
|
||||||
|
description: "The work schedule or employment type of the specific vacancy"
|
||||||
|
required: false
|
||||||
|
start_date:
|
||||||
|
name: "Start Date"
|
||||||
|
type: "date"
|
||||||
|
description: "The start date of the specific vacancy"
|
||||||
|
required: false
|
||||||
|
language:
|
||||||
|
name: "Language"
|
||||||
|
type: "str"
|
||||||
|
description: "The language (2-letter code) used to start the conversation"
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
results:
|
results:
|
||||||
competencies:
|
competencies:
|
||||||
name: "competencies"
|
name: "competencies"
|
||||||
|
|||||||
@@ -5,6 +5,19 @@ All notable changes to EveAI will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [2.3.2-alfa]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Changelog display
|
||||||
|
- Introduction of Specialist Magic Links
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- dynamic fields for adding documents / urls to dossier catalog
|
||||||
|
- tabs in latest bootstrap version no longer functional
|
||||||
|
- partner association of license tier not working when no partner selected
|
||||||
|
- data-type dynamic field needs conversion to isoformat
|
||||||
|
- Add public tables to env.py of tenant schema
|
||||||
|
|
||||||
## [2.3.1-alfa]
|
## [2.3.1-alfa]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
37
content/privacy/1.0/1.0.0.md
Normal file
37
content/privacy/1.0/1.0.0.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Privacy Policy
|
||||||
|
|
||||||
|
## Version 1.0.0
|
||||||
|
|
||||||
|
*Effective Date: 2025-06-03*
|
||||||
|
|
||||||
|
### 1. Introduction
|
||||||
|
|
||||||
|
This Privacy Policy describes how EveAI collects, uses, and discloses your information when you use our services.
|
||||||
|
|
||||||
|
### 2. Information We Collect
|
||||||
|
|
||||||
|
We collect information you provide directly to us, such as account information, content you process through our services, and communication data.
|
||||||
|
|
||||||
|
### 3. How We Use Your Information
|
||||||
|
|
||||||
|
We use your information to provide, maintain, and improve our services, process transactions, send communications, and comply with legal obligations.
|
||||||
|
|
||||||
|
### 4. Data Security
|
||||||
|
|
||||||
|
We implement appropriate security measures to protect your personal information against unauthorized access, alteration, disclosure, or destruction.
|
||||||
|
|
||||||
|
### 5. International Data Transfers
|
||||||
|
|
||||||
|
Your information may be transferred to and processed in countries other than the country you reside in, where data protection laws may differ.
|
||||||
|
|
||||||
|
### 6. Your Rights
|
||||||
|
|
||||||
|
Depending on your location, you may have certain rights regarding your personal information, such as access, correction, deletion, or restriction of processing.
|
||||||
|
|
||||||
|
### 7. Changes to This Policy
|
||||||
|
|
||||||
|
We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page.
|
||||||
|
|
||||||
|
### 8. Contact Us
|
||||||
|
|
||||||
|
If you have any questions about this Privacy Policy, please contact us at privacy@askeveai.be.
|
||||||
37
content/terms/1.0/1.0.0.md
Normal file
37
content/terms/1.0/1.0.0.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Terms of Service
|
||||||
|
|
||||||
|
## Version 1.0.0
|
||||||
|
|
||||||
|
*Effective Date: 2025-06-03*
|
||||||
|
|
||||||
|
### 1. Introduction
|
||||||
|
|
||||||
|
Welcome to EveAI. By accessing or using our services, you agree to be bound by these Terms of Service.
|
||||||
|
|
||||||
|
### 2. Service Description
|
||||||
|
|
||||||
|
EveAI provides AI-powered solutions for businesses to optimize their operations through intelligent document processing and specialist execution.
|
||||||
|
|
||||||
|
### 3. User Accounts
|
||||||
|
|
||||||
|
To access certain features of the Service, you must register for an account. You are responsible for maintaining the confidentiality of your account information.
|
||||||
|
|
||||||
|
### 4. Privacy
|
||||||
|
|
||||||
|
Your use of the Service is also governed by our Privacy Policy, which can be found [here](/content/privacy).
|
||||||
|
|
||||||
|
### 5. Intellectual Property
|
||||||
|
|
||||||
|
All content, features, and functionality of the Service are owned by EveAI and are protected by international copyright, trademark, and other intellectual property laws.
|
||||||
|
|
||||||
|
### 6. Limitation of Liability
|
||||||
|
|
||||||
|
In no event shall EveAI be liable for any indirect, incidental, special, consequential or punitive damages.
|
||||||
|
|
||||||
|
### 7. Changes to Terms
|
||||||
|
|
||||||
|
We reserve the right to modify these Terms at any time. Your continued use of the Service after such modifications will constitute your acceptance of the new Terms.
|
||||||
|
|
||||||
|
### 8. Governing Law
|
||||||
|
|
||||||
|
These Terms shall be governed by the laws of Belgium.
|
||||||
@@ -91,6 +91,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ../eveai_app:/app/eveai_app
|
- ../eveai_app:/app/eveai_app
|
||||||
- ../common:/app/common
|
- ../common:/app/common
|
||||||
|
- ../content:/app/content
|
||||||
- ../config:/app/config
|
- ../config:/app/config
|
||||||
- ../migrations:/app/migrations
|
- ../migrations:/app/migrations
|
||||||
- ../scripts:/app/scripts
|
- ../scripts:/app/scripts
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ COPY config /app/config
|
|||||||
COPY migrations /app/migrations
|
COPY migrations /app/migrations
|
||||||
COPY scripts /app/scripts
|
COPY scripts /app/scripts
|
||||||
COPY patched_packages /app/patched_packages
|
COPY patched_packages /app/patched_packages
|
||||||
|
COPY content /app/content
|
||||||
|
|
||||||
# Set permissions for entrypoint script
|
# Set permissions for entrypoint script
|
||||||
RUN chmod 777 /app/scripts/entrypoint.sh
|
RUN chmod 777 /app/scripts/entrypoint.sh
|
||||||
|
|||||||
516
documentation/Eveai Chat Client Developer Documentation.md
Normal file
516
documentation/Eveai Chat Client Developer Documentation.md
Normal file
@@ -0,0 +1,516 @@
|
|||||||
|
# Evie Chat Client - Developer Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Evie Chat Client is a modern, customizable chat interface for interacting with eveai specialists. It supports both anonymous and authenticated modes, with initial focus on anonymous mode. The client provides real-time interaction with AI specialists, customizable tenant branding, European-compliant analytics tracking, and secure QR code access.
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
- **Anonymous Mode**: Public access with tenant UUID and API key authentication
|
||||||
|
- **QR Code Access**: Secure pre-authenticated landing pages for QR code integration
|
||||||
|
- **Real-time Communication**: Server-Sent Events (SSE) for live updates and intermediate states
|
||||||
|
- **Tenant Customization**: Simple CSS variable-based theming with visual editor
|
||||||
|
- **Multiple Choice Options**: Dynamic button/dropdown responses from specialists
|
||||||
|
- **Chat History**: Persistent ChatSession and Interaction storage
|
||||||
|
- **File Upload Support**: Planned for future implementation
|
||||||
|
- **European Analytics**: Umami integration for GDPR-compliant tracking
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
evie-project/
|
||||||
|
├── common/ # Shared code across components
|
||||||
|
│ ├── services/ # Reusable business logic
|
||||||
|
│ │ ├── chat_service.py # Chat session management
|
||||||
|
│ │ ├── specialist_service.py # Specialist interaction wrapper
|
||||||
|
│ │ ├── tenant_service.py # Tenant config & theming
|
||||||
|
│ │ └── qr_service.py # QR code session management
|
||||||
|
│ └── utils/ # Utility functions
|
||||||
|
│ ├── auth.py # API key validation
|
||||||
|
│ ├── tracking.py # Umami analytics integration
|
||||||
|
│ └── qr_utils.py # QR code generation utilities
|
||||||
|
├── eveai_chat_client/ # Chat client component
|
||||||
|
│ ├── app.py # Flask app entry point
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── chat_routes.py # Main chat interface routes
|
||||||
|
│ │ ├── api_routes.py # SSE/API endpoints
|
||||||
|
│ │ └── qr_routes.py # QR code landing pages
|
||||||
|
│ └── templates/
|
||||||
|
│ ├── base.html # Base template
|
||||||
|
│ ├── chat.html # Main chat interface
|
||||||
|
│ ├── qr_expired.html # QR code error page
|
||||||
|
│ └── components/
|
||||||
|
│ ├── message.html # Individual message component
|
||||||
|
│ ├── options.html # Multiple choice options
|
||||||
|
│ └── thinking.html # Intermediate states display
|
||||||
|
└── eveai_app/ # Admin interface (existing)
|
||||||
|
└── qr_management/ # QR code creation interface
|
||||||
|
├── create_qr.py
|
||||||
|
└── qr_templates.html
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Approach
|
||||||
|
|
||||||
|
- **Services Layer**: Direct integration with common/services for better performance
|
||||||
|
- **Database**: Utilizes existing ChatSession and Interaction models
|
||||||
|
- **Caching**: Leverages existing Redis setup
|
||||||
|
- **Static Files**: Uses existing nginx/static structure
|
||||||
|
|
||||||
|
## QR Code Access Flow
|
||||||
|
|
||||||
|
### QR Code System Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Admin as Admin (eveai_app)
|
||||||
|
participant QRService as QR Service (common)
|
||||||
|
participant PublicDB as Public Schema
|
||||||
|
participant TenantDB as Tenant Schema
|
||||||
|
participant User as End User
|
||||||
|
participant ChatClient as Chat Client
|
||||||
|
participant ChatSession as Chat Session
|
||||||
|
|
||||||
|
%% QR Code Creation Flow
|
||||||
|
Admin->>QRService: Create QR code with specialist config
|
||||||
|
QRService->>PublicDB: Store qr_lookup (qr_id → tenant_code)
|
||||||
|
QRService->>TenantDB: Store qr_sessions (full config + args)
|
||||||
|
QRService->>Admin: Return QR code image with /qr/{qr_id}
|
||||||
|
|
||||||
|
%% QR Code Usage Flow
|
||||||
|
User->>ChatClient: Scan QR → GET /qr/{qr_id}
|
||||||
|
ChatClient->>PublicDB: Lookup tenant_code by qr_id
|
||||||
|
ChatClient->>TenantDB: Get full QR session data
|
||||||
|
ChatClient->>ChatSession: Create ChatSession with pre-filled args
|
||||||
|
ChatClient->>User: Set temp auth + redirect to chat interface
|
||||||
|
User->>ChatClient: Access chat with pre-authenticated session
|
||||||
|
```
|
||||||
|
|
||||||
|
### QR Code Data Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[Admin Creates QR Code] --> B[Generate UUID for QR Session]
|
||||||
|
B --> C[Store Lookup in Public Schema]
|
||||||
|
C --> D[Store Full Data in Tenant Schema]
|
||||||
|
D --> E[Generate QR Code Image]
|
||||||
|
|
||||||
|
F[User Scans QR Code] --> G[Extract QR Session ID from URL]
|
||||||
|
G --> H[Lookup Tenant Code in Public Schema]
|
||||||
|
H --> I[Retrieve Full QR Data from Tenant Schema]
|
||||||
|
I --> J{QR Valid & Not Expired?}
|
||||||
|
J -->|No| K[Show Error Page]
|
||||||
|
J -->|Yes| L[Create ChatSession with Pre-filled Args]
|
||||||
|
L --> M[Set Temporary Browser Authentication]
|
||||||
|
M --> N[Redirect to Chat Interface]
|
||||||
|
N --> O[Start Chat with Specialist]
|
||||||
|
```
|
||||||
|
|
||||||
|
## URL Structure & Parameters
|
||||||
|
|
||||||
|
### Main Chat Interface
|
||||||
|
```
|
||||||
|
GET /chat/{tenant_code}/{specialist_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `api_key` (required for direct access): Tenant API key for authentication
|
||||||
|
- `session` (optional): Existing chat session ID
|
||||||
|
- `utm_source`, `utm_campaign`, `utm_medium` (optional): Analytics tracking
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
```
|
||||||
|
# Direct access
|
||||||
|
/chat/550e8400-e29b-41d4-a716-446655440000/document-analyzer?api_key=xxx&utm_source=email
|
||||||
|
|
||||||
|
# QR code access (after redirect)
|
||||||
|
/chat/550e8400-e29b-41d4-a716-446655440000/document-analyzer?session=abc123-def456
|
||||||
|
```
|
||||||
|
|
||||||
|
### QR Code Landing Pages
|
||||||
|
```
|
||||||
|
GET /qr/{qr_session_id} # QR code entry point (redirects, no HTML page)
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
```
|
||||||
|
POST /api/chat/{tenant_code}/interact # Send message to specialist
|
||||||
|
GET /api/chat/{tenant_code}/status/{session_id} # SSE endpoint for updates
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication & Security
|
||||||
|
|
||||||
|
### Anonymous Mode Access Methods
|
||||||
|
|
||||||
|
1. **Direct Access**: URL with API key parameter
|
||||||
|
2. **QR Code Access**: Pre-authenticated via secure landing page
|
||||||
|
|
||||||
|
### QR Code Security Model
|
||||||
|
- **QR Code Contains**: Only a UUID session identifier
|
||||||
|
- **Sensitive Data**: Stored securely in tenant database schema
|
||||||
|
- **Usage Control**: Configurable expiration and usage limits
|
||||||
|
- **Audit Trail**: Track QR code creation and usage
|
||||||
|
|
||||||
|
### Security Considerations
|
||||||
|
- Use tenant UUIDs to prevent enumeration attacks
|
||||||
|
- Validate API keys against tenant database
|
||||||
|
- Implement CORS policies for cross-origin requests
|
||||||
|
- Sanitize all user messages and file uploads
|
||||||
|
- QR sessions have configurable expiration and usage limits
|
||||||
|
|
||||||
|
## QR Code Management
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
|
||||||
|
#### Public Schema (Routing Only)
|
||||||
|
```sql
|
||||||
|
CREATE TABLE qr_lookup (
|
||||||
|
qr_session_id UUID PRIMARY KEY,
|
||||||
|
tenant_code UUID NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
INDEX idx_tenant_code (tenant_code)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Tenant Schema (Full QR Data)
|
||||||
|
```sql
|
||||||
|
CREATE TABLE qr_sessions (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
specialist_id UUID NOT NULL,
|
||||||
|
api_key VARCHAR(255) NOT NULL,
|
||||||
|
specialist_args JSONB,
|
||||||
|
metadata JSONB,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMP,
|
||||||
|
usage_count INTEGER DEFAULT 0,
|
||||||
|
usage_limit INTEGER,
|
||||||
|
created_by_user_id UUID
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### QR Code Creation (eveai_app)
|
||||||
|
```python
|
||||||
|
# In eveai_app admin interface
|
||||||
|
from common.services.qr_service import QRService
|
||||||
|
|
||||||
|
def create_specialist_qr_code():
|
||||||
|
qr_data = {
|
||||||
|
'tenant_code': current_tenant.code,
|
||||||
|
'specialist_id': selected_specialist.id,
|
||||||
|
'api_key': current_tenant.api_key,
|
||||||
|
'specialist_args': {
|
||||||
|
'department': 'sales',
|
||||||
|
'language': 'en',
|
||||||
|
'context': 'product_inquiry'
|
||||||
|
},
|
||||||
|
'metadata': {
|
||||||
|
'name': 'Sales Support QR - Product Brochure',
|
||||||
|
'usage_limit': 500,
|
||||||
|
'expires_days': 90
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
qr_service = QRService()
|
||||||
|
qr_session_id, qr_image = qr_service.create_qr_session(qr_data)
|
||||||
|
return qr_image
|
||||||
|
```
|
||||||
|
|
||||||
|
### QR Code Processing (eveai_chat_client)
|
||||||
|
```python
|
||||||
|
# In eveai_chat_client routes
|
||||||
|
from common.services.qr_service import QRService
|
||||||
|
from common.services.chat_service import ChatService
|
||||||
|
|
||||||
|
@app.route('/qr/<qr_session_id>')
|
||||||
|
def handle_qr_code(qr_session_id):
|
||||||
|
qr_service = QRService()
|
||||||
|
qr_data = qr_service.get_and_validate_qr_session(qr_session_id)
|
||||||
|
|
||||||
|
if not qr_data:
|
||||||
|
return render_template('qr_expired.html'), 410
|
||||||
|
|
||||||
|
# Create ChatSession with pre-filled arguments
|
||||||
|
chat_service = ChatService()
|
||||||
|
chat_session = chat_service.create_session(
|
||||||
|
tenant_code=qr_data['tenant_code'],
|
||||||
|
specialist_id=qr_data['specialist_id'],
|
||||||
|
initial_args=qr_data['specialist_args'],
|
||||||
|
source='qr_code'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set temporary authentication
|
||||||
|
flask_session['qr_auth'] = {
|
||||||
|
'tenant_code': qr_data['tenant_code'],
|
||||||
|
'api_key': qr_data['api_key'],
|
||||||
|
'chat_session_id': chat_session.id,
|
||||||
|
'expires_at': datetime.utcnow() + timedelta(hours=24)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Redirect to chat interface
|
||||||
|
return redirect(f"/chat/{qr_data['tenant_code']}/{qr_data['specialist_id']}?session={chat_session.id}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Real-time Communication
|
||||||
|
|
||||||
|
### Server-Sent Events (SSE)
|
||||||
|
- **Connection**: Long-lived SSE connection per chat session
|
||||||
|
- **Message Types**:
|
||||||
|
- `message`: Complete specialist response
|
||||||
|
- `thinking`: Intermediate processing states
|
||||||
|
- `options`: Multiple choice response options
|
||||||
|
- `error`: Error messages
|
||||||
|
- `complete`: Interaction completion
|
||||||
|
|
||||||
|
### SSE Message Format
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "thinking",
|
||||||
|
"data": {
|
||||||
|
"message": "Analyzing your request...",
|
||||||
|
"step": 1,
|
||||||
|
"total_steps": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tenant Customization
|
||||||
|
|
||||||
|
### Theme Configuration
|
||||||
|
Stored in tenant table as JSONB column:
|
||||||
|
```sql
|
||||||
|
ALTER TABLE tenants ADD COLUMN theme_config JSONB;
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSS Variables Approach
|
||||||
|
Inline CSS variables in chat template:
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
/* Brand Colors */
|
||||||
|
--primary-color: {{ tenant.theme_config.primary_color or '#007bff' }};
|
||||||
|
--secondary-color: {{ tenant.theme_config.secondary_color or '#6c757d' }};
|
||||||
|
--accent-color: {{ tenant.theme_config.accent_color or '#28a745' }};
|
||||||
|
|
||||||
|
/* Chat Interface */
|
||||||
|
--user-message-bg: {{ tenant.theme_config.user_message_bg or 'var(--primary-color)' }};
|
||||||
|
--bot-message-bg: {{ tenant.theme_config.bot_message_bg or '#f8f9fa' }};
|
||||||
|
--chat-bg: {{ tenant.theme_config.chat_bg or '#ffffff' }};
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-family: {{ tenant.theme_config.font_family or 'system-ui, -apple-system, sans-serif' }};
|
||||||
|
--font-size-base: {{ tenant.theme_config.font_size or '16px' }};
|
||||||
|
|
||||||
|
/* Branding */
|
||||||
|
--logo-url: url('/api/tenant/{{ tenant.code }}/logo');
|
||||||
|
--header-bg: {{ tenant.theme_config.header_bg or 'var(--primary-color)' }};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Theme Editor (eveai_app)
|
||||||
|
Simple form interface with:
|
||||||
|
- Color pickers for brand colors
|
||||||
|
- Font selection dropdown
|
||||||
|
- Logo upload functionality
|
||||||
|
- Live preview of chat interface
|
||||||
|
- Reset to defaults option
|
||||||
|
|
||||||
|
## Multiple Choice Options
|
||||||
|
|
||||||
|
### Dynamic Rendering Logic
|
||||||
|
```python
|
||||||
|
def render_options(options_list):
|
||||||
|
if len(options_list) <= 3:
|
||||||
|
return render_template('components/options.html',
|
||||||
|
display_type='buttons',
|
||||||
|
options=options_list)
|
||||||
|
else:
|
||||||
|
return render_template('components/options.html',
|
||||||
|
display_type='dropdown',
|
||||||
|
options=options_list)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option Data Structure
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "options",
|
||||||
|
"data": {
|
||||||
|
"question": "How would you like to proceed?",
|
||||||
|
"options": [
|
||||||
|
{"id": "option1", "text": "Continue analysis", "value": "continue"},
|
||||||
|
{"id": "option2", "text": "Generate report", "value": "report"},
|
||||||
|
{"id": "option3", "text": "Start over", "value": "restart"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Analytics Integration
|
||||||
|
|
||||||
|
### Umami Setup
|
||||||
|
- **European Hosting**: Self-hosted Umami instance
|
||||||
|
- **Privacy Compliant**: No cookies, GDPR compliant by design
|
||||||
|
- **Tracking Events**:
|
||||||
|
- Chat session start (including QR code source)
|
||||||
|
- Message sent
|
||||||
|
- Option selected
|
||||||
|
- Session duration
|
||||||
|
- Specialist interaction completion
|
||||||
|
- QR code usage
|
||||||
|
|
||||||
|
### Tracking Implementation
|
||||||
|
```javascript
|
||||||
|
// Track chat events
|
||||||
|
function trackEvent(eventName, eventData) {
|
||||||
|
if (window.umami) {
|
||||||
|
umami.track(eventName, eventData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track QR code usage
|
||||||
|
function trackQRUsage(qrSessionId, tenantCode) {
|
||||||
|
trackEvent('qr_code_used', {
|
||||||
|
qr_session_id: qrSessionId,
|
||||||
|
tenant_code: tenantCode
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Upload Support (Future)
|
||||||
|
|
||||||
|
### Planned Implementation
|
||||||
|
- **Multipart Upload**: Standard HTML5 file upload
|
||||||
|
- **File Types**: Documents, images, spreadsheets
|
||||||
|
- **Storage**: Tenant-specific S3 buckets
|
||||||
|
- **Processing**: Integration with existing document processing pipeline
|
||||||
|
- **UI**: Drag-and-drop interface with progress indicators
|
||||||
|
|
||||||
|
### Security Considerations
|
||||||
|
- File type validation
|
||||||
|
- Size limits per tenant
|
||||||
|
- Virus scanning integration
|
||||||
|
- Temporary file cleanup
|
||||||
|
|
||||||
|
## Development Guidelines
|
||||||
|
|
||||||
|
### Code Organization
|
||||||
|
- **Services**: Place reusable business logic in `common/services/`
|
||||||
|
- **Utils**: Place utility functions in `common/utils/`
|
||||||
|
- **Multi-tenant**: Maintain data isolation using existing patterns
|
||||||
|
- **Error Handling**: Implement proper error handling and logging
|
||||||
|
|
||||||
|
### Service Layer Examples
|
||||||
|
```python
|
||||||
|
# common/services/qr_service.py
|
||||||
|
class QRService:
|
||||||
|
def create_qr_session(self, qr_data):
|
||||||
|
# Create QR session with hybrid storage approach
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_and_validate_qr_session(self, qr_session_id):
|
||||||
|
# Validate and retrieve QR session data
|
||||||
|
pass
|
||||||
|
|
||||||
|
# common/services/chat_service.py
|
||||||
|
class ChatService:
|
||||||
|
def create_session(self, tenant_code, specialist_id, initial_args=None, source='direct'):
|
||||||
|
# Create chat session with optional pre-filled arguments
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Strategy
|
||||||
|
- Unit tests for services and utilities in `common/`
|
||||||
|
- Integration tests for chat flow including QR code access
|
||||||
|
- UI tests for theme customization
|
||||||
|
- Load testing for SSE connections
|
||||||
|
- Cross-browser compatibility testing
|
||||||
|
|
||||||
|
### Performance Considerations
|
||||||
|
- Cache tenant configurations in Redis
|
||||||
|
- Cache QR session lookups in Redis
|
||||||
|
- Optimize SSE connection management
|
||||||
|
- Implement connection pooling for database
|
||||||
|
- Use CDN for static assets
|
||||||
|
- Monitor real-time connection limits
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Container Configuration
|
||||||
|
- New `eveai_chat_client` container
|
||||||
|
- Integration with existing docker setup
|
||||||
|
- Environment configuration for tenant isolation
|
||||||
|
- Load balancer configuration for SSE connections
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
- Flask and Flask-restx (existing)
|
||||||
|
- Celery integration (existing)
|
||||||
|
- PostgreSQL and Redis (existing)
|
||||||
|
- Umami analytics client library
|
||||||
|
- QR code generation library (qrcode)
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
### Authenticated Mode
|
||||||
|
- User login integration
|
||||||
|
- Session persistence across devices
|
||||||
|
- Advanced specialist access controls
|
||||||
|
- User-specific chat history
|
||||||
|
|
||||||
|
### Advanced Features
|
||||||
|
- Voice message support
|
||||||
|
- Screen sharing capabilities
|
||||||
|
- Collaborative chat sessions
|
||||||
|
- Advanced analytics dashboard
|
||||||
|
- Mobile app integration
|
||||||
|
|
||||||
|
## Configuration Examples
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
```bash
|
||||||
|
CHAT_CLIENT_PORT=5000
|
||||||
|
TENANT_API_VALIDATION_CACHE_TTL=3600
|
||||||
|
SSE_CONNECTION_TIMEOUT=300
|
||||||
|
QR_SESSION_DEFAULT_EXPIRY_DAYS=30
|
||||||
|
QR_SESSION_MAX_USAGE_LIMIT=1000
|
||||||
|
UMAMI_WEBSITE_ID=your-website-id
|
||||||
|
UMAMI_SCRIPT_URL=https://your-umami.domain/script.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sample Theme Configuration
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"primary_color": "#2563eb",
|
||||||
|
"secondary_color": "#64748b",
|
||||||
|
"accent_color": "#059669",
|
||||||
|
"user_message_bg": "#2563eb",
|
||||||
|
"bot_message_bg": "#f1f5f9",
|
||||||
|
"chat_bg": "#ffffff",
|
||||||
|
"font_family": "Inter, system-ui, sans-serif",
|
||||||
|
"font_size": "16px",
|
||||||
|
"header_bg": "#1e40af"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sample QR Session Data
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"tenant_code": "123e4567-e89b-12d3-a456-426614174000",
|
||||||
|
"specialist_id": "789e0123-e45f-67g8-h901-234567890123",
|
||||||
|
"api_key": "tenant_api_key_here",
|
||||||
|
"specialist_args": {
|
||||||
|
"department": "technical_support",
|
||||||
|
"product_category": "software",
|
||||||
|
"priority": "high",
|
||||||
|
"language": "en"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"name": "Technical Support QR - Software Issues",
|
||||||
|
"created_by": "admin_user_id",
|
||||||
|
"usage_limit": 100,
|
||||||
|
"expires_at": "2025-09-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This documentation provides a comprehensive foundation for developing the Evie Chat Client with secure QR code integration while maintaining consistency with the existing eveai multi-tenant architecture.
|
||||||
@@ -7,7 +7,7 @@ from werkzeug.middleware.proxy_fix import ProxyFix
|
|||||||
import logging.config
|
import logging.config
|
||||||
|
|
||||||
from common.extensions import (db, migrate, bootstrap, security, login_manager, cors, csrf, session,
|
from common.extensions import (db, migrate, bootstrap, security, login_manager, cors, csrf, session,
|
||||||
minio_client, simple_encryption, metrics, cache_manager)
|
minio_client, simple_encryption, metrics, cache_manager, content_manager)
|
||||||
from common.models.user import User, Role, Tenant, TenantDomain
|
from common.models.user import User, Role, Tenant, TenantDomain
|
||||||
import common.models.interaction
|
import common.models.interaction
|
||||||
import common.models.entitlements
|
import common.models.entitlements
|
||||||
@@ -15,7 +15,7 @@ import common.models.document
|
|||||||
from common.utils.startup_eveai import perform_startup_actions
|
from common.utils.startup_eveai import perform_startup_actions
|
||||||
from config.logging_config import LOGGING
|
from config.logging_config import LOGGING
|
||||||
from common.utils.security import set_tenant_session_data
|
from common.utils.security import set_tenant_session_data
|
||||||
from .errors import register_error_handlers
|
from common.utils.errors import register_error_handlers
|
||||||
from common.utils.celery_utils import make_celery, init_celery
|
from common.utils.celery_utils import make_celery, init_celery
|
||||||
from common.utils.template_filters import register_filters
|
from common.utils.template_filters import register_filters
|
||||||
from config.config import get_config
|
from config.config import get_config
|
||||||
@@ -124,6 +124,7 @@ def register_extensions(app):
|
|||||||
minio_client.init_app(app)
|
minio_client.init_app(app)
|
||||||
cache_manager.init_app(app)
|
cache_manager.init_app(app)
|
||||||
metrics.init_app(app)
|
metrics.init_app(app)
|
||||||
|
content_manager.init_app(app)
|
||||||
|
|
||||||
|
|
||||||
def register_blueprints(app):
|
def register_blueprints(app):
|
||||||
|
|||||||
102
eveai_app/templates/basic/view_markdown.html
Normal file
102
eveai_app/templates/basic/view_markdown.html
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content_title %}{{ title }}{% endblock %}
|
||||||
|
{% block content_description %}{{ description }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" id="showRaw">Show Raw</button>
|
||||||
|
<button class="btn btn-sm btn-outline-primary active" id="showRendered">Show Rendered</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Raw markdown view (hidden by default) -->
|
||||||
|
<div id="rawMarkdown" class="code-wrapper" style="display: none;">
|
||||||
|
<pre><code class="language-markdown">{{ markdown_content }}</code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rendered markdown view -->
|
||||||
|
<div id="renderedMarkdown" class="markdown-body">
|
||||||
|
{{ markdown_content | markdown }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block styles %}
|
||||||
|
{{ super() }}
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@4.0.0/github-markdown.min.css">
|
||||||
|
<style>
|
||||||
|
pre, code {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
white-space: pre-wrap !important;
|
||||||
|
word-wrap: break-word !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
padding: 1rem !important;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body {
|
||||||
|
padding: 1rem;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode styling (optional) */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.markdown-body {
|
||||||
|
color: #c9d1d9;
|
||||||
|
background-color: #0d1117;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{{ super() }}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Initialize syntax highlighting
|
||||||
|
document.querySelectorAll('pre code').forEach((block) => {
|
||||||
|
hljs.highlightElement(block);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle buttons for display
|
||||||
|
const showRawBtn = document.getElementById('showRaw');
|
||||||
|
const showRenderedBtn = document.getElementById('showRendered');
|
||||||
|
const rawMarkdown = document.getElementById('rawMarkdown');
|
||||||
|
const renderedMarkdown = document.getElementById('renderedMarkdown');
|
||||||
|
|
||||||
|
showRawBtn.addEventListener('click', function() {
|
||||||
|
rawMarkdown.style.display = 'block';
|
||||||
|
renderedMarkdown.style.display = 'none';
|
||||||
|
showRawBtn.classList.add('active');
|
||||||
|
showRenderedBtn.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
showRenderedBtn.addEventListener('click', function() {
|
||||||
|
rawMarkdown.style.display = 'none';
|
||||||
|
renderedMarkdown.style.display = 'block';
|
||||||
|
showRawBtn.classList.remove('active');
|
||||||
|
showRenderedBtn.classList.add('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -19,17 +19,17 @@
|
|||||||
<div class="nav-wrapper position-relative end-0">
|
<div class="nav-wrapper position-relative end-0">
|
||||||
<ul class="nav nav-pills nav-fill p-1" role="tablist">
|
<ul class="nav nav-pills nav-fill p-1" role="tablist">
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<a class="nav-link mb-0 px-0 py-1 active" data-toggle="tab" href="#storage-tab" role="tab" aria-controls="model-info" aria-selected="true">
|
<a class="nav-link mb-0 px-0 py-1 active" data-bs-toggle="tab" href="#storage-tab" role="tab" aria-controls="model-info" aria-selected="true">
|
||||||
Storage
|
Storage
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link mb-0 px-0 py-1" data-toggle="tab" href="#embedding-tab" role="tab" aria-controls="license-info" aria-selected="false">
|
<a class="nav-link mb-0 px-0 py-1" data-bs-toggle="tab" href="#embedding-tab" role="tab" aria-controls="license-info" aria-selected="false">
|
||||||
Embedding
|
Embedding
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link mb-0 px-0 py-1" data-toggle="tab" href="#interaction-tab" role="tab" aria-controls="chunking" aria-selected="false">
|
<a class="nav-link mb-0 px-0 py-1" data-bs-toggle="tab" href="#interaction-tab" role="tab" aria-controls="chunking" aria-selected="false">
|
||||||
Interaction
|
Interaction
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -19,17 +19,17 @@
|
|||||||
<div class="nav-wrapper position-relative end-0">
|
<div class="nav-wrapper position-relative end-0">
|
||||||
<ul class="nav nav-pills nav-fill p-1" role="tablist">
|
<ul class="nav nav-pills nav-fill p-1" role="tablist">
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<a class="nav-link mb-0 px-0 py-1 active" data-toggle="tab" href="#storage-tab" role="tab" aria-controls="model-info" aria-selected="true">
|
<a class="nav-link mb-0 px-0 py-1 active" data-bs-toggle="tab" href="#storage-tab" role="tab" aria-controls="model-info" aria-selected="true">
|
||||||
Storage
|
Storage
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link mb-0 px-0 py-1" data-toggle="tab" href="#embedding-tab" role="tab" aria-controls="license-info" aria-selected="false">
|
<a class="nav-link mb-0 px-0 py-1" data-bs-toggle="tab" href="#embedding-tab" role="tab" aria-controls="license-info" aria-selected="false">
|
||||||
Embedding
|
Embedding
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link mb-0 px-0 py-1" data-toggle="tab" href="#interaction-tab" role="tab" aria-controls="chunking" aria-selected="false">
|
<a class="nav-link mb-0 px-0 py-1" data-bs-toggle="tab" href="#interaction-tab" role="tab" aria-controls="chunking" aria-selected="false">
|
||||||
Interaction
|
Interaction
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -107,17 +107,17 @@
|
|||||||
<!-- Nav Tabs -->
|
<!-- Nav Tabs -->
|
||||||
<ul class="nav nav-tabs" id="periodTabs" role="tablist">
|
<ul class="nav nav-tabs" id="periodTabs" role="tablist">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link active" id="status-tab" data-toggle="tab" href="#status" role="tab">
|
<a class="nav-link active" id="status-tab" data-bs-toggle="tab" href="#status" role="tab">
|
||||||
Status & Timeline
|
Status & Timeline
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" id="usage-tab" data-toggle="tab" href="#usage" role="tab">
|
<a class="nav-link" id="usage-tab" data-bs-toggle="tab" href="#usage" role="tab">
|
||||||
Usage
|
Usage
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" id="financial-tab" data-toggle="tab" href="#financial" role="tab">
|
<a class="nav-link" id="financial-tab" data-bs-toggle="tab" href="#financial" role="tab">
|
||||||
Financial
|
Financial
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% from "macros.html" import render_field, render_included_field %}
|
{% from "macros.html" import render_field, render_included_field %}
|
||||||
|
|
||||||
@@ -19,17 +20,17 @@
|
|||||||
<div class="nav-wrapper position-relative end-0">
|
<div class="nav-wrapper position-relative end-0">
|
||||||
<ul class="nav nav-pills nav-fill p-1" role="tablist">
|
<ul class="nav nav-pills nav-fill p-1" role="tablist">
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<a class="nav-link mb-0 px-0 py-1 active" data-toggle="tab" href="#storage-tab" role="tab" aria-controls="model-info" aria-selected="true">
|
<a class="nav-link mb-0 px-0 py-1 active" data-bs-toggle="tab" href="#storage-tab" role="tab" aria-controls="storage-tab" aria-selected="true">
|
||||||
Storage
|
Storage
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link mb-0 px-0 py-1" data-toggle="tab" href="#embedding-tab" role="tab" aria-controls="license-info" aria-selected="false">
|
<a class="nav-link mb-0 px-0 py-1" data-bs-toggle="tab" href="#embedding-tab" role="tab" aria-controls="embedding-tab" aria-selected="false">
|
||||||
Embedding
|
Embedding
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link mb-0 px-0 py-1" data-toggle="tab" href="#interaction-tab" role="tab" aria-controls="chunking" aria-selected="false">
|
<a class="nav-link mb-0 px-0 py-1" data-bs-toggle="tab" href="#interaction-tab" role="tab" aria-controls="interaction-tab" aria-selected="false">
|
||||||
Interaction
|
Interaction
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% from "macros.html" import render_field %}
|
||||||
|
|
||||||
|
{% block title %}Edit Specialist Magic Link{% endblock %}
|
||||||
|
|
||||||
|
{% block content_title %}Edit Specialist Magic Link{% endblock %}
|
||||||
|
{% block content_description %}Edit a Specialist Magic Link{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<form method="post">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
{% set disabled_fields = ['magic_link_code'] %}
|
||||||
|
{% set exclude_fields = [] %}
|
||||||
|
<!-- Render Static Fields -->
|
||||||
|
{% for field in form.get_static_fields() %}
|
||||||
|
{{ render_field(field, disabled_fields, exclude_fields) }}
|
||||||
|
{% endfor %}
|
||||||
|
<!-- Render Dynamic Fields -->
|
||||||
|
{% for collection_name, fields in form.get_dynamic_fields().items() %}
|
||||||
|
{% if fields|length > 0 %}
|
||||||
|
<h4 class="mt-4">{{ collection_name }}</h4>
|
||||||
|
{% endif %}
|
||||||
|
{% for field in fields %}
|
||||||
|
{{ render_field(field, disabled_fields, exclude_fields) }}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
<button type="submit" class="btn btn-primary">Save Specialist Magic Link</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content_footer %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
23
eveai_app/templates/interaction/specialist_magic_link.html
Normal file
23
eveai_app/templates/interaction/specialist_magic_link.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% from "macros.html" import render_field %}
|
||||||
|
|
||||||
|
{% block title %}Specialist Magic Link{% endblock %}
|
||||||
|
|
||||||
|
{% block content_title %}Register Specialist Magic Link{% endblock %}
|
||||||
|
{% block content_description %}Define a new specialist magic link{% 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 Specialist Magic Link</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content_footer %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
26
eveai_app/templates/interaction/specialist_magic_links.html
Normal file
26
eveai_app/templates/interaction/specialist_magic_links.html
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% from 'macros.html' import render_selectable_table, render_pagination %}
|
||||||
|
|
||||||
|
{% block title %}Specialist Magic Links{% endblock %}
|
||||||
|
|
||||||
|
{% block content_title %}Specialist Magic Links{% endblock %}
|
||||||
|
{% block content_description %}View Specialists Magic Links{% 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('interaction_bp.handle_specialist_magic_link_selection') }}" id="specialistMagicLinksForm">
|
||||||
|
{{ render_selectable_table(headers=["Specialist ML ID", "Name", "Magic Link Code"], rows=rows, selectable=True, id="specialistMagicLinksTable") }}
|
||||||
|
<div class="form-group mt-3 d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<button type="submit" name="action" value="edit_specialist_magic_link" class="btn btn-primary" onclick="return validateTableSelection('specialistMagicLinksForm')">Edit Specialist Magic Link</button>
|
||||||
|
</div>
|
||||||
|
<button type="submit" name="action" value="create_specialist_magic_link" class="btn btn-success">Register Specialist Magic Link</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content_footer %}
|
||||||
|
{{ render_pagination(pagination, 'interaction_bp.specialist_magic_links') }}
|
||||||
|
{% endblock %}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% from 'macros.html' import render_selectable_table, render_pagination %}
|
{% from 'macros.html' import render_selectable_table, render_pagination %}
|
||||||
|
|
||||||
{% block title %}Retrievers{% endblock %}
|
{% block title %}Specialists{% endblock %}
|
||||||
|
|
||||||
{% block content_title %}Specialists{% endblock %}
|
{% block content_title %}Specialists{% endblock %}
|
||||||
{% block content_description %}View Specialists for Tenant{% endblock %}
|
{% block content_description %}View Specialists for Tenant{% endblock %}
|
||||||
|
|||||||
@@ -138,7 +138,7 @@
|
|||||||
{% elif cell.type == 'badge' %}
|
{% elif cell.type == 'badge' %}
|
||||||
<span class="badge badge-sm {{ cell.badge_class }}">{{ cell.value }}</span>
|
<span class="badge badge-sm {{ cell.badge_class }}">{{ cell.value }}</span>
|
||||||
{% elif cell.type == 'link' %}
|
{% elif cell.type == 'link' %}
|
||||||
<a href="{{ cell.href }}" class="text-secondary font-weight-normal text-xs" data-toggle="tooltip" data-original-title="{{ cell.title }}">{{ cell.value }}</a>
|
<a href="{{ cell.href }}" class="text-secondary font-weight-normal text-xs" data-bs-toggle="tooltip" data-original-title="{{ cell.title }}">{{ cell.value }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ cell.value }}
|
{{ cell.value }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -192,7 +192,7 @@
|
|||||||
{% elif cell.type == 'badge' %}
|
{% elif cell.type == 'badge' %}
|
||||||
<span class="badge badge-sm {{ cell.badge_class }}">{{ cell.value }}</span>
|
<span class="badge badge-sm {{ cell.badge_class }}">{{ cell.value }}</span>
|
||||||
{% elif cell.type == 'link' %}
|
{% elif cell.type == 'link' %}
|
||||||
<a href="{{ cell.href }}" class="text-secondary font-weight-normal text-xs" data-toggle="tooltip" data-original-title="{{ cell.title }}">{{ cell.value }}</a>
|
<a href="{{ cell.href }}" class="text-secondary font-weight-normal text-xs" data-bs-toggle="tooltip" data-original-title="{{ cell.title }}">{{ cell.value }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ cell.value }}
|
{{ cell.value }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -357,7 +357,7 @@
|
|||||||
{% elif cell.type == 'badge' %}
|
{% elif cell.type == 'badge' %}
|
||||||
<span class="badge badge-sm {{ cell.badge_class }}">{{ cell.value }}</span>
|
<span class="badge badge-sm {{ cell.badge_class }}">{{ cell.value }}</span>
|
||||||
{% elif cell.type == 'link' %}
|
{% elif cell.type == 'link' %}
|
||||||
<a href="{{ cell.href }}" class="text-secondary font-weight-normal text-xs" data-toggle="tooltip" data-original-title="{{ cell.title }}">{{ cell.value }}</a>
|
<a href="{{ cell.href }}" class="text-secondary font-weight-normal text-xs" data-bs-toggle="tooltip" data-original-title="{{ cell.title }}">{{ cell.value }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ cell.value }}
|
{{ cell.value }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -450,3 +450,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro debug_to_console(var_name, var_value) %}
|
||||||
|
<script>
|
||||||
|
console.log('{{ var_name }}:', {{ var_value|tojson }});
|
||||||
|
</script>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,7 @@
|
|||||||
{% if current_user.is_authenticated %}
|
{% if current_user.is_authenticated %}
|
||||||
{{ dropdown('Interactions', 'hub', [
|
{{ dropdown('Interactions', 'hub', [
|
||||||
{'name': 'Specialists', 'url': '/interaction/specialists', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
|
{'name': 'Specialists', 'url': '/interaction/specialists', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
|
||||||
|
{'name': 'Specialist Magic Links', 'url': '/interaction/specialist_magic_links', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
|
||||||
{'name': 'Chat Sessions', 'url': '/interaction/chat_sessions', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
|
{'name': 'Chat Sessions', 'url': '/interaction/chat_sessions', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
|
||||||
]) }}
|
]) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% from "macros.html" import render_field %}
|
{% from "macros.html" import render_field, debug_to_console %}
|
||||||
{% block title %}Register Partner Service{% endblock %}
|
{% block title %}Edit Partner Service{% endblock %}
|
||||||
|
|
||||||
{% block content_title %}Register Partner Service{% endblock %}
|
{% block content_title %}Edit Partner Service{% endblock %}
|
||||||
{% block content_description %}Register Partner Service{% endblock %}
|
{% block content_description %}Edit Partner Service{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<form method="post">
|
<form method="post">
|
||||||
@@ -16,6 +16,8 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
<!-- Render Dynamic Fields -->
|
<!-- Render Dynamic Fields -->
|
||||||
{% for collection_name, fields in form.get_dynamic_fields().items() %}
|
{% for collection_name, fields in form.get_dynamic_fields().items() %}
|
||||||
|
{{ debug_to_console('collection_name', collection_name) }}
|
||||||
|
{{ debug_to_console('fields', fields) }}
|
||||||
{% if fields|length > 0 %}
|
{% if fields|length > 0 %}
|
||||||
<h4 class="mt-4">{{ collection_name }}</h4>
|
<h4 class="mt-4">{{ collection_name }}</h4>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -23,6 +25,6 @@
|
|||||||
{{ render_field(field, disabled_fields, exclude_fields) }}
|
{{ render_field(field, disabled_fields, exclude_fields) }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<button type="submit" class="btn btn-primary">Register Partner Service</button>
|
<button type="submit" class="btn btn-primary">Save Partner Service</button>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -19,3 +19,37 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content_footer %} {% endblock %}
|
{% block content_footer %} {% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
// JavaScript om de gebruiker's timezone te detecteren
|
||||||
|
document.addEventListener('DOMContentLoaded', (event) => {
|
||||||
|
// Detect timezone
|
||||||
|
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
|
||||||
|
// Send timezone to the server via a POST request
|
||||||
|
fetch('/set_user_timezone', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ timezone: userTimezone })
|
||||||
|
}).then(response => {
|
||||||
|
if (response.ok) {
|
||||||
|
console.log('Timezone sent to server successfully');
|
||||||
|
} else {
|
||||||
|
console.error('Failed to send timezone to server');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialiseer Select2 voor timezone selectie
|
||||||
|
$('#timezone').select2({
|
||||||
|
placeholder: 'Selecteer een timezone...',
|
||||||
|
allowClear: true,
|
||||||
|
maximumSelectionLength: 10,
|
||||||
|
theme: 'bootstrap',
|
||||||
|
width: '100%'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
// JavaScript to detect user's timezone
|
// JavaScript om de gebruiker's timezone te detecteren
|
||||||
document.addEventListener('DOMContentLoaded', (event) => {
|
document.addEventListener('DOMContentLoaded', (event) => {
|
||||||
// Detect timezone
|
// Detect timezone
|
||||||
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
@@ -45,6 +45,31 @@
|
|||||||
console.error('Failed to send timezone to server');
|
console.error('Failed to send timezone to server');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('#timezone').select2({
|
||||||
|
placeholder: 'Selecteer een timezone...',
|
||||||
|
allowClear: true,
|
||||||
|
theme: 'bootstrap',
|
||||||
|
width: '100%',
|
||||||
|
dropdownAutoWidth: true,
|
||||||
|
dropdownCssClass: 'timezone-dropdown', // Een custom class voor specifieke styling
|
||||||
|
scrollAfterSelect: false,
|
||||||
|
// Verbeterd scroll gedrag
|
||||||
|
dropdownParent: $('body')
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stel de huidige waarde in als de dropdown wordt geopend
|
||||||
|
$('#timezone').on('select2:open', function() {
|
||||||
|
if ($(this).val()) {
|
||||||
|
setTimeout(function() {
|
||||||
|
let selectedOption = $('.select2-results__option[aria-selected=true]');
|
||||||
|
if (selectedOption.length) {
|
||||||
|
selectedOption[0].scrollIntoView({ behavior: 'auto', block: 'center' });
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
<div class="nav-wrapper position-relative end-0">
|
<div class="nav-wrapper position-relative end-0">
|
||||||
<ul class="nav nav-pills nav-fill p-1" role="tablist">
|
<ul class="nav nav-pills nav-fill p-1" role="tablist">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link mb-0 px-0 py-1" data-toggle="tab" href="#license-info-tab" role="tab" aria-controls="license-info" aria-selected="false">
|
<a class="nav-link mb-0 px-0 py-1" data-bs-toggle="tab" href="#license-info-tab" role="tab" aria-controls="license-info" aria-selected="false">
|
||||||
License Information
|
License Information
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -9,9 +9,17 @@ from common.models.user import Tenant
|
|||||||
from common.utils.database import Database
|
from common.utils.database import Database
|
||||||
from common.utils.nginx_utils import prefixed_url_for
|
from common.utils.nginx_utils import prefixed_url_for
|
||||||
from .basic_forms import SessionDefaultsForm
|
from .basic_forms import SessionDefaultsForm
|
||||||
|
from common.extensions import content_manager
|
||||||
|
|
||||||
|
import markdown
|
||||||
|
|
||||||
basic_bp = Blueprint('basic_bp', __name__)
|
basic_bp = Blueprint('basic_bp', __name__)
|
||||||
|
|
||||||
|
# Markdown filter toevoegen aan Jinja2
|
||||||
|
@basic_bp.app_template_filter('markdown')
|
||||||
|
def render_markdown(text):
|
||||||
|
return markdown.markdown(text, extensions=['tables', 'fenced_code'])
|
||||||
|
|
||||||
|
|
||||||
@basic_bp.before_request
|
@basic_bp.before_request
|
||||||
def log_before_request():
|
def log_before_request():
|
||||||
@@ -104,28 +112,57 @@ def check_csrf():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@basic_bp.route('/release_notes', methods=['GET'])
|
@basic_bp.route('/content/<content_type>', methods=['GET'])
|
||||||
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
|
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
|
||||||
def release_notes():
|
def view_content(content_type):
|
||||||
"""Display the CHANGELOG.md file."""
|
"""
|
||||||
|
Show content like release notes, terms of use, etc.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_type (str): Type content (eg. 'changelog', 'terms', 'privacy')
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Construct the URL to the CHANGELOG.md file in the static directory
|
current_app.logger.debug(f"Showing content {content_type}")
|
||||||
static_url = url_for('static', filename='docs/CHANGELOG.md', _external=True)
|
major_minor = request.args.get('version')
|
||||||
|
patch = request.args.get('patch')
|
||||||
|
|
||||||
# Make a request to get the content of the CHANGELOG.md file
|
# Gebruik de ContentManager om de content op te halen
|
||||||
response = requests.get(static_url)
|
content_data = content_manager.read_content(content_type, major_minor, patch)
|
||||||
response.raise_for_status() # Raise an exception for HTTP errors
|
|
||||||
|
|
||||||
# Get the content of the response
|
if not content_data:
|
||||||
markdown_content = response.text
|
flash(f'Content van type {content_type} werd niet gevonden.', 'danger')
|
||||||
|
return redirect(prefixed_url_for('basic_bp.index'))
|
||||||
|
|
||||||
|
# Titels en beschrijvingen per contenttype
|
||||||
|
titles = {
|
||||||
|
'changelog': 'Release Notes',
|
||||||
|
'terms': 'Terms & Conditions',
|
||||||
|
'privacy': 'Privacy Statement',
|
||||||
|
# Voeg andere types toe indien nodig
|
||||||
|
}
|
||||||
|
|
||||||
|
descriptions = {
|
||||||
|
'changelog': 'EveAI Release Notes',
|
||||||
|
'terms': "Terms & Conditions for using AskEveAI's Evie",
|
||||||
|
'privacy': "Privacy Statement for AskEveAI's Evie",
|
||||||
|
# Voeg andere types toe indien nodig
|
||||||
|
}
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'basic/view_markdown.html',
|
'basic/view_markdown.html',
|
||||||
title='Release Notes',
|
title=titles.get(content_type, content_type.capitalize()),
|
||||||
description='EveAI Release Notes and Change History',
|
description=descriptions.get(content_type, ''),
|
||||||
markdown_content=markdown_content
|
markdown_content=content_data['content'],
|
||||||
|
version=content_data['version']
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
current_app.logger.error(f"Error displaying release notes: {str(e)}")
|
current_app.logger.error(f"Error displaying content {content_type}: {str(e)}")
|
||||||
flash(f'Error displaying release notes: {str(e)}', 'danger')
|
flash(f'Error displaying content: {str(e)}', 'danger')
|
||||||
return redirect(prefixed_url_for('basic_bp.index'))
|
return redirect(prefixed_url_for('basic_bp.index'))
|
||||||
|
|
||||||
|
@basic_bp.route('/release_notes', methods=['GET'])
|
||||||
|
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
|
||||||
|
def release_notes():
|
||||||
|
"""Doorverwijzen naar de nieuwe content view voor changelog"""
|
||||||
|
current_app.logger.debug(f"Redirecting to content viewer")
|
||||||
|
return redirect(prefixed_url_for('basic_bp.view_content', content_type='changelog'))
|
||||||
|
|||||||
@@ -389,10 +389,7 @@ def add_document():
|
|||||||
|
|
||||||
catalog = Catalog.query.get_or_404(catalog_id)
|
catalog = Catalog.query.get_or_404(catalog_id)
|
||||||
if catalog.configuration and len(catalog.configuration) > 0:
|
if catalog.configuration and len(catalog.configuration) > 0:
|
||||||
full_config = cache_manager.catalogs_config_cache.get_config(catalog.type)
|
form.add_dynamic_fields("tagging_fields", catalog.configuration)
|
||||||
document_version_configurations = full_config['document_version_configurations']
|
|
||||||
for config in document_version_configurations:
|
|
||||||
form.add_dynamic_fields(config, full_config, catalog.configuration[config])
|
|
||||||
|
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
try:
|
try:
|
||||||
@@ -402,11 +399,8 @@ def add_document():
|
|||||||
sub_file_type = form.sub_file_type.data
|
sub_file_type = form.sub_file_type.data
|
||||||
filename = secure_filename(file.filename)
|
filename = secure_filename(file.filename)
|
||||||
extension = filename.rsplit('.', 1)[1].lower()
|
extension = filename.rsplit('.', 1)[1].lower()
|
||||||
catalog_properties = {}
|
|
||||||
full_config = cache_manager.catalogs_config_cache.get_config(catalog.type)
|
catalog_properties = form.get_dynamic_data("tagging_fields")
|
||||||
document_version_configurations = full_config['document_version_configurations']
|
|
||||||
for config in document_version_configurations:
|
|
||||||
catalog_properties[config] = form.get_dynamic_data(config)
|
|
||||||
|
|
||||||
api_input = {
|
api_input = {
|
||||||
'catalog_id': catalog_id,
|
'catalog_id': catalog_id,
|
||||||
@@ -446,10 +440,7 @@ def add_url():
|
|||||||
|
|
||||||
catalog = Catalog.query.get_or_404(catalog_id)
|
catalog = Catalog.query.get_or_404(catalog_id)
|
||||||
if catalog.configuration and len(catalog.configuration) > 0:
|
if catalog.configuration and len(catalog.configuration) > 0:
|
||||||
full_config = cache_manager.catalogs_config_cache.get_config(catalog.type)
|
form.add_dynamic_fields("tagging_fields", catalog.configuration)
|
||||||
document_version_configurations = full_config['document_version_configurations']
|
|
||||||
for config in document_version_configurations:
|
|
||||||
form.add_dynamic_fields(config, full_config, catalog.configuration[config])
|
|
||||||
|
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from datetime import date
|
||||||
|
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import (IntegerField, FloatField, BooleanField, StringField, TextAreaField, FileField,
|
from wtforms import (IntegerField, FloatField, BooleanField, StringField, TextAreaField, FileField,
|
||||||
validators, ValidationError)
|
validators, ValidationError)
|
||||||
@@ -396,6 +398,12 @@ class DynamicFormBase(FlaskForm):
|
|||||||
except (TypeError, ValueError) as e:
|
except (TypeError, ValueError) as e:
|
||||||
current_app.logger.error(f"Error converting initial data to a list of patterns: {e}")
|
current_app.logger.error(f"Error converting initial data to a list of patterns: {e}")
|
||||||
field_data = {}
|
field_data = {}
|
||||||
|
elif field_type == 'date' and isinstance(field_data, str):
|
||||||
|
try:
|
||||||
|
field_data = date.fromisoformat(field_data)
|
||||||
|
except ValueError:
|
||||||
|
current_app.logger.error(f"Error converting ISO date string '{field_data}' to date object")
|
||||||
|
field_data = None
|
||||||
elif default is not None:
|
elif default is not None:
|
||||||
field_data = default
|
field_data = default
|
||||||
|
|
||||||
@@ -543,6 +551,8 @@ class DynamicFormBase(FlaskForm):
|
|||||||
data[original_field_name] = patterns_to_json(field.data)
|
data[original_field_name] = patterns_to_json(field.data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
current_app.logger.error(f"Error converting initial data to patterns: {e}")
|
current_app.logger.error(f"Error converting initial data to patterns: {e}")
|
||||||
|
elif isinstance(field, DateField):
|
||||||
|
data[original_field_name] = field.data.isoformat()
|
||||||
else:
|
else:
|
||||||
data[original_field_name] = field.data
|
data[original_field_name] = field.data
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ from wtforms.validators import DataRequired, Length, Optional
|
|||||||
from wtforms_sqlalchemy.fields import QuerySelectMultipleField
|
from wtforms_sqlalchemy.fields import QuerySelectMultipleField
|
||||||
|
|
||||||
from common.models.document import Retriever
|
from common.models.document import Retriever
|
||||||
from common.models.interaction import EveAITool
|
from common.models.interaction import EveAITool, Specialist
|
||||||
from common.extensions import cache_manager
|
from common.extensions import cache_manager
|
||||||
|
from common.utils.form_assistants import validate_json
|
||||||
|
|
||||||
from .dynamic_form_base import DynamicFormBase
|
from .dynamic_form_base import DynamicFormBase
|
||||||
|
|
||||||
@@ -132,4 +133,46 @@ class ExecuteSpecialistForm(DynamicFormBase):
|
|||||||
description = TextAreaField('Specialist Description', validators=[Optional()], render_kw={'readonly': True})
|
description = TextAreaField('Specialist Description', validators=[Optional()], render_kw={'readonly': True})
|
||||||
|
|
||||||
|
|
||||||
|
class SpecialistMagicLinkForm(FlaskForm):
|
||||||
|
name = StringField('Name', validators=[DataRequired(), Length(max=50)])
|
||||||
|
description = TextAreaField('Description', validators=[Optional()])
|
||||||
|
magic_link_code = StringField('Magic Link Code', validators=[DataRequired(), Length(max=55)], render_kw={'readonly': True})
|
||||||
|
specialist_id = SelectField('Specialist', validators=[DataRequired()])
|
||||||
|
valid_from = DateField('Valid From', id='form-control datepicker', validators=[Optional()])
|
||||||
|
valid_to = DateField('Valid To', id='form-control datepicker', validators=[Optional()])
|
||||||
|
|
||||||
|
# Metadata fields
|
||||||
|
user_metadata = TextAreaField('User Metadata', validators=[Optional(), validate_json])
|
||||||
|
system_metadata = TextAreaField('System Metadata', validators=[Optional(), validate_json])
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
specialists = Specialist.query.all()
|
||||||
|
# Dynamically populate the 'type' field using the constructor
|
||||||
|
self.specialist_id.choices = [(specialist.id, specialist.name) for specialist in specialists]
|
||||||
|
|
||||||
|
|
||||||
|
class EditSpecialistMagicLinkForm(DynamicFormBase):
|
||||||
|
name = StringField('Name', validators=[DataRequired(), Length(max=50)])
|
||||||
|
description = TextAreaField('Description', validators=[Optional()])
|
||||||
|
magic_link_code = StringField('Magic Link Code', validators=[DataRequired(), Length(max=55)],
|
||||||
|
render_kw={'readonly': True})
|
||||||
|
specialist_id = IntegerField('Specialist', validators=[DataRequired()], render_kw={'readonly': True})
|
||||||
|
specialist_name = StringField('Specialist Name', validators=[DataRequired()], render_kw={'readonly': True})
|
||||||
|
valid_from = DateField('Valid From', id='form-control datepicker', validators=[Optional()])
|
||||||
|
valid_to = DateField('Valid To', id='form-control datepicker', validators=[Optional()])
|
||||||
|
|
||||||
|
# Metadata fields
|
||||||
|
user_metadata = TextAreaField('User Metadata', validators=[Optional(), validate_json])
|
||||||
|
system_metadata = TextAreaField('System Metadata', validators=[Optional(), validate_json])
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
specialist = Specialist.query.get(kwargs['specialist_id'])
|
||||||
|
if specialist:
|
||||||
|
self.specialist_name.data = specialist.name
|
||||||
|
else:
|
||||||
|
self.specialist_name.data = ''
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import ast
|
import ast
|
||||||
import json
|
import json
|
||||||
|
import uuid
|
||||||
from datetime import datetime as dt, timezone as tz
|
from datetime import datetime as dt, timezone as tz
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -13,9 +14,10 @@ from werkzeug.utils import secure_filename
|
|||||||
|
|
||||||
from common.models.document import Embedding, DocumentVersion, Retriever
|
from common.models.document import Embedding, DocumentVersion, Retriever
|
||||||
from common.models.interaction import (ChatSession, Interaction, InteractionEmbedding, Specialist, SpecialistRetriever,
|
from common.models.interaction import (ChatSession, Interaction, InteractionEmbedding, Specialist, SpecialistRetriever,
|
||||||
EveAIAgent, EveAITask, EveAITool, EveAIAssetVersion)
|
EveAIAgent, EveAITask, EveAITool, EveAIAssetVersion, SpecialistMagicLink)
|
||||||
|
|
||||||
from common.extensions import db, cache_manager
|
from common.extensions import db, cache_manager
|
||||||
|
from common.models.user import SpecialistMagicLinkTenant
|
||||||
from common.services.interaction.specialist_services import SpecialistServices
|
from common.services.interaction.specialist_services import SpecialistServices
|
||||||
from common.utils.asset_utils import create_asset_stack, add_asset_version_file
|
from common.utils.asset_utils import create_asset_stack, add_asset_version_file
|
||||||
from common.utils.execution_progress import ExecutionProgressTracker
|
from common.utils.execution_progress import ExecutionProgressTracker
|
||||||
@@ -26,7 +28,8 @@ from common.utils.nginx_utils import prefixed_url_for
|
|||||||
from common.utils.view_assistants import form_validation_failed, prepare_table_for_macro
|
from common.utils.view_assistants import form_validation_failed, prepare_table_for_macro
|
||||||
|
|
||||||
from .interaction_forms import (SpecialistForm, EditSpecialistForm, EditEveAIAgentForm, EditEveAITaskForm,
|
from .interaction_forms import (SpecialistForm, EditSpecialistForm, EditEveAIAgentForm, EditEveAITaskForm,
|
||||||
EditEveAIToolForm, AddEveAIAssetForm, EditEveAIAssetVersionForm, ExecuteSpecialistForm)
|
EditEveAIToolForm, AddEveAIAssetForm, EditEveAIAssetVersionForm, ExecuteSpecialistForm,
|
||||||
|
SpecialistMagicLinkForm, EditSpecialistMagicLinkForm)
|
||||||
|
|
||||||
interaction_bp = Blueprint('interaction_bp', __name__, url_prefix='/interaction')
|
interaction_bp = Blueprint('interaction_bp', __name__, url_prefix='/interaction')
|
||||||
|
|
||||||
@@ -669,3 +672,119 @@ def session_interactions(chat_session_id):
|
|||||||
"""
|
"""
|
||||||
chat_session = ChatSession.query.get_or_404(chat_session_id)
|
chat_session = ChatSession.query.get_or_404(chat_session_id)
|
||||||
return session_interactions_by_session_id(chat_session.session_id)
|
return session_interactions_by_session_id(chat_session.session_id)
|
||||||
|
|
||||||
|
|
||||||
|
# Routes for SpecialistMagicLink Management -------------------------------------------------------
|
||||||
|
@interaction_bp.route('/specialist_magic_link', methods=['GET', 'POST'])
|
||||||
|
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
|
||||||
|
def specialist_magic_link():
|
||||||
|
form = SpecialistMagicLinkForm()
|
||||||
|
|
||||||
|
if request.method == 'GET':
|
||||||
|
magic_link_code = f"SPECIALIST_ML-{str(uuid.uuid4())}"
|
||||||
|
form.magic_link_code.data = magic_link_code
|
||||||
|
|
||||||
|
if form.validate_on_submit():
|
||||||
|
tenant_id = session.get('tenant').get('id')
|
||||||
|
try:
|
||||||
|
new_specialist_magic_link = SpecialistMagicLink()
|
||||||
|
|
||||||
|
# Populate fields individually instead of using populate_obj (gives problem with QueryMultipleSelectField)
|
||||||
|
form.populate_obj(new_specialist_magic_link)
|
||||||
|
|
||||||
|
set_logging_information(new_specialist_magic_link, dt.now(tz.utc))
|
||||||
|
|
||||||
|
# Create 'public' SpecialistMagicLinkTenant
|
||||||
|
new_spec_ml_tenant = SpecialistMagicLinkTenant()
|
||||||
|
new_spec_ml_tenant.magic_link_code = new_specialist_magic_link.magic_link_code
|
||||||
|
new_spec_ml_tenant.tenant_id = tenant_id
|
||||||
|
|
||||||
|
db.session.add(new_specialist_magic_link)
|
||||||
|
db.session.add(new_spec_ml_tenant)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
flash('Specialist Magic Link successfully added!', 'success')
|
||||||
|
current_app.logger.info(f'Specialist {new_specialist_magic_link.name} successfully added for '
|
||||||
|
f'tenant {tenant_id}!')
|
||||||
|
|
||||||
|
return redirect(prefixed_url_for('interaction_bp.edit_specialist_magic_link',
|
||||||
|
specialist_magic_link_id=new_specialist_magic_link.id))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
current_app.logger.error(f'Failed to add specialist magic link. Error: {str(e)}', exc_info=True)
|
||||||
|
flash(f'Failed to add specialist magic link. Error: {str(e)}', 'danger')
|
||||||
|
|
||||||
|
return render_template('interaction/specialist_magic_link.html', form=form)
|
||||||
|
|
||||||
|
|
||||||
|
@interaction_bp.route('/specialist_magic_link/<int:specialist_magic_link_id>', methods=['GET', 'POST'])
|
||||||
|
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
|
||||||
|
def edit_specialist_magic_link(specialist_magic_link_id):
|
||||||
|
specialist_ml = SpecialistMagicLink.query.get_or_404(specialist_magic_link_id)
|
||||||
|
# We need to pass along the extra kwarg specialist_id, as this id is required to initialize the form
|
||||||
|
form = EditSpecialistMagicLinkForm(request.form, obj=specialist_ml, specialist_id=specialist_ml.specialist_id)
|
||||||
|
|
||||||
|
# Find the Specialist type and type_version to enable to retrieve the arguments
|
||||||
|
specialist = Specialist.query.get_or_404(specialist_ml.specialist_id)
|
||||||
|
specialist_config = cache_manager.specialists_config_cache.get_config(specialist.type, specialist.type_version)
|
||||||
|
|
||||||
|
form.add_dynamic_fields("arguments", specialist_config, specialist_ml.specialist_args)
|
||||||
|
|
||||||
|
if form.validate_on_submit():
|
||||||
|
# Update the basic fields
|
||||||
|
form.populate_obj(specialist_ml)
|
||||||
|
# Update the arguments dynamic fields
|
||||||
|
specialist_ml.specialist_args = form.get_dynamic_data("arguments")
|
||||||
|
|
||||||
|
# Update logging information
|
||||||
|
update_logging_information(specialist_ml, dt.now(tz.utc))
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.session.commit()
|
||||||
|
flash('Specialist Magic Link updated successfully!', 'success')
|
||||||
|
current_app.logger.info(f'Specialist Magic Link {specialist_ml.id} updated successfully')
|
||||||
|
return redirect(prefixed_url_for('interaction_bp.specialist_magic_links'))
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Failed to update specialist Magic Link. Error: {str(e)}', 'danger')
|
||||||
|
current_app.logger.error(f'Failed to update specialist Magic Link {specialist_ml.id}. Error: {str(e)}')
|
||||||
|
else:
|
||||||
|
form_validation_failed(request, form)
|
||||||
|
|
||||||
|
return render_template('interaction/edit_specialist_magic_link.html', form=form)
|
||||||
|
|
||||||
|
|
||||||
|
@interaction_bp.route('/specialist_magic_links', methods=['GET', 'POST'])
|
||||||
|
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
|
||||||
|
def specialist_magic_links():
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
per_page = request.args.get('per_page', 10, type=int)
|
||||||
|
|
||||||
|
query = SpecialistMagicLink.query.order_by(SpecialistMagicLink.id)
|
||||||
|
|
||||||
|
pagination = query.paginate(page=page, per_page=per_page)
|
||||||
|
the_specialist_magic_links = pagination.items
|
||||||
|
|
||||||
|
# prepare table data
|
||||||
|
rows = prepare_table_for_macro(the_specialist_magic_links, [('id', ''), ('name', ''), ('magic_link_code', ''),])
|
||||||
|
|
||||||
|
# Render the catalogs in a template
|
||||||
|
return render_template('interaction/specialist_magic_links.html', rows=rows, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
|
@interaction_bp.route('/handle_specialist_magic_link_selection', methods=['POST'])
|
||||||
|
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
|
||||||
|
def handle_specialist_magic_link_selection():
|
||||||
|
action = request.form.get('action')
|
||||||
|
if action == 'create_specialist_magic_link':
|
||||||
|
return redirect(prefixed_url_for('interaction_bp.specialist_magic_link'))
|
||||||
|
|
||||||
|
specialist_ml_identification = request.form.get('selected_row')
|
||||||
|
specialist_ml_id = ast.literal_eval(specialist_ml_identification).get('value')
|
||||||
|
|
||||||
|
if action == "edit_specialist_magic_link":
|
||||||
|
return redirect(prefixed_url_for('interaction_bp.edit_specialist_magic_link',
|
||||||
|
specialist_magic_link_id=specialist_ml_id))
|
||||||
|
|
||||||
|
return redirect(prefixed_url_for('interaction_bp.specialists'))
|
||||||
|
|||||||
@@ -161,19 +161,19 @@ def edit_partner_service(partner_service_id):
|
|||||||
partner_service = PartnerService.query.get_or_404(partner_service_id)
|
partner_service = PartnerService.query.get_or_404(partner_service_id)
|
||||||
partner = session.get('partner', None)
|
partner = session.get('partner', None)
|
||||||
partner_id = session['partner']['id']
|
partner_id = session['partner']['id']
|
||||||
|
current_app.logger.debug(f"Request Type: {request.method}")
|
||||||
|
|
||||||
form = EditPartnerServiceForm(obj=partner_service)
|
form = EditPartnerServiceForm(obj=partner_service)
|
||||||
if request.method == 'GET':
|
|
||||||
partner_service_config = cache_manager.partner_services_config_cache.get_config(partner_service.type,
|
partner_service_config = cache_manager.partner_services_config_cache.get_config(partner_service.type,
|
||||||
partner_service.type_version)
|
partner_service.type_version)
|
||||||
configuration_config = partner_service_config.get('configuration')
|
configuration_config = partner_service_config.get('configuration')
|
||||||
current_app.logger.debug(f"Configuration config for {partner_service.type} {partner_service.type_version}: "
|
current_app.logger.debug(f"Configuration config for {partner_service.type} {partner_service.type_version}: "
|
||||||
f"{configuration_config}")
|
f"{configuration_config}")
|
||||||
form.add_dynamic_fields("configuration", configuration_config, partner_service.configuration)
|
form.add_dynamic_fields("configuration", partner_service_config, partner_service.configuration)
|
||||||
permissions_config = partner_service_config.get('permissions')
|
permissions_config = partner_service_config.get('permissions')
|
||||||
current_app.logger.debug(f"Permissions config for {partner_service.type} {partner_service.type_version}: "
|
current_app.logger.debug(f"Permissions config for {partner_service.type} {partner_service.type_version}: "
|
||||||
f"{permissions_config}")
|
f"{permissions_config}")
|
||||||
form.add_dynamic_fields("permissions", permissions_config, partner_service.permissions)
|
form.add_dynamic_fields("permissions", partner_service_config, partner_service.permissions)
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
current_app.logger.debug(f"Form returned: {form.data}")
|
current_app.logger.debug(f"Form returned: {form.data}")
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class TenantForm(FlaskForm):
|
|||||||
# initialise currency field
|
# initialise currency field
|
||||||
self.currency.choices = [(curr, curr) for curr in current_app.config['SUPPORTED_CURRENCIES']]
|
self.currency.choices = [(curr, curr) for curr in current_app.config['SUPPORTED_CURRENCIES']]
|
||||||
# initialise timezone
|
# initialise timezone
|
||||||
self.timezone.choices = [(tz, tz) for tz in pytz.all_timezones]
|
self.timezone.choices = [(tz, tz) for tz in pytz.common_timezones]
|
||||||
# Initialize fallback algorithms
|
# Initialize fallback algorithms
|
||||||
self.type.choices = [(t, t) for t in current_app.config['TENANT_TYPES']]
|
self.type.choices = [(t, t) for t in current_app.config['TENANT_TYPES']]
|
||||||
# Show field only for Super Users with partner in session
|
# Show field only for Super Users with partner in session
|
||||||
|
|||||||
@@ -121,7 +121,7 @@
|
|||||||
{% elif cell.type == 'badge' %}
|
{% elif cell.type == 'badge' %}
|
||||||
<span class="badge badge-sm {{ cell.badge_class }}">{{ cell.value }}</span>
|
<span class="badge badge-sm {{ cell.badge_class }}">{{ cell.value }}</span>
|
||||||
{% elif cell.type == 'link' %}
|
{% elif cell.type == 'link' %}
|
||||||
<a href="{{ cell.href }}" class="text-secondary font-weight-normal text-xs" data-toggle="tooltip" data-original-title="{{ cell.title }}">{{ cell.value }}</a>
|
<a href="{{ cell.href }}" class="text-secondary font-weight-normal text-xs" data-bs-toggle="tooltip" data-original-title="{{ cell.title }}">{{ cell.value }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ cell.value }}
|
{{ cell.value }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -177,7 +177,7 @@
|
|||||||
{% elif cell.type == 'badge' %}
|
{% elif cell.type == 'badge' %}
|
||||||
<span class="badge badge-sm {{ cell.badge_class }}">{{ cell.value }}</span>
|
<span class="badge badge-sm {{ cell.badge_class }}">{{ cell.value }}</span>
|
||||||
{% elif cell.type == 'link' %}
|
{% elif cell.type == 'link' %}
|
||||||
<a href="{{ cell.href }}" class="text-secondary font-weight-normal text-xs" data-toggle="tooltip" data-original-title="{{ cell.title }}">{{ cell.value }}</a>
|
<a href="{{ cell.href }}" class="text-secondary font-weight-normal text-xs" data-bs-toggle="tooltip" data-original-title="{{ cell.title }}">{{ cell.value }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ cell.value }}
|
{{ cell.value }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -342,7 +342,7 @@
|
|||||||
{% elif cell.type == 'badge' %}
|
{% elif cell.type == 'badge' %}
|
||||||
<span class="badge badge-sm {{ cell.badge_class }}">{{ cell.value }}</span>
|
<span class="badge badge-sm {{ cell.badge_class }}">{{ cell.value }}</span>
|
||||||
{% elif cell.type == 'link' %}
|
{% elif cell.type == 'link' %}
|
||||||
<a href="{{ cell.href }}" class="text-secondary font-weight-normal text-xs" data-toggle="tooltip" data-original-title="{{ cell.title }}">{{ cell.value }}</a>
|
<a href="{{ cell.href }}" class="text-secondary font-weight-normal text-xs" data-bs-toggle="tooltip" data-original-title="{{ cell.title }}">{{ cell.value }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ cell.value }}
|
{{ cell.value }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Add SpecialistMagicLinkTenant model
|
||||||
|
|
||||||
|
Revision ID: 2b4cb553530e
|
||||||
|
Revises: 7d3c6f48735c
|
||||||
|
Create Date: 2025-06-03 20:26:36.423880
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '2b4cb553530e'
|
||||||
|
down_revision = '7d3c6f48735c'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('specialist_magic_link_tenant',
|
||||||
|
sa.Column('magic_link_code', sa.String(length=55), nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['tenant_id'], ['public.tenant.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('magic_link_code'),
|
||||||
|
schema='public'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('specialist_magic_link_tenant', schema='public')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -71,8 +71,8 @@ target_db = current_app.extensions['migrate'].db
|
|||||||
def get_public_table_names():
|
def get_public_table_names():
|
||||||
# TODO: This function should include the necessary functionality to automatically retrieve table names
|
# TODO: This function should include the necessary functionality to automatically retrieve table names
|
||||||
return ['role', 'roles_users', 'tenant', 'user', 'tenant_domain','license_tier', 'license', 'license_usage',
|
return ['role', 'roles_users', 'tenant', 'user', 'tenant_domain','license_tier', 'license', 'license_usage',
|
||||||
'business_event_log', 'tenant_project']
|
'business_event_log', 'tenant_project', 'partner', 'partner_service', 'invoice', 'license_period',
|
||||||
|
'license_change_log', 'partner_service_license_tier', 'payment', 'partner_tenant']
|
||||||
|
|
||||||
PUBLIC_TABLES = get_public_table_names()
|
PUBLIC_TABLES = get_public_table_names()
|
||||||
logger.info(f"Public tables: {PUBLIC_TABLES}")
|
logger.info(f"Public tables: {PUBLIC_TABLES}")
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Add SpecialistMagicLink model
|
||||||
|
|
||||||
|
Revision ID: d69520ec540d
|
||||||
|
Revises: 55c696c4a687
|
||||||
|
Create Date: 2025-06-03 20:25:51.129869
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import pgvector
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'd69520ec540d'
|
||||||
|
down_revision = '55c696c4a687'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('specialist_magic_link',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('specialist_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('magic_link_code', sa.String(length=55), nullable=False),
|
||||||
|
sa.Column('valid_from', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('valid_to', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('specialist_args', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
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(['specialist_id'], ['specialist.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['updated_by'], ['public.user.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('magic_link_code')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('specialist_magic_link')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -1192,5 +1192,27 @@ select.select2[multiple] {
|
|||||||
border: 1px solid var(--bs-primary) !important; /* Duidelijke rand toevoegen */
|
border: 1px solid var(--bs-primary) !important; /* Duidelijke rand toevoegen */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Select2 settings ---------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.select2-container--default .select2-results > .select2-results__options {
|
||||||
|
max-height: 200px !important; /* Pas deze waarde aan naar wens */
|
||||||
|
overflow-y: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zorg voor een consistente breedte */
|
||||||
|
.select2-container {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Voorkom dat de dropdown de pagina uitbreidt */
|
||||||
|
.select2-dropdown {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timezone-dropdown {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ def initialize_default_tenant():
|
|||||||
'timezone': 'UTC',
|
'timezone': 'UTC',
|
||||||
'default_language': 'en',
|
'default_language': 'en',
|
||||||
'allowed_languages': ['en', 'fr', 'nl', 'de', 'es'],
|
'allowed_languages': ['en', 'fr', 'nl', 'de', 'es'],
|
||||||
'llm_model': 'mistral.mistral-large-latest',
|
|
||||||
'type': 'Active',
|
'type': 'Active',
|
||||||
'currency': '€',
|
'currency': '€',
|
||||||
'created_at': dt.now(tz.utc),
|
'created_at': dt.now(tz.utc),
|
||||||
|
|||||||
Reference in New Issue
Block a user