- Modernized authentication with the introduction of TenantProject

- Created a base mail template
- Adapt and improve document API to usage of catalogs and processors
- Adapt eveai_sync to new authentication mechanism and usage of catalogs and processors
This commit is contained in:
Josako
2024-11-21 17:24:33 +01:00
parent 4c009949b3
commit 7702a6dfcc
72 changed files with 2338 additions and 503 deletions

View File

@@ -10,7 +10,7 @@
{% block content %}
<div class="container">
<form method="POST" action="{{ url_for('document_bp.handle_catalog_selection') }}">
{{ render_selectable_table(headers=["Catalog ID", "Name"], rows=rows, selectable=True, id="catalogsTable") }}
{{ render_selectable_table(headers=["Catalog ID", "Name", "Type"], rows=rows, selectable=True, id="catalogsTable") }}
<div class="form-group mt-3">
<button type="submit" name="action" value="set_session_catalog" class="btn btn-primary">Set Session Catalog</button>
<button type="submit" name="action" value="edit_catalog" class="btn btn-primary">Edit Catalog</button>

View File

@@ -0,0 +1,28 @@
{% extends "email/base.html" %}
{% block content %}
<p>Hello,</p>
<p>A new API project has been created for your Ask Eve AI tenant. Here are the details:</p>
<div class="info-box">
<p><strong>Tenant ID:</strong> {{ tenant_id }}</p>
<p><strong>Tenant Name:</strong> {{ tenant_name }}</p>
<p><strong>Project Name:</strong> {{ project_name }}</p>
<p><strong>API Key:</strong> <span style="font-family: monospace; background-color: #f0f0f0; padding: 5px;">{{ api_key }}</span></p>
<div style="margin-top: 15px;">
<p><strong>Enabled Services:</strong></p>
<ul style="list-style-type: none; padding-left: 0;">
{% for service in services %}
<li>✓ {{ service }}</li>
{% endfor %}
</ul>
</div>
</div>
<div class="warning-box">
<strong>Important:</strong> Please store this API key securely. It cannot be retrieved once this email is gone.
</div>
<p>You can start using this API key right away to interact with our services. For documentation and usage examples, please visit our <a href="https://docs.askeveai.com">documentation</a>.</p>
{% endblock %}

View File

@@ -0,0 +1,106 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ subject|default('Message from Ask Eve AI') }}</title>
<style>
.email-container {
font-family: Tahoma, Geneva, sans-serif;
max-width: 600px;
margin: 0 auto;
}
.header {
text-align: center;
padding: 20px;
}
.header img {
max-width: 200px;
}
.footer {
text-align: center;
padding: 20px;
background-color: #f8f9fa;
}
.signature {
font-style: italic;
margin: 20px 0;
}
.footer-text {
font-size: 12px;
color: #666;
}
.footer img {
max-width: 100%;
height: auto;
width: 600px; /* Match the container width */
display: block;
margin: 20px auto;
}
@media only screen and (max-width: 600px) {
.footer img {
width: 100%;
}
}
.social-links {
margin: 20px 0;
}
.social-links a {
margin: 0 10px;
color: #0066cc;
text-decoration: none;
}
.info-box {
background-color: #f8f9fa;
border-left: 4px solid #0066cc;
padding: 15px;
margin: 20px 0;
}
.warning-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 15px;
margin: 20px 0;
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<img src="https://askeveai.com/wp-content/uploads/2024/07/Logo-Square-small.png" alt="Ask Eve AI Logo">
</div>
<div class="content-wrapper">
{% block content %}{% endblock %}
</div>
<div class="footer">
<div class="signature">
Best regards,<br>
Evie
</div>
{% if promo_image_url %}
<a href="https://www.askeveai.com">
<img src="{{ promo_image_url }}" alt="Ask Eve AI Promotion">
</a>
{% endif %}
<div class="social-links">
<a href="https://twitter.com/askeveai">Twitter</a>
<a href="https://linkedin.com/company/ask-eve-ai">LinkedIn</a>
</div>
<div class="footer-text">
© {{ year }} Ask Eve AI. All rights reserved.<br>
<a href="https://www.askeveai.com/privacy">Privacy Policy</a> |
<a href="https://www.askeveai.com/terms">Terms of Service</a>
{% if unsubscribe_url %}
| <a href="{{ unsubscribe_url }}">Unsubscribe</a>
{% endif %}
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,28 @@
{% extends 'base.html' %}
{% from "macros.html" import render_selectable_table, render_pagination %}
{% block title %}View Licenses{% endblock %}
{% block content_title %}View Licenses{% endblock %}
{% block content_description %}View Licenses{% endblock %}
{% block content %}
<form action="{{ url_for('entitlements_bp.handle_license_selection') }}" method="POST">
{{ render_selectable_table(headers=["License ID", "Name", "Start Date", "End Date", "Active"], rows=rows, selectable=True, id="licensesTable") }}
<!-- <div class="form-group mt-3">-->
<!-- <button type="submit" name="action" value="edit_user" class="btn btn-primary">Edit Selected User</button>-->
<!-- <button type="submit" name="action" value="resend_confirmation_email" class="btn btn-secondary">Resend Confirmation Email</button>-->
<!-- <button type="submit" name="action" value="send_password_reset_email" class="btn btn-secondary">Send Password Reset Email</button>-->
<!-- <button type="submit" name="action" value="reset_uniquifier" class="btn btn-secondary">Reset Uniquifier</button>-->
<!-- &lt;!&ndash; Additional buttons can be added here for other actions &ndash;&gt;-->
<!-- </div>-->
</form>
{% endblock %}
{% block content_footer %}
{{ render_pagination(pagination, 'entitlements_bp.view_licenses') }}
{% endblock %}
{% block scripts %}
{% endblock %}

View File

@@ -7,7 +7,7 @@
{% block content_description %}View License Usage{% endblock %}
{% block content %}
<form action="{{ url_for('user_bp.handle_user_action') }}" method="POST">
<form action="{{ url_for('entitlements_bp.handle_usage_selection') }}" method="POST">
{{ render_selectable_table(headers=["Usage ID", "Start Date", "End Date", "Storage (MiB)", "Embedding (MiB)", "Interaction (tokens)"], rows=rows, selectable=False, id="usagesTable") }}
<!-- <div class="form-group mt-3">-->
<!-- <button type="submit" name="action" value="edit_user" class="btn btn-primary">Edit Selected User</button>-->
@@ -20,7 +20,7 @@
{% endblock %}
{% block content_footer %}
{{ render_pagination(pagination, 'user_bp.select_tenant') }}
{{ render_pagination(pagination, 'entitlements_bp.view_usages') }}
{% endblock %}
{% block scripts %}

View File

@@ -1,86 +1,3 @@
<!--{% macro render_field(field, disabled_fields=[], exclude_fields=[], class='') %}-->
<!-- {% set disabled = field.name in disabled_fields %}-->
<!-- {% set exclude_fields = exclude_fields + ['csrf_token', 'submit'] %}-->
<!-- {% if field.name not in exclude_fields %}-->
<!-- {% if field.type == 'BooleanField' %}-->
<!-- <div class="form-check">-->
<!-- {{ field(class="form-check-input " + class, type="checkbox", id="flexSwitchCheckDefault") }}-->
<!-- {{ field.label(class="form-check-label", for="flexSwitchCheckDefault", disabled=disabled) }}-->
<!-- </div>-->
<!-- {% else %}-->
<!-- <div class="form-group">-->
<!-- {{ field.label(class="form-label") }}-->
<!-- {{ field(class="form-control " + class, disabled=disabled) }}-->
<!-- {% if field.errors %}-->
<!-- <div class="invalid-feedback">-->
<!-- {% for error in field.errors %}-->
<!-- {{ error }}-->
<!-- {% endfor %}-->
<!-- </div>-->
<!-- {% endif %}-->
<!-- </div>-->
<!-- {% endif %}-->
<!-- {% endif %}-->
<!--{% endmacro %}-->
{% macro render_field_old(field, disabled_fields=[], exclude_fields=[], class='') %}
<!-- Debug info -->
<!-- Field name: {{ field.name }}, Field type: {{ field.__class__.__name__ }} -->
{% set disabled = field.name in disabled_fields %}
{% set exclude_fields = exclude_fields + ['csrf_token', 'submit'] %}
{% if field.name not in exclude_fields %}
{% if field.type == 'BooleanField' %}
<div class="form-group">
<div class="form-check form-switch">
{{ field(class="form-check-input " + class, disabled=disabled) }}
{% if field.description %}
{{ field.label(class="form-check-label",
**{'data-bs-toggle': 'tooltip',
'data-bs-placement': 'right',
'title': field.description}) }}
{% else %}
{{ field.label(class="form-check-label") }}
{% endif %}
</div>
{% if field.errors %}
<div class="invalid-feedback d-block">
{% for error in field.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
{% else %}
<div class="form-group">
{% if field.description %}
{{ field.label(class="form-label",
**{'data-bs-toggle': 'tooltip',
'data-bs-placement': 'right',
'title': field.description}) }}
{% else %}
{{ field.label(class="form-label") }}
{% endif %}
{% if field.type == 'TextAreaField' and 'json-editor' in class %}
<div id="{{ field.id }}-editor" class="json-editor-container"></div>
{{ field(class="form-control d-none " + class, disabled=disabled) }}
{% else %}
{{ field(class="form-control " + class, disabled=disabled) }}
{% endif %}
{% if field.errors %}
<div class="invalid-feedback d-block">
{% for error in field.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
{% endif %}
{% endmacro %}
{% macro render_field(field, disabled_fields=[], exclude_fields=[], class='') %}
<!-- Debug info -->
<!-- Field name: {{ field.name }}, Field type: {{ field.__class__.__name__ }} -->
@@ -97,8 +14,20 @@
**{'data-bs-toggle': 'tooltip',
'data-bs-placement': 'right',
'title': field.description}) }}
{% if field.flags.required %}
<span class="required-field-indicator" aria-hidden="true">
<i class="material-symbols-outlined required-icon">check_circle</i>
</span>
<span class="visually-hidden">Required field</span>
{% endif %}
{% else %}
{{ field.label(class="form-check-label") }}
{% if field.flags.required %}
<span class="required-field-indicator" aria-hidden="true">
<i class="material-symbols-outlined required-icon">check_circle</i>
</span>
<span class="visually-hidden">Required field</span>
{% endif %}
{% endif %}
</div>
{% if field.errors %}
@@ -116,8 +45,20 @@
**{'data-bs-toggle': 'tooltip',
'data-bs-placement': 'right',
'title': field.description}) }}
{% if field.flags.required %}
<span class="required-field-indicator" aria-hidden="true">
<i class="material-symbols-outlined required-icon">check_circle</i>
</span>
<span class="visually-hidden">Required field</span>
{% endif %}
{% else %}
{{ field.label(class="form-label") }}
{% if field.flags.required %}
<span class="required-field-indicator" aria-hidden="true">
<i class="material-symbols-outlined required-icon">check_circle</i>
</span>
<span class="visually-hidden">Required field</span>
{% endif %}
{% endif %}
{% if field.type == 'TextAreaField' and 'json-editor' in class %}
@@ -147,14 +88,67 @@
{% if field.type == 'BooleanField' %}
<div class="form-check">
{{ field(class="form-check-input", type="checkbox", id="flexSwitchCheckDefault") }}
{{ field.label(class="form-check-label", for="flexSwitchCheckDefault", disabled=disabled) }}
{% if field.description %}
{{ field.label(class="form-check-label",
for="flexSwitchCheckDefault",
disabled=disabled,
**{'data-bs-toggle': 'tooltip',
'data-bs-placement': 'right',
'title': field.description}) }}
{% if field.flags.required %}
<span class="required-field-indicator" aria-hidden="true">
<i class="material-symbols-outlined required-icon">check_circle</i>
</span>
<span class="visually-hidden">Required field</span>
{% endif %}
{% else %}
{{ field.label(class="form-check-label", for="flexSwitchCheckDefault", disabled=disabled) }}
{% if field.flags.required %}
<span class="required-field-indicator" aria-hidden="true">
<i class="material-symbols-outlined required-icon">check_circle</i>
</span>
<span class="visually-hidden">Required field</span>
{% endif %}
{% endif %}
</div>
{% else %}
<div class="form-group">
{{ field.label(class="form-label") }}
{{ field(class="form-control", disabled=disabled) }}
{% if field.description %}
<div class="field-label-wrapper">
{{ field.label(class="form-label",
**{'data-bs-toggle': 'tooltip',
'data-bs-placement': 'right',
'title': field.description}) }}
{% if field.flags.required %}
<span class="required-field-indicator" aria-hidden="true">
<i class="material-symbols-outlined required-icon">check_circle</i>
</span>
<span class="visually-hidden">Required field</span>
{% endif %}
</div>
{% else %}
<div class="field-label-wrapper">
{{ field.label(class="form-label") }}
{% if field.flags.required %}
<span class="required-field-indicator" aria-hidden="true">
<i class="material-symbols-outlined required-icon">check_circle</i>
</span>
<span class="visually-hidden">Required field</span>
{% endif %}
</div>
{% endif %}
{% if field.type == 'TextAreaField' and 'json-editor' in field.render_kw.get('class', '') %}
<div id="{{ field.id }}-editor" class="json-editor-container"></div>
{{ field(class="form-control d-none", disabled=disabled) }}
{% elif field.type == 'SelectField' %}
{{ field(class="form-control form-select", disabled=disabled) }}
{% else %}
{{ field(class="form-control", disabled=disabled) }}
{% endif %}
{% if field.errors %}
<div class="invalid-feedback">
<div class="invalid-feedback d-block">
{% for error in field.errors %}
{{ error }}
{% endfor %}

View File

@@ -75,6 +75,8 @@
{'name': 'Edit Tenant', 'url': '/user/tenant/' ~ session['tenant'].get('id'), 'roles': ['Super User', 'Tenant Admin']},
{'name': 'Tenant Domains', 'url': '/user/view_tenant_domains', 'roles': ['Super User', 'Tenant Admin']},
{'name': 'Tenant Domain Registration', 'url': '/user/tenant_domain', 'roles': ['Super User', 'Tenant Admin']},
{'name': 'Tenant Projects', 'url': '/user/tenant_projects', 'roles': ['Super User', 'Tenant Admin']},
{'name': 'Tenant Project Registration', 'url': '/user/tenant_project', 'roles': ['Super User', 'Tenant Admin']},
{'name': 'User List', 'url': '/user/view_users', 'roles': ['Super User', 'Tenant Admin']},
{'name': 'User Registration', 'url': '/user/user', 'roles': ['Super User', 'Tenant Admin']},
]) }}
@@ -107,6 +109,7 @@
{'name': 'License Tier Registration', 'url': '/entitlements/license_tier', 'roles': ['Super User']},
{'name': 'All License Tiers', 'url': '/entitlements/view_license_tiers', 'roles': ['Super User']},
{'name': 'Trigger Actions', 'url': '/administration/trigger_actions', 'roles': ['Super User']},
{'name': 'All Licenses', 'url': 'entitlements/view_licenses', 'roles': ['Super User', 'Tenant Admin']},
{'name': 'Usage', 'url': '/entitlements/view_usages', 'roles': ['Super User', 'Tenant Admin']},
]) }}
{% endif %}
@@ -122,17 +125,6 @@
{% endif %}
</ul>
{% if current_user.is_authenticated %}
<ul class="navbar-nav d-lg-block d-none">
<li class="nav-item">
<a href="/document/catalogs" class="btn btn-sm bg-gradient-primary mb-0 me-2">
{% if 'catalog_name' in session %}
CATALOG: {{ session['catalog_name'] }}
{% else %}
CHOOSE CATALOG
{% endif %}
</a>
</li>
</ul>
<ul class="navbar-nav d-lg-block d-none">
<li class="nav-item">
<a href="/session_defaults" class="btn btn-sm bg-gradient-primary mb-0">

View File

@@ -59,6 +59,78 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
</script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Get all forms with tabs
const formsWithTabs = document.querySelectorAll('form');
formsWithTabs.forEach(form => {
// Handle the form's submit event
form.addEventListener('submit', function(event) {
const invalidFields = form.querySelectorAll(':invalid');
if (invalidFields.length > 0) {
// Prevent form submission
event.preventDefault();
// Find which tab contains the first invalid field
const firstInvalidField = invalidFields[0];
const tabPane = firstInvalidField.closest('.tab-pane');
if (tabPane) {
// Get the tab ID
const tabId = tabPane.id;
// Find and click the corresponding tab button
const tabButton = document.querySelector(`[data-bs-toggle="tab"][data-bs-target="#${tabId}"]`);
if (tabButton) {
const tab = new bootstrap.Tab(tabButton);
tab.show();
}
// Scroll the invalid field into view and focus it
firstInvalidField.scrollIntoView({ behavior: 'smooth', block: 'center' });
firstInvalidField.focus();
}
// Optional: Show a message about validation errors
const errorCount = invalidFields.length;
const message = `Please fill in all required fields (${errorCount} ${errorCount === 1 ? 'error' : 'errors'} found)`;
if (typeof Swal !== 'undefined') {
// If SweetAlert2 is available
Swal.fire({
title: 'Validation Error',
text: message,
icon: 'error',
confirmButtonText: 'OK'
});
} else {
// Fallback to browser alert
alert(message);
}
}
});
// Optional: Real-time validation as user switches tabs
const tabButtons = document.querySelectorAll('[data-bs-toggle="tab"]');
tabButtons.forEach(button => {
button.addEventListener('shown.bs.tab', function() {
const previousTabPane = document.querySelector(button.getAttribute('data-bs-target'));
if (previousTabPane) {
const invalidFields = previousTabPane.querySelectorAll(':invalid');
if (invalidFields.length > 0) {
// Add visual indicator to tab
button.classList.add('has-error');
} else {
button.classList.remove('has-error');
}
}
});
});
});
});
</script>
<style>
.json-editor-container {

View File

@@ -0,0 +1,28 @@
{% extends 'base.html' %}
{% block title %}Delete Tenant Project{% endblock %}
{% block content_title %}Delete Tenant Project{% endblock %}
{% block content_description %}Are you sure you want to delete this tenant project?{% endblock %}
{% block content %}
<div class="container">
<div class="alert alert-warning">
<p><strong>Warning:</strong> You are about to delete the following tenant project:</p>
<ul>
<li><strong>Name:</strong> {{ tenant_project.name }}</li>
<li><strong>API Key:</strong> {{ tenant_project.visual_api_key }}</li>
<li><strong>Responsible:</strong> {{ tenant_project.responsible_email or 'Not specified' }}</li>
</ul>
<p>This action cannot be undone.</p>
</div>
<form method="POST">
{{ form.csrf_token if form }}
<div class="form-group mt-3">
<a href="{{ url_for('user_bp.tenant_projects') }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-danger">Confirm Delete</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,26 @@
{% extends 'base.html' %}
{% from "macros.html" import render_field %}
{% block title %}Edit Tenant Project{% endblock %}
{% block content_title %}Edit Tenant Project{% endblock %}
{% block content_description %}Edit a Tenant Project. It is impossible to view of renew the existing API key.
You need to invalidate the current project, and create a new one.
{% endblock %}
{% block content %}
<form method="post">
{{ form.hidden_tag() }}
{% set disabled_fields = [] %}
{% set exclude_fields = [] %}
<!-- Render Static Fields -->
{% for field in form %}
{{ render_field(field, disabled_fields, exclude_fields) }}
{% endfor %}
<button type="submit" class="btn btn-primary">Save Tenant Project</button>
</form>
{% endblock %}
{% block content_footer %}
{% endblock %}

View File

@@ -0,0 +1,23 @@
{% extends 'base.html' %}
{% from "macros.html" import render_field %}
{% block title %}Tenant Project Registration{% endblock %}
{% block content_title %}Register Tenant Project{% endblock %}
{% block content_description %}Define a new tenant project to enable APIs{% endblock %}
{% block content %}
<form method="post">
{{ form.hidden_tag() }}
{% set disabled_fields = [] %}
{% set exclude_fields = [] %}
{% for field in form %}
{{ render_field(field, disabled_fields, exclude_fields) }}
{% endfor %}
<button type="submit" class="btn btn-primary">Register Tenant Project</button>
</form>
{% endblock %}
{% block content_footer %}
{% endblock %}

View File

@@ -0,0 +1,25 @@
{% extends 'base.html' %}
{% from 'macros.html' import render_selectable_table, render_pagination %}
{% block title %}Documents{% endblock %}
{% block content_title %}Tenant Projects{% endblock %}
{% block content_description %}View Tenant Projects for Tenant{% endblock %}
{% block content_class %}<div class="col-xl-12 col-lg-5 col-md-7 mx-auto"></div>{% endblock %}
{% block content %}
<div class="container">
<form method="POST" action="{{ url_for("user_bp.handle_tenant_project_selection") }}">
{{ render_selectable_table(headers=["Tenant Project ID", "Name", "API Clue", "Responsible", "Active"], rows=rows, selectable=True, id="catalogsTable") }}
<div class="form-group mt-3">
<button type="submit" name="action" value="edit_tenant_project" class="btn btn-primary">Edit Tenant Project</button>
<button type="submit" name="action" value="invalidate_tenant_project" class="btn btn-primary">Invalidate Tenant Project</button>
<button type="submit" name="action" value="delete_tenant_project" class="btn btn-danger">Delete Tenant Project</button>
</div>
</form>
</div>
{% endblock %}
{% block content_footer %}
{{ render_pagination(pagination, "user_bp.tenant_projects") }}
{% endblock %}

View File

@@ -2,7 +2,12 @@ from flask import session
from flask_security import current_user
from flask_wtf import FlaskForm
from wtforms import StringField, SelectField
from wtforms.validators import DataRequired
from wtforms.validators import DataRequired, Optional
from wtforms_sqlalchemy.fields import QuerySelectField
from common.models.document import Catalog
from common.models.user import Tenant
from common.utils.database import Database
class SessionDefaultsForm(FlaskForm):
@@ -13,11 +18,32 @@ class SessionDefaultsForm(FlaskForm):
tenant_name = StringField('Tenant Name', validators=[DataRequired()])
default_language = SelectField('Default Language', choices=[], validators=[DataRequired()])
# Default Catalog - initialize as a regular SelectField
catalog = SelectField('Catalog', choices=[], validators=[Optional()])
def __init__(self):
super().__init__()
# Set basic fields first (these don't require DB access)
self.user_name.data = current_user.user_name
self.user_email.data = current_user.email
self.tenant_name.data = session.get('tenant').get('name')
self.default_language.choices = [(lang, lang.lower()) for lang in
session.get('tenant').get('allowed_languages')]
self.default_language.data = session.get('default_language')
self.default_language.data = session.get('default_language')
# Get a new session for catalog queries
tenant_id = session.get('tenant').get('id')
tenant_session = Database(tenant_id).get_session()
try:
# Populate catalog choices using tenant session
catalogs = tenant_session.query(Catalog).all()
self.catalog.choices = [(str(c.id), c.name) for c in catalogs]
self.catalog.choices.insert(0, ('', 'Select a Catalog')) # Add empty choice
# Set current catalog if exists
catalog_id = session.get('catalog_id')
if catalog_id:
self.catalog.data = str(catalog_id)
finally:
tenant_session.close()

View File

@@ -1,7 +1,11 @@
from flask import request, render_template, Blueprint, session, current_app, jsonify
from flask import request, render_template, Blueprint, session, current_app, jsonify, flash, redirect
from flask_security import roles_required, roles_accepted
from flask_wtf.csrf import generate_csrf
from common.models.document import Catalog
from common.models.user import Tenant
from common.utils.database import Database
from common.utils.nginx_utils import prefixed_url_for
from .basic_forms import SessionDefaultsForm
basic_bp = Blueprint('basic_bp', __name__)
@@ -9,7 +13,7 @@ basic_bp = Blueprint('basic_bp', __name__)
@basic_bp.before_request
def log_before_request():
pass
current_app.logger.debug(f'Before request: {request.path} =====================================')
@basic_bp.after_request
@@ -35,12 +39,39 @@ def confirm_email_fail():
@basic_bp.route('/session_defaults', methods=['GET', 'POST'])
@roles_accepted('Super User', 'Tenant Admin')
def session_defaults():
form = SessionDefaultsForm()
try:
# Get tenant session
tenant_id = session.get('tenant').get('id')
tenant_db = Database(tenant_id)
tenant_session = tenant_db.get_session()
if form.validate_on_submit():
session['default_language'] = form.default_language.data
try:
form = SessionDefaultsForm()
return render_template('basic/session_defaults.html', form=form)
if form.validate_on_submit():
session['default_language'] = form.default_language.data
if form.catalog.data:
catalog_id = int(form.catalog.data)
catalog = tenant_session.query(Catalog).get(catalog_id)
if catalog:
session['catalog_id'] = catalog.id
session['catalog_name'] = catalog.name
else:
session.pop('catalog_id', None)
session.pop('catalog_name', None)
flash('Session defaults updated successfully', 'success')
return redirect(prefixed_url_for('basic_bp.index'))
return render_template('basic/session_defaults.html', form=form)
finally:
tenant_session.close()
except Exception as e:
current_app.logger.error(f"Error in session_defaults: {str(e)}")
flash('Error accessing catalog data. Please ensure your session is valid.', 'danger')
return redirect(prefixed_url_for('security_bp.login'))
@basic_bp.route('/set_user_timezone', methods=['POST'])

View File

@@ -1,18 +1,17 @@
from flask import session, current_app, request
from flask import session, current_app
from flask_wtf import FlaskForm
from wtforms import (StringField, BooleanField, SubmitField, DateField, IntegerField, FloatField, SelectMultipleField,
SelectField, FieldList, FormField, TextAreaField, URLField)
from wtforms import (StringField, BooleanField, SubmitField, DateField, IntegerField, SelectField, TextAreaField, URLField)
from wtforms.validators import DataRequired, Length, Optional, URL, ValidationError, NumberRange
from flask_wtf.file import FileField, FileAllowed, FileRequired
from flask_wtf.file import FileField, FileRequired
import json
from wtforms_sqlalchemy.fields import QuerySelectField
from common.models.document import Catalog
from config.catalog_types import CATALOG_TYPES
from config.processor_types import PROCESSOR_TYPES
from config.retriever_types import RETRIEVER_TYPES
from config.type_defs.catalog_types import CATALOG_TYPES
from config.type_defs.processor_types import PROCESSOR_TYPES
from config.type_defs.retriever_types import RETRIEVER_TYPES
from .dynamic_form_base import DynamicFormBase
@@ -179,6 +178,7 @@ class EditRetrieverForm(DynamicFormBase):
class AddDocumentForm(DynamicFormBase):
file = FileField('File', validators=[FileRequired(), allowed_file])
catalog = StringField('Catalog', render_kw={'readonly': True})
sub_file_type = StringField('Sub File Type', validators=[Optional(), Length(max=50)])
name = StringField('Name', validators=[Length(max=100)])
language = SelectField('Language', choices=[], validators=[Optional()])
@@ -193,9 +193,12 @@ class AddDocumentForm(DynamicFormBase):
if not self.language.data:
self.language.data = session.get('tenant').get('default_language')
self.catalog.data = session.get('catalog_name', '')
class AddURLForm(DynamicFormBase):
url = URLField('URL', validators=[DataRequired(), URL()])
catalog = StringField('Catalog', render_kw={'readonly': True})
sub_file_type = StringField('Sub File Type', validators=[Optional(), Length(max=50)])
name = StringField('Name', validators=[Length(max=100)])
language = SelectField('Language', choices=[], validators=[Optional()])
@@ -210,22 +213,7 @@ class AddURLForm(DynamicFormBase):
if not self.language.data:
self.language.data = session.get('tenant').get('default_language')
class AddURLsForm(FlaskForm):
urls = TextAreaField('URL(s) (one per line)', validators=[DataRequired()])
name = StringField('Name Prefix', validators=[Length(max=100)])
language = SelectField('Language', choices=[], validators=[Optional()])
user_context = TextAreaField('User Context', validators=[Optional()])
valid_from = DateField('Valid from', id='form-control datepicker', validators=[Optional()])
submit = SubmitField('Submit')
def __init__(self):
super().__init__()
self.language.choices = [(language, language) for language in
session.get('tenant').get('allowed_languages')]
if not self.language.data:
self.language.data = session.get('tenant').get('default_language')
self.catalog.data = session.get('catalog_name', '')
class EditDocumentForm(FlaskForm):

View File

@@ -1,7 +1,6 @@
import ast
from datetime import datetime as dt, timezone as tz
from babel.messages.setuptools_frontend import update_catalog
from flask import request, redirect, flash, render_template, Blueprint, session, current_app
from flask_security import roles_accepted, current_user
from sqlalchemy import desc
@@ -10,35 +9,33 @@ from werkzeug.utils import secure_filename
from sqlalchemy.exc import SQLAlchemyError
import requests
from requests.exceptions import SSLError
from urllib.parse import urlparse, unquote
import io
import json
from common.models.document import Document, DocumentVersion, Catalog, Retriever, Processor
from common.extensions import db, minio_client
from common.extensions import db
from common.utils.document_utils import validate_file_type, create_document_stack, start_embedding_task, process_url, \
process_multiple_urls, get_documents_list, edit_document, \
edit_document, \
edit_document_version, refresh_document
from common.utils.eveai_exceptions import EveAIInvalidLanguageException, EveAIUnsupportedFileType, \
EveAIDoubleURLException
from config.processor_types import PROCESSOR_TYPES
from .document_forms import AddDocumentForm, AddURLForm, EditDocumentForm, EditDocumentVersionForm, AddURLsForm, \
from config.type_defs.processor_types import PROCESSOR_TYPES
from .document_forms import AddDocumentForm, AddURLForm, EditDocumentForm, EditDocumentVersionForm, \
CatalogForm, EditCatalogForm, RetrieverForm, EditRetrieverForm, ProcessorForm, EditProcessorForm
from common.utils.middleware import mw_before_request
from common.utils.celery_utils import current_celery
from common.utils.nginx_utils import prefixed_url_for
from common.utils.view_assistants import form_validation_failed, prepare_table_for_macro, form_to_dict
from common.utils.view_assistants import form_validation_failed, prepare_table_for_macro
from .document_list_view import DocumentListView
from .document_version_list_view import DocumentVersionListView
from config.catalog_types import CATALOG_TYPES
from config.retriever_types import RETRIEVER_TYPES
from config.type_defs.catalog_types import CATALOG_TYPES
from config.type_defs.retriever_types import RETRIEVER_TYPES
document_bp = Blueprint('document_bp', __name__, url_prefix='/document')
@document_bp.before_request
def log_before_request():
pass
current_app.logger.debug(f'Before request: {request.path} =====================================')
@document_bp.after_request
@@ -214,7 +211,7 @@ def edit_processor(processor_id):
db.session.add(processor)
db.session.commit()
flash('Retriever updated successfully!', 'success')
current_app.logger.info(f'Retriever {processor.id} updated successfully')
current_app.logger.info(f'Processor {processor.id} updated successfully')
except SQLAlchemyError as e:
db.session.rollback()
flash(f'Failed to update processor. Error: {str(e)}', 'danger')
@@ -376,7 +373,7 @@ def add_document():
form = AddDocumentForm(request.form)
catalog_id = session.get('catalog_id', None)
if catalog_id is None:
flash('You need to set a Session Catalog before adding Documents or URLs')
flash('You need to set a Session Catalog before adding Documents or URLs', 'warning')
return redirect(prefixed_url_for('document_bp.catalogs'))
catalog = Catalog.query.get_or_404(catalog_id)
@@ -434,7 +431,7 @@ def add_url():
form = AddURLForm(request.form)
catalog_id = session.get('catalog_id', None)
if catalog_id is None:
flash('You need to set a Session Catalog before adding Documents or URLs')
flash('You need to set a Session Catalog before adding Documents or URLs', 'warning')
return redirect(prefixed_url_for('document_bp.catalogs'))
catalog = Catalog.query.get_or_404(catalog_id)
@@ -547,6 +544,7 @@ def edit_document_view(document_id):
if form.validate_on_submit():
updated_doc, error = edit_document(
session.get('tenant').get('id', 0),
document_id,
form.name.data,
form.valid_from.data,
@@ -569,10 +567,8 @@ def edit_document_version_view(document_version_id):
doc_vers = DocumentVersion.query.get_or_404(document_version_id)
form = EditDocumentVersionForm(request.form, obj=doc_vers)
catalog_id = session.get('catalog_id', None)
if catalog_id is None:
flash('You need to set a Session Catalog before adding Documents or URLs')
return redirect(prefixed_url_for('document_bp.catalogs'))
doc_vers = DocumentVersion.query.get_or_404(document_version_id)
catalog_id = doc_vers.document.catalog_id
catalog = Catalog.query.get_or_404(catalog_id)
if catalog.configuration and len(catalog.configuration) > 0:
@@ -587,6 +583,7 @@ def edit_document_version_view(document_version_id):
catalog_properties[config] = form.get_dynamic_data(config)
updated_version, error = edit_document_version(
session.get('tenant').get('id', 0),
document_version_id,
form.user_context.data,
catalog_properties,

View File

@@ -35,7 +35,7 @@ def license_tier():
return render_template('entitlements/license_tier.html', form=form)
current_app.logger.info(f"Successfully created license tier {new_license_tier.id}")
flash(f"Successfully created tenant license tier {new_license_tier.id}")
flash(f"Successfully created tenant license tier {new_license_tier.id}", 'success')
return redirect(prefixed_url_for('entitlements_bp.view_license_tiers'))
else:
@@ -232,3 +232,63 @@ def view_usages():
# Render the users in a template
return render_template('entitlements/view_usages.html', rows=rows, pagination=pagination)
@entitlements_bp.route('/handle_usage_selection', methods=['POST'])
@roles_accepted('Super User', 'Tenant Admin')
def handle_usage_selection():
usage_identification = request.form['selected_row']
usage_id = ast.literal_eval(usage_identification).get('value')
the_usage = LicenseUsage.query.get_or_404(usage_id)
action = request.form['action']
pass # Currently, no actions are defined
@entitlements_bp.route('/view_licenses')
@roles_accepted('Super User', 'Tenant Admin')
def view_licenses():
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 10, type=int)
tenant_id = session.get('tenant').get('id')
# Get current date in UTC
current_date = dt.now(tz=tz.utc).date()
# Query licenses for the tenant, with ordering and active status
query = (
License.query.filter_by(tenant_id=tenant_id)
.add_columns(
License.id,
License.start_date,
License.end_date,
License.license_tier,
License.license_tier.name.label('license_tier_name'),
((License.start_date <= current_date) &
(or_(License.end_date.is_(None), License.end_date >= current_date))).label('active')
)
.order_by(License.start_date.desc())
)
pagination = query.paginate(page=page, per_page=per_page)
lics = pagination.items
# prepare table data
rows = prepare_table_for_macro(lics, [('id', ''), ('license_tier_name', ''), ('start_date', ''), ('end_date', ''),
('active', ''),])
# Render the licenses in a template
return render_template('entitlements/view_licenses.html', rows=rows, pagination=pagination)
@entitlements_bp.route('/handle_license_selection', methods=['POST'])
@roles_accepted('Super User', 'Tenant Admin')
def handle_license_selection():
license_identification = request.form['selected_row']
license_id = ast.literal_eval(license_identification).get('value')
the_license = LicenseUsage.query.get_or_404(license_id)
action = request.form['action']
pass # Currently, no actions are defined

View File

@@ -1,17 +1,12 @@
from flask import session, current_app, request
from flask_wtf import FlaskForm
from wtforms import (StringField, BooleanField, SubmitField, DateField, IntegerField, FloatField, SelectMultipleField,
SelectField, FieldList, FormField, TextAreaField, URLField)
from wtforms.validators import DataRequired, Length, Optional, URL, ValidationError, NumberRange
from flask_wtf.file import FileField, FileAllowed, FileRequired
import json
from wtforms import (StringField, BooleanField, SelectField, TextAreaField)
from wtforms.validators import DataRequired, Length
from wtforms_sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
from wtforms_sqlalchemy.fields import QuerySelectMultipleField
from common.models.document import Retriever
from config.catalog_types import CATALOG_TYPES
from config.specialist_types import SPECIALIST_TYPES
from config.type_defs.specialist_types import SPECIALIST_TYPES
from .dynamic_form_base import DynamicFormBase

View File

@@ -1,28 +1,17 @@
import ast
import os
from datetime import datetime as dt, timezone as tz
import chardet
from flask import request, redirect, flash, render_template, Blueprint, session, current_app
from flask_security import roles_accepted, current_user
from flask_security import roles_accepted
from sqlalchemy import desc
from sqlalchemy.orm import joinedload
from werkzeug.datastructures import FileStorage
from werkzeug.utils import secure_filename
from sqlalchemy.exc import SQLAlchemyError
import requests
from requests.exceptions import SSLError
from urllib.parse import urlparse
import io
from common.models.document import Embedding, DocumentVersion, Retriever
from common.models.interaction import ChatSession, Interaction, InteractionEmbedding, Specialist, SpecialistRetriever
from common.extensions import db
from common.utils.document_utils import set_logging_information, update_logging_information
from config.specialist_types import SPECIALIST_TYPES
from .document_forms import AddDocumentForm, AddURLForm, EditDocumentForm, EditDocumentVersionForm
from config.type_defs.specialist_types import SPECIALIST_TYPES
from common.utils.middleware import mw_before_request
from common.utils.celery_utils import current_celery
from common.utils.nginx_utils import prefixed_url_for
from common.utils.view_assistants import form_validation_failed, prepare_table_for_macro
from .interaction_forms import SpecialistForm, EditSpecialistForm
@@ -32,7 +21,7 @@ interaction_bp = Blueprint('interaction_bp', __name__, url_prefix='/interaction'
@interaction_bp.before_request
def log_before_request():
pass
current_app.logger.debug(f'Before request: {request.path} =====================================')
@interaction_bp.after_request

View File

@@ -11,11 +11,12 @@ from itsdangerous import URLSafeTimedSerializer
from sqlalchemy.exc import SQLAlchemyError
from common.models.user import User
from common.utils.eveai_exceptions import EveAIException
from common.utils.nginx_utils import prefixed_url_for
from eveai_app.views.security_forms import SetPasswordForm, ResetPasswordForm, RequestResetForm
from common.extensions import db
from common.utils.security_utils import confirm_token, send_confirmation_email, send_reset_email
from common.utils.security import set_tenant_session_data
from common.utils.security import set_tenant_session_data, is_valid_tenant
security_bp = Blueprint('security_bp', __name__)
@@ -40,11 +41,15 @@ def login():
if request.method == 'POST':
try:
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first()
if user is None or not verify_and_update_password(form.password.data, user):
flash('Invalid username or password', 'danger')
current_app.logger.error(f'Failed to login user')
return redirect(prefixed_url_for('security_bp.login'))
try:
user = User.query.filter_by(email=form.email.data).first()
if user is None or not verify_and_update_password(form.password.data, user):
raise EveAIException('Invalid email or password')
is_valid_tenant(user.tenant_id)
except EveAIException as e:
flash(f'Failed to login user: {str(e)}', 'danger')
current_app.logger.error(f'Failed to login user: {str(e)}')
abort(401)
if login_user(user):
current_app.logger.info(f'Login successful! Current User is {current_user.email}')
@@ -55,7 +60,7 @@ def login():
return redirect(prefixed_url_for('user_bp.tenant_overview'))
else:
flash('Invalid username or password', 'danger')
current_app.logger.error(f'Failed to login user {user.email}')
current_app.logger.error(f'Invalid username or password for given email: {user.email}')
abort(401)
else:
current_app.logger.error(f'Invalid login form: {form.errors}')

View File

@@ -6,51 +6,24 @@ from wtforms.validators import DataRequired, Length, Email, NumberRange, Optiona
import pytz
from common.models.user import Role
from config.type_defs.service_types import SERVICE_TYPES
class TenantForm(FlaskForm):
name = StringField('Name', validators=[DataRequired(), Length(max=80)])
type = SelectField('Tenant Type', validators=[Optional()], default='Active')
website = StringField('Website', validators=[DataRequired(), Length(max=255)])
# language fields
default_language = SelectField('Default Language', choices=[], validators=[DataRequired()])
allowed_languages = SelectMultipleField('Allowed Languages', choices=[], validators=[DataRequired()])
# invoicing fields
currency = SelectField('Currency', choices=[], validators=[DataRequired()])
usage_email = EmailField('Usage Email', validators=[DataRequired(), Email()])
# Timezone
timezone = SelectField('Timezone', choices=[], validators=[DataRequired()])
# RAG context
rag_context = TextAreaField('RAG Context', validators=[Optional()])
# Tenant Type
type = SelectField('Tenant Type', validators=[Optional()], default='Active')
# LLM fields
embedding_model = SelectField('Embedding Model', choices=[], validators=[DataRequired()])
llm_model = SelectField('Large Language Model', choices=[], validators=[DataRequired()])
# Embedding variables
html_tags = StringField('HTML Tags', validators=[DataRequired()],
default='p, h1, h2, h3, h4, h5, h6, li')
html_end_tags = StringField('HTML End Tags', validators=[DataRequired()],
default='p, li')
html_included_elements = StringField('HTML Included Elements', validators=[Optional()])
html_excluded_elements = StringField('HTML Excluded Elements', validators=[Optional()])
html_excluded_classes = StringField('HTML Excluded Classes', validators=[Optional()])
min_chunk_size = IntegerField('Minimum Chunk Size (2000)', validators=[NumberRange(min=0), Optional()], default=2000)
max_chunk_size = IntegerField('Maximum Chunk Size (3000)', validators=[NumberRange(min=0), Optional()], default=3000)
# Embedding Search variables
es_k = IntegerField('Limit for Searching Embeddings (5)',
default=5,
validators=[NumberRange(min=0)])
es_similarity_threshold = FloatField('Similarity Threshold for Searching Embeddings (0.5)',
default=0.5,
validators=[NumberRange(min=0, max=1)])
# Chat Variables
chat_RAG_temperature = FloatField('RAG Temperature', default=0.3, validators=[NumberRange(min=0, max=1)])
chat_no_RAG_temperature = FloatField('No RAG Temperature', default=0.5, validators=[NumberRange(min=0, max=1)])
fallback_algorithms = SelectMultipleField('Fallback Algorithms', choices=[], validators=[Optional()])
# Tuning variables
embed_tuning = BooleanField('Enable Embedding Tuning', default=False)
rag_tuning = BooleanField('Enable RAG Tuning', default=False)
submit = SubmitField('Submit')
def __init__(self, *args, **kwargs):
@@ -66,8 +39,6 @@ class TenantForm(FlaskForm):
self.embedding_model.choices = [(model, model) for model in current_app.config['SUPPORTED_EMBEDDINGS']]
self.llm_model.choices = [(model, model) for model in current_app.config['SUPPORTED_LLMS']]
# Initialize fallback algorithms
self.fallback_algorithms.choices = \
[(algorithm, algorithm.lower()) for algorithm in current_app.config['FALLBACK_ALGORITHMS']]
self.type.choices = [(t, t) for t in current_app.config['TENANT_TYPES']]
@@ -79,6 +50,8 @@ class BaseUserForm(FlaskForm):
valid_to = DateField('Valid to', id='form-control datepicker', validators=[Optional()])
tenant_id = IntegerField('Tenant ID', validators=[NumberRange(min=0)])
roles = SelectMultipleField('Roles', coerce=int)
is_primary_contact = BooleanField('Primary Contact')
is_financial_contact = BooleanField('Financial Contact')
def __init__(self, *args, **kwargs):
super(BaseUserForm, self).__init__(*args, **kwargs)
@@ -90,6 +63,12 @@ class CreateUserForm(BaseUserForm):
class EditUserForm(BaseUserForm):
# Some R/O informational fields
confirmed_at = DateField('Confirmed At', id='form-control datepicker', validators=[Optional()],
render_kw={'readonly': True})
last_login_at = DateField('Last Login At', id='form-control datepicker', validators=[Optional()],
render_kw={'readonly': True})
login_count = IntegerField('Login Count', validators=[Optional()], render_kw={'readonly': True})
submit = SubmitField('Save User')
@@ -121,4 +100,30 @@ class TenantSelectionForm(FlaskForm):
self.types.choices = [(t, t) for t in current_app.config['TENANT_TYPES']]
class TenantProjectForm(FlaskForm):
name = StringField('Name', validators=[DataRequired(), Length(max=50)])
description = TextAreaField('Description', validators=[Optional()])
services = SelectMultipleField('Allowed Services', choices=[], validators=[DataRequired()])
unencrypted_api_key = StringField('Unencrypted API Key', validators=[DataRequired()], render_kw={'readonly': True})
visual_api_key = StringField('Visual API Key', validators=[DataRequired()], render_kw={'readonly': True})
active = BooleanField('Active', validators=[DataRequired()], default=True)
responsible_email = EmailField('Responsible Email', validators=[Optional(), Email()])
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Initialize choices for the services field
self.services.choices = [(key, value['description']) for key, value in SERVICE_TYPES.items()]
class EditTenantProjectForm(FlaskForm):
name = StringField('Name', validators=[DataRequired(), Length(max=50)])
description = TextAreaField('Description', validators=[Optional()])
services = SelectMultipleField('Allowed Services', choices=[], validators=[DataRequired()])
visual_api_key = StringField('Visual API Key', validators=[DataRequired()], render_kw={'readonly': True})
active = BooleanField('Active', validators=[DataRequired()], default=True)
responsible_email = EmailField('Responsible Email', validators=[Optional(), Email()])
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Initialize choices for the services field
self.services.choices = [(key, value['description']) for key, value in SERVICE_TYPES.items()]

View File

@@ -2,15 +2,18 @@
import uuid
from datetime import datetime as dt, timezone as tz
from flask import request, redirect, flash, render_template, Blueprint, session, current_app, jsonify
from flask_mailman import EmailMessage
from flask_security import hash_password, roles_required, roles_accepted, current_user
from itsdangerous import URLSafeTimedSerializer
from sqlalchemy.exc import SQLAlchemyError
import ast
from common.models.user import User, Tenant, Role, TenantDomain
from common.models.user import User, Tenant, Role, TenantDomain, TenantProject
from common.extensions import db, security, minio_client, simple_encryption
from common.utils.security_utils import send_confirmation_email, send_reset_email
from .user_forms import TenantForm, CreateUserForm, EditUserForm, TenantDomainForm, TenantSelectionForm
from config.type_defs.service_types import SERVICE_TYPES
from .user_forms import TenantForm, CreateUserForm, EditUserForm, TenantDomainForm, TenantSelectionForm, \
TenantProjectForm, EditTenantProjectForm
from common.utils.database import Database
from common.utils.view_assistants import prepare_table_for_macro, form_validation_failed
from common.utils.simple_encryption import generate_api_key
@@ -21,7 +24,7 @@ user_bp = Blueprint('user_bp', __name__, url_prefix='/user')
@user_bp.before_request
def log_before_request():
pass
current_app.logger.debug(f'Before request: {request.path} =====================================')
@user_bp.after_request
@@ -33,7 +36,9 @@ def log_after_request(response):
@roles_required('Super User')
def tenant():
form = TenantForm()
current_app.logger.debug(f'Tenant form: {form}')
if form.validate_on_submit():
current_app.logger.debug(f'Tenant form submitted: {form.data}')
# Handle the required attributes
new_tenant = Tenant()
form.populate_obj(new_tenant)
@@ -53,7 +58,7 @@ def tenant():
return render_template('user/tenant.html', form=form)
current_app.logger.info(f"Successfully created tenant {new_tenant.id} in Database")
flash(f"Successfully created tenant {new_tenant.id} in Database")
flash(f"Successfully created tenant {new_tenant.id} in Database", 'success')
# Create schema for new tenant
current_app.logger.info(f"Creating schema for tenant {new_tenant.id}")
@@ -442,6 +447,163 @@ def tenant_overview():
return render_template('user/tenant_overview.html', form=form)
@user_bp.route('/tenant_project', methods=['GET', 'POST'])
@roles_accepted('Super User', 'Tenant Admin')
def tenant_project():
form = TenantProjectForm()
if request.method == 'GET':
# Initialize the API key
new_api_key = generate_api_key(prefix="EveAI")
form.unencrypted_api_key.data = new_api_key
form.visual_api_key.data = f"EVEAI-...{new_api_key[-4:]}"
if form.validate_on_submit():
new_tenant_project = TenantProject()
form.populate_obj(new_tenant_project)
new_tenant_project.tenant_id = session['tenant']['id']
new_tenant_project.encrypted_api_key = simple_encryption.encrypt_api_key(new_tenant_project.unencrypted_api_key)
set_logging_information(new_tenant_project, dt.now(tz.utc))
# Add new Tenant Project to the database
try:
db.session.add(new_tenant_project)
db.session.commit()
# Send email notification
services = [SERVICE_TYPES[service]['name']
for service in form.services.data
if service in SERVICE_TYPES]
email_sent = send_api_key_notification(
tenant_id=session['tenant']['id'],
tenant_name=session['tenant']['name'],
project_name=new_tenant_project.name,
api_key=new_tenant_project.unencrypted_api_key,
services=services,
responsible_email=form.responsible_email.data
)
if email_sent:
flash('Tenant Project created successfully and notification email sent.', 'success')
else:
flash('Tenant Project created successfully but failed to send notification email.', 'warning')
current_app.logger.info(f'Tenant Project {new_tenant_project.name} added for tenant '
f'{session['tenant']['id']}.')
return redirect(prefixed_url_for('user_bp.tenant_projects'))
except SQLAlchemyError as e:
db.session.rollback()
flash(f'Failed to create Tenant Project. Error: {str(e)}', 'danger')
current_app.logger.error(f"Failed to create Tenant Project for tenant {session['tenant']['id']}. "
f"Error: {str(e)}")
return render_template('user/tenant_project.html', form=form)
@user_bp.route('/tenant_projects', methods=['GET', 'POST'])
@roles_accepted('Super User', 'Tenant Admin')
def tenant_projects():
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 10, type=int)
tenant_id = session['tenant']['id']
query = TenantProject.query.filter_by(tenant_id=tenant_id).order_by(TenantProject.id)
pagination = query.paginate(page=page, per_page=per_page)
the_tenant_projects = pagination.items
# prepare table data
rows = prepare_table_for_macro(the_tenant_projects, [('id', ''), ('name', ''), ('visual_api_key', ''),
('responsible_email', ''), ('active', '')])
# Render the catalogs in a template
return render_template('user/tenant_projects.html', rows=rows, pagination=pagination)
@user_bp.route('/handle_tenant_project_selection', methods=['POST'])
@roles_accepted('Super User', 'Tenant Admin')
def handle_tenant_project_selection():
tenant_project_identification = request.form.get('selected_row')
tenant_project_id = ast.literal_eval(tenant_project_identification).get('value')
action = request.form.get('action')
tenant_project = TenantProject.query.get_or_404(tenant_project_id)
if action == 'edit_tenant_project':
return redirect(prefixed_url_for('user_bp.edit_tenant_project', tenant_project_id=tenant_project_id))
elif action == 'invalidate_tenant_project':
tenant_project.active = False
try:
db.session.add(tenant_project)
db.session.commit()
flash('Tenant Project invalidated successfully.', 'success')
current_app.logger.info(f'Tenant Project {tenant_project.name} invalidated for tenant '
f'{session['tenant']['id']}.')
except SQLAlchemyError as e:
db.session.rollback()
flash(f'Failed to invalidate Tenant Project {tenant_project.name}. Error: {str(e)}', 'danger')
current_app.logger.error(f"Failed to invalidate Tenant Project for tenant {session['tenant']['id']}. "
f"Error: {str(e)}")
elif action == 'delete_tenant_project':
return redirect(prefixed_url_for('user_bp.delete_tenant_project', tenant_project_id=tenant_project_id))
return redirect(prefixed_url_for('user_bp.tenant_projects'))
@user_bp.route('/tenant_project/<int:tenant_project_id>', methods=['GET','POST'])
@roles_accepted('Super User', 'Tenant Admin')
def edit_tenant_project(tenant_project_id):
tenant_project = TenantProject.query.get_or_404(tenant_project_id)
tenant_id = session['tenant']['id']
form = EditTenantProjectForm(obj=tenant_project)
if form.validate_on_submit():
form.populate_obj(tenant_project)
update_logging_information(tenant_project, dt.now(tz.utc))
try:
db.session.add(tenant_project)
db.session.commit()
flash('Tenant Project updated successfully.', 'success')
current_app.logger.info(f'Tenant Project {tenant_project.name} updated for tenant {tenant_id}.')
return redirect(prefixed_url_for('user_bp.tenant_projects'))
except SQLAlchemyError as e:
db.session.rollback()
flash(f'Failed to update Tenant Project. Error: {str(e)}', 'danger')
current_app.logger.error(f"Failed to update Tenant Project {tenant_project.name} for tenant {tenant_id}. ")
return render_template('user/edit_tenant.html', form=form, tenant_project_id=tenant_project_id)
@user_bp.route('/tenant_project/delete/<int:tenant_project_id>', methods=['GET', 'POST'])
@roles_accepted('Super User', 'Tenant Admin')
def delete_tenant_project(tenant_project_id):
tenant_id = session['tenant']['id']
tenant_project = TenantProject.query.get_or_404(tenant_project_id)
# Ensure project belongs to current tenant
if tenant_project.tenant_id != tenant_id:
flash('You do not have permission to delete this project.', 'danger')
return redirect(prefixed_url_for('user_bp.tenant_projects'))
if request.method == 'GET':
return render_template('user/confirm_delete_tenant_project.html',
tenant_project=tenant_project)
try:
project_name = tenant_project.name
db.session.delete(tenant_project)
db.session.commit()
flash(f'Tenant Project "{project_name}" successfully deleted.', 'success')
current_app.logger.info(f'Tenant Project {project_name} deleted for tenant {tenant_id}')
except SQLAlchemyError as e:
db.session.rollback()
flash(f'Failed to delete Tenant Project. Error: {str(e)}', 'danger')
current_app.logger.error(f'Failed to delete Tenant Project {tenant_project_id}. Error: {str(e)}')
return redirect(prefixed_url_for('user_bp.tenant_projects'))
def reset_uniquifier(user):
security.datastore.set_uniquifier(user)
db.session.add(user)
@@ -459,3 +621,64 @@ def set_logging_information(obj, timestamp):
def update_logging_information(obj, timestamp):
obj.updated_at = timestamp
obj.updated_by = current_user.id
def get_notification_email(tenant_id, user_email=None):
"""
Determine which email address to use for notification.
Priority: Provided email > Primary contact > Default email
"""
if user_email:
return user_email
# Try to find primary contact
primary_contact = User.query.filter_by(
tenant_id=tenant_id,
is_primary_contact=True
).first()
if primary_contact:
return primary_contact.email
return "pieter@askeveai.com"
def send_api_key_notification(tenant_id, tenant_name, project_name, api_key, services, responsible_email=None):
"""
Send API key notification email
"""
recipient_email = get_notification_email(tenant_id, responsible_email)
# Prepare email content
context = {
'tenant_id': tenant_id,
'tenant_name': tenant_name,
'project_name': project_name,
'api_key': api_key,
'services': services,
'year': dt.now(tz.utc).year,
'promo_image_url': current_app.config.get('PROMOTIONAL_IMAGE_URL',
'https://static.askeveai.com/promo/default.jpg')
}
try:
# Create email message
msg = EmailMessage(
subject='Your new API-key from Ask Eve AI (Evie)',
body=render_template('email/api_key_notification.html', **context),
from_email=current_app.config['MAIL_DEFAULT_SENDER'],
to=[recipient_email]
)
# Set HTML content type
msg.content_subtype = "html"
# Send email
msg.send()
current_app.logger.info(f"API key notification sent to {recipient_email} for tenant {tenant_id}")
return True
except Exception as e:
current_app.logger.error(f"Failed to send API key notification email: {str(e)}")
return False