3 Commits

Author SHA1 Message Date
Josako
57c0e7a1ba Update changelog 2025-06-04 13:35:27 +02:00
Josako
0d05499d2b - Add Specialist Magic Links
- correction of some bugs:
  - 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
2025-06-04 11:53:35 +02:00
Josako
b4e58659a8 - Allow and improve viewing of different content types. First type implemented: changelog 2025-06-03 09:48:50 +02:00
44 changed files with 1542 additions and 96 deletions

View File

@@ -11,6 +11,7 @@ from flask_restx import Api
from prometheus_flask_exporter import PrometheusMetrics
from .utils.cache.eveai_cache_manager import EveAICacheManager
from .utils.content_utils import ContentManager
from .utils.simple_encryption import SimpleEncryption
from .utils.minio_utils import MinioClient
@@ -30,4 +31,5 @@ simple_encryption = SimpleEncryption()
minio_client = MinioClient()
metrics = PrometheusMetrics.for_app_factory()
cache_manager = EveAICacheManager()
content_manager = ContentManager()

View File

@@ -215,3 +215,24 @@ class SpecialistDispatcher(db.Model):
dispatcher_id = db.Column(db.Integer, db.ForeignKey(Dispatcher.id, ondelete='CASCADE'), primary_key=True)
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}>"

View File

@@ -271,3 +271,13 @@ class PartnerTenant(db.Model):
created_by = db.Column(db.Integer, db.ForeignKey('public.user.id'), nullable=True)
updated_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now(), onupdate=db.func.now())
updated_by = db.Column(db.Integer, db.ForeignKey('public.user.id'), 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)

View File

@@ -6,7 +6,7 @@ from sqlalchemy.exc import SQLAlchemyError
from common.extensions import db
from common.models.entitlements import PartnerServiceLicenseTier
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
@@ -19,7 +19,7 @@ class LicenseTierServices:
# Get partner service (MANAGEMENT_SERVICE type)
partner = Partner.query.get(partner_id)
if not partner:
return
raise EveAINoSessionPartner()
# Find a management service for this partner
management_service = next((service for service in session['partner']['services']

View File

@@ -28,7 +28,7 @@ class TenantServices:
if service.get('type') == 'MANAGEMENT_SERVICE'), None)
if not management_service:
current_app.logger.error(f"No Management Service defined for partner {partner_id}"
current_app.logger.error(f"No Management Service defined for partner {partner_id} "
f"while associating tenant {tenant_id} with partner.")
raise EveAINoManagementPartnerService()

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

View File

@@ -1,3 +1,4 @@
import traceback
import jinja2
@@ -12,6 +13,7 @@ def not_found_error(error):
if not current_user.is_authenticated:
return redirect(prefixed_url_for('security.login'))
current_app.logger.error(f"Not Found Error: {error}")
current_app.logger.error(traceback.format_exc())
return render_template('error/404.html'), 404
@@ -19,6 +21,7 @@ def internal_server_error(error):
if not current_user.is_authenticated:
return redirect(prefixed_url_for('security.login'))
current_app.logger.error(f"Internal Server Error: {error}")
current_app.logger.error(traceback.format_exc())
return render_template('error/500.html'), 500
@@ -26,6 +29,7 @@ def not_authorised_error(error):
if not current_user.is_authenticated:
return redirect(prefixed_url_for('security.login'))
current_app.logger.error(f"Not Authorised Error: {error}")
current_app.logger.error(traceback.format_exc())
return render_template('error/401.html')
@@ -33,6 +37,7 @@ def access_forbidden(error):
if not current_user.is_authenticated:
return redirect(prefixed_url_for('security.login'))
current_app.logger.error(f"Access Forbidden: {error}")
current_app.logger.error(traceback.format_exc())
return render_template('error/403.html')
@@ -42,6 +47,7 @@ def key_error_handler(error):
return redirect(prefixed_url_for('security.login'))
# 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(traceback.format_exc())
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.
"""
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')
# Perform logout if user is authenticated
@@ -95,6 +102,26 @@ def general_exception(e):
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):
app.register_error_handler(404, not_found_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(KeyError, key_error_handler)
app.register_error_handler(AttributeError, attribute_error_handler)
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
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)

View File

@@ -172,6 +172,9 @@ class Config(object):
# Entitlement Constants
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):
DEVELOPMENT = True

View File

@@ -68,11 +68,27 @@ competency_details:
required: true
default: true
arguments:
vacancy_text:
name: "vacancy_text"
type: "text"
description: "The Vacancy Text"
region:
name: "Region"
type: "str"
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
results:
competencies:
name: "competencies"

View File

@@ -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/),
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]
### Added

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

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

View File

@@ -91,6 +91,7 @@ services:
volumes:
- ../eveai_app:/app/eveai_app
- ../common:/app/common
- ../content:/app/content
- ../config:/app/config
- ../migrations:/app/migrations
- ../scripts:/app/scripts

View File

@@ -56,6 +56,7 @@ COPY config /app/config
COPY migrations /app/migrations
COPY scripts /app/scripts
COPY patched_packages /app/patched_packages
COPY content /app/content
# Set permissions for entrypoint script
RUN chmod 777 /app/scripts/entrypoint.sh

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

View File

@@ -7,7 +7,7 @@ from werkzeug.middleware.proxy_fix import ProxyFix
import logging.config
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
import common.models.interaction
import common.models.entitlements
@@ -15,7 +15,7 @@ import common.models.document
from common.utils.startup_eveai import perform_startup_actions
from config.logging_config import LOGGING
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.template_filters import register_filters
from config.config import get_config
@@ -124,6 +124,7 @@ def register_extensions(app):
minio_client.init_app(app)
cache_manager.init_app(app)
metrics.init_app(app)
content_manager.init_app(app)
def register_blueprints(app):

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

View File

@@ -19,17 +19,17 @@
<div class="nav-wrapper position-relative end-0">
<ul class="nav nav-pills nav-fill p-1" role="tablist">
<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
</a>
</li>
<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
</a>
</li>
<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
</a>
</li>

View File

@@ -19,17 +19,17 @@
<div class="nav-wrapper position-relative end-0">
<ul class="nav nav-pills nav-fill p-1" role="tablist">
<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
</a>
</li>
<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
</a>
</li>
<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
</a>
</li>

View File

@@ -107,17 +107,17 @@
<!-- Nav Tabs -->
<ul class="nav nav-tabs" id="periodTabs" role="tablist">
<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
</a>
</li>
<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
</a>
</li>
<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
</a>
</li>

View File

@@ -1,3 +1,4 @@
{% extends 'base.html' %}
{% from "macros.html" import render_field, render_included_field %}
@@ -19,17 +20,17 @@
<div class="nav-wrapper position-relative end-0">
<ul class="nav nav-pills nav-fill p-1" role="tablist">
<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
</a>
</li>
<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
</a>
</li>
<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
</a>
</li>
@@ -68,4 +69,4 @@
{% block content_footer %}
{% endblock %}
{% endblock %}

View File

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

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

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

View File

@@ -1,7 +1,7 @@
{% extends 'base.html' %}
{% from 'macros.html' import render_selectable_table, render_pagination %}
{% block title %}Retrievers{% endblock %}
{% block title %}Specialists{% endblock %}
{% block content_title %}Specialists{% endblock %}
{% block content_description %}View Specialists for Tenant{% endblock %}

View File

@@ -138,7 +138,7 @@
{% elif cell.type == 'badge' %}
<span class="badge badge-sm {{ cell.badge_class }}">{{ cell.value }}</span>
{% 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 %}
{{ cell.value }}
{% endif %}
@@ -192,7 +192,7 @@
{% elif cell.type == 'badge' %}
<span class="badge badge-sm {{ cell.badge_class }}">{{ cell.value }}</span>
{% 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 %}
{{ cell.value }}
{% endif %}
@@ -357,7 +357,7 @@
{% elif cell.type == 'badge' %}
<span class="badge badge-sm {{ cell.badge_class }}">{{ cell.value }}</span>
{% 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 %}
{{ cell.value }}
{% endif %}
@@ -450,3 +450,10 @@
</div>
</div>
{% endmacro %}
{% macro debug_to_console(var_name, var_value) %}
<script>
console.log('{{ var_name }}:', {{ var_value|tojson }});
</script>
{% endmacro %}

View File

@@ -106,6 +106,7 @@
{% if current_user.is_authenticated %}
{{ dropdown('Interactions', 'hub', [
{'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']},
]) }}
{% endif %}

View File

@@ -1,9 +1,9 @@
{% extends "base.html" %}
{% from "macros.html" import render_field %}
{% block title %}Register Partner Service{% endblock %}
{% from "macros.html" import render_field, debug_to_console %}
{% block title %}Edit Partner Service{% endblock %}
{% block content_title %}Register Partner Service{% endblock %}
{% block content_description %}Register Partner Service{% endblock %}
{% block content_title %}Edit Partner Service{% endblock %}
{% block content_description %}Edit Partner Service{% endblock %}
{% block content %}
<form method="post">
@@ -16,6 +16,8 @@
{% endfor %}
<!-- Render Dynamic Fields -->
{% 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 %}
<h4 class="mt-4">{{ collection_name }}</h4>
{% endif %}
@@ -23,6 +25,6 @@
{{ render_field(field, disabled_fields, exclude_fields) }}
{% 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>
{% endblock %}

View File

@@ -19,3 +19,37 @@
{% 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 %}

View File

@@ -26,7 +26,7 @@
{% block scripts %}
<script>
// JavaScript to detect user's timezone
// JavaScript om de gebruiker's timezone te detecteren
document.addEventListener('DOMContentLoaded', (event) => {
// Detect timezone
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
@@ -45,6 +45,31 @@
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>
{% endblock %}

View File

@@ -21,7 +21,7 @@
<div class="nav-wrapper position-relative end-0">
<ul class="nav nav-pills nav-fill p-1" role="tablist">
<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
</a>
</li>

View File

@@ -9,9 +9,17 @@ from common.models.user import Tenant
from common.utils.database import Database
from common.utils.nginx_utils import prefixed_url_for
from .basic_forms import SessionDefaultsForm
from common.extensions import content_manager
import markdown
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
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')
def release_notes():
"""Display the CHANGELOG.md file."""
def view_content(content_type):
"""
Show content like release notes, terms of use, etc.
Args:
content_type (str): Type content (eg. 'changelog', 'terms', 'privacy')
"""
try:
# Construct the URL to the CHANGELOG.md file in the static directory
static_url = url_for('static', filename='docs/CHANGELOG.md', _external=True)
current_app.logger.debug(f"Showing content {content_type}")
major_minor = request.args.get('version')
patch = request.args.get('patch')
# Make a request to get the content of the CHANGELOG.md file
response = requests.get(static_url)
response.raise_for_status() # Raise an exception for HTTP errors
# Gebruik de ContentManager om de content op te halen
content_data = content_manager.read_content(content_type, major_minor, patch)
# Get the content of the response
markdown_content = response.text
if not content_data:
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(
'basic/view_markdown.html',
title='Release Notes',
description='EveAI Release Notes and Change History',
markdown_content=markdown_content
title=titles.get(content_type, content_type.capitalize()),
description=descriptions.get(content_type, ''),
markdown_content=content_data['content'],
version=content_data['version']
)
except Exception as e:
current_app.logger.error(f"Error displaying release notes: {str(e)}")
flash(f'Error displaying release notes: {str(e)}', 'danger')
current_app.logger.error(f"Error displaying content {content_type}: {str(e)}")
flash(f'Error displaying content: {str(e)}', 'danger')
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'))

View File

@@ -389,10 +389,7 @@ def add_document():
catalog = Catalog.query.get_or_404(catalog_id)
if catalog.configuration and len(catalog.configuration) > 0:
full_config = cache_manager.catalogs_config_cache.get_config(catalog.type)
document_version_configurations = full_config['document_version_configurations']
for config in document_version_configurations:
form.add_dynamic_fields(config, full_config, catalog.configuration[config])
form.add_dynamic_fields("tagging_fields", catalog.configuration)
if form.validate_on_submit():
try:
@@ -402,11 +399,8 @@ def add_document():
sub_file_type = form.sub_file_type.data
filename = secure_filename(file.filename)
extension = filename.rsplit('.', 1)[1].lower()
catalog_properties = {}
full_config = cache_manager.catalogs_config_cache.get_config(catalog.type)
document_version_configurations = full_config['document_version_configurations']
for config in document_version_configurations:
catalog_properties[config] = form.get_dynamic_data(config)
catalog_properties = form.get_dynamic_data("tagging_fields")
api_input = {
'catalog_id': catalog_id,
@@ -446,10 +440,7 @@ def add_url():
catalog = Catalog.query.get_or_404(catalog_id)
if catalog.configuration and len(catalog.configuration) > 0:
full_config = cache_manager.catalogs_config_cache.get_config(catalog.type)
document_version_configurations = full_config['document_version_configurations']
for config in document_version_configurations:
form.add_dynamic_fields(config, full_config, catalog.configuration[config])
form.add_dynamic_fields("tagging_fields", catalog.configuration)
if form.validate_on_submit():
try:

View File

@@ -1,3 +1,5 @@
from datetime import date
from flask_wtf import FlaskForm
from wtforms import (IntegerField, FloatField, BooleanField, StringField, TextAreaField, FileField,
validators, ValidationError)
@@ -396,6 +398,12 @@ class DynamicFormBase(FlaskForm):
except (TypeError, ValueError) as e:
current_app.logger.error(f"Error converting initial data to a list of patterns: {e}")
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:
field_data = default
@@ -543,6 +551,8 @@ class DynamicFormBase(FlaskForm):
data[original_field_name] = patterns_to_json(field.data)
except Exception as 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:
data[original_field_name] = field.data
return data

View File

@@ -7,8 +7,9 @@ from wtforms.validators import DataRequired, Length, Optional
from wtforms_sqlalchemy.fields import QuerySelectMultipleField
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.utils.form_assistants import validate_json
from .dynamic_form_base import DynamicFormBase
@@ -132,4 +133,46 @@ class ExecuteSpecialistForm(DynamicFormBase):
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 = ''

View File

@@ -1,5 +1,6 @@
import ast
import json
import uuid
from datetime import datetime as dt, timezone as tz
import time
@@ -13,9 +14,10 @@ from werkzeug.utils import secure_filename
from common.models.document import Embedding, DocumentVersion, Retriever
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.models.user import SpecialistMagicLinkTenant
from common.services.interaction.specialist_services import SpecialistServices
from common.utils.asset_utils import create_asset_stack, add_asset_version_file
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 .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')
@@ -669,3 +672,119 @@ def session_interactions(chat_session_id):
"""
chat_session = ChatSession.query.get_or_404(chat_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'))

View File

@@ -161,19 +161,19 @@ def edit_partner_service(partner_service_id):
partner_service = PartnerService.query.get_or_404(partner_service_id)
partner = session.get('partner', None)
partner_id = session['partner']['id']
current_app.logger.debug(f"Request Type: {request.method}")
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.type_version)
configuration_config = partner_service_config.get('configuration')
current_app.logger.debug(f"Configuration config for {partner_service.type} {partner_service.type_version}: "
f"{configuration_config}")
form.add_dynamic_fields("configuration", configuration_config, partner_service.configuration)
permissions_config = partner_service_config.get('permissions')
current_app.logger.debug(f"Permissions config for {partner_service.type} {partner_service.type_version}: "
f"{permissions_config}")
form.add_dynamic_fields("permissions", permissions_config, partner_service.permissions)
partner_service_config = cache_manager.partner_services_config_cache.get_config(partner_service.type,
partner_service.type_version)
configuration_config = partner_service_config.get('configuration')
current_app.logger.debug(f"Configuration config for {partner_service.type} {partner_service.type_version}: "
f"{configuration_config}")
form.add_dynamic_fields("configuration", partner_service_config, partner_service.configuration)
permissions_config = partner_service_config.get('permissions')
current_app.logger.debug(f"Permissions config for {partner_service.type} {partner_service.type_version}: "
f"{permissions_config}")
form.add_dynamic_fields("permissions", partner_service_config, partner_service.permissions)
if request.method == 'POST':
current_app.logger.debug(f"Form returned: {form.data}")

View File

@@ -36,7 +36,7 @@ class TenantForm(FlaskForm):
# initialise currency field
self.currency.choices = [(curr, curr) for curr in current_app.config['SUPPORTED_CURRENCIES']]
# 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
self.type.choices = [(t, t) for t in current_app.config['TENANT_TYPES']]
# Show field only for Super Users with partner in session

View File

@@ -121,7 +121,7 @@
{% elif cell.type == 'badge' %}
<span class="badge badge-sm {{ cell.badge_class }}">{{ cell.value }}</span>
{% 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 %}
{{ cell.value }}
{% endif %}
@@ -177,7 +177,7 @@
{% elif cell.type == 'badge' %}
<span class="badge badge-sm {{ cell.badge_class }}">{{ cell.value }}</span>
{% 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 %}
{{ cell.value }}
{% endif %}
@@ -342,7 +342,7 @@
{% elif cell.type == 'badge' %}
<span class="badge badge-sm {{ cell.badge_class }}">{{ cell.value }}</span>
{% 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 %}
{{ cell.value }}
{% endif %}

View File

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

View File

@@ -71,8 +71,8 @@ target_db = current_app.extensions['migrate'].db
def get_public_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',
'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()
logger.info(f"Public tables: {PUBLIC_TABLES}")

View File

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

View File

@@ -1192,5 +1192,27 @@ select.select2[multiple] {
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;
}

View File

@@ -83,7 +83,6 @@ def initialize_default_tenant():
'timezone': 'UTC',
'default_language': 'en',
'allowed_languages': ['en', 'fr', 'nl', 'de', 'es'],
'llm_model': 'mistral.mistral-large-latest',
'type': 'Active',
'currency': '',
'created_at': dt.now(tz.utc),