- 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

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