Introduction of Partner Model, adding code to Tenant model

This commit is contained in:
Josako
2025-04-03 14:07:23 +02:00
parent 1762b930bc
commit 9ad7c1aee9
93 changed files with 823 additions and 22 deletions

View File

@@ -20,6 +20,7 @@ class Tenant(db.Model):
# company Information
id = db.Column(db.Integer, primary_key=True)
code = db.Column(db.String(50), unique=True, nullable=True)
name = db.Column(db.String(80), unique=True, nullable=False)
website = db.Column(db.String(255), nullable=True)
timezone = db.Column(db.String(50), nullable=True, default='UTC')
@@ -99,6 +100,7 @@ class User(db.Model, UserMixin):
# User Information
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey('public.tenant.id'), nullable=False)
user_name = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(255), unique=True, nullable=False)
password = db.Column(db.String(255), nullable=True)
@@ -120,7 +122,6 @@ class User(db.Model, UserMixin):
# Relations
roles = db.relationship('Role', secondary=RolesUsers.__table__, backref=db.backref('users', lazy='dynamic'))
tenant_id = db.Column(db.Integer, db.ForeignKey('public.tenant.id'), nullable=False)
def __repr__(self):
return '<User %r>' % self.user_name
@@ -176,3 +177,91 @@ class TenantProject(db.Model):
def __repr__(self):
return f"<TenantProject {self.id}: {self.name}>"
class Partner(db.Model):
__bind_key__ = 'public'
__table_args__ = {'schema': 'public'}
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey('public.tenant.id'), nullable=False, unique=True)
code = db.Column(db.String(50), unique=True, nullable=False)
# Basic information
logo_url = db.Column(db.String(255), nullable=True)
active = db.Column(db.Boolean, default=True)
# Versioning Information
created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now())
created_by = db.Column(db.Integer, db.ForeignKey('public.user.id'), nullable=True)
updated_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now(), onupdate=db.func.now())
updated_by = db.Column(db.Integer, db.ForeignKey('public.user.id'), nullable=True)
# Relationships
services = db.relationship('PartnerService', back_populates='partner')
tenant = db.relationship('Tenant', backref=db.backref('partner', uselist=False))
def to_dict(self):
return {
'id': self.id,
'tenant_id': self.tenant_id,
'code': self.code,
'logo_url': self.logo_url,
'active': self.active,
'name': self.tenant.name
}
class PartnerService(db.Model):
__bind_key__ = 'public'
__table_args__ = {'schema': 'public'}
id = db.Column(db.Integer, primary_key=True)
partner_id = db.Column(db.Integer, db.ForeignKey('public.partner.id'), nullable=False)
# Basic info
name = db.Column(db.String(50), nullable=False)
description = db.Column(db.Text, nullable=True)
# Service type with versioning (similar to your specialist/retriever pattern)
type = db.Column(db.String(50), nullable=False) # REFERRAL, KNOWLEDGE, SPECIALIST, IMPLEMENTATION, WHITE_LABEL
type_version = db.Column(db.String(20), nullable=False, default="1.0.0")
# Status
active = db.Column(db.Boolean, default=True)
# Dynamic configuration specific to this service - using JSONB like your other models
configuration = db.Column(db.JSON, nullable=True)
# For services that need to track shared resources
system_metadata = db.Column(db.JSON, nullable=True)
user_metadata = db.Column(db.JSON, nullable=True)
# Relationships
partner = db.relationship('Partner', back_populates='services')
# Versioning Information
created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now())
created_by = db.Column(db.Integer, db.ForeignKey('public.user.id'), nullable=True)
updated_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now(), onupdate=db.func.now())
updated_by = db.Column(db.Integer, db.ForeignKey('public.user.id'), nullable=True)
class PartnerTenant(db.Model):
__bind_key__ = 'public'
__table_args__ = {'schema': 'public'}
partner_service_id = db.Column(db.Integer, db.ForeignKey('public.partner_service.id'), primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey('public.tenant.id'), primary_key=True)
# Relationship type
relationship_type = db.Column(db.String(20), nullable=False) # REFERRED, MANAGED, WHITE_LABEL
# JSONB for flexible configuration specific to this relationship
configuration = db.Column(db.JSON, nullable=True)
# Tracking
created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now())
created_by = db.Column(db.Integer, db.ForeignKey('public.user.id'), nullable=True)
updated_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now(), onupdate=db.func.now())
updated_by = db.Column(db.Integer, db.ForeignKey('public.user.id'), nullable=True)

View File

@@ -7,7 +7,7 @@ from flask import current_app
from common.utils.cache.base import CacheHandler, CacheKey
from config.type_defs import agent_types, task_types, tool_types, specialist_types, retriever_types, prompt_types, \
catalog_types
catalog_types, partner_service_types
def is_major_minor(version: str) -> bool:
@@ -422,7 +422,6 @@ PromptConfigCacheHandler, PromptConfigVersionTreeCacheHandler, PromptConfigTypes
config_type='prompts',
config_dir='config/prompts',
types_module=prompt_types.PROMPT_TYPES
))
CatalogConfigCacheHandler, CatalogConfigVersionTreeCacheHandler, CatalogConfigTypesCacheHandler = (
@@ -430,7 +429,14 @@ CatalogConfigCacheHandler, CatalogConfigVersionTreeCacheHandler, CatalogConfigTy
config_type='catalogs',
config_dir='config/catalogs',
types_module=catalog_types.CATALOG_TYPES
))
# Add to common/utils/cache/config_cache.py
PartnerServiceConfigCacheHandler, PartnerServiceConfigVersionTreeCacheHandler, PartnerServiceConfigTypesCacheHandler = (
create_config_cache_handlers(
config_type='partner_services',
config_dir='config/partner_services',
types_module=partner_service_types.PARTNER_SERVICE_TYPES
))
@@ -459,6 +465,9 @@ def register_config_cache_handlers(cache_manager) -> None:
cache_manager.register_handler(AgentConfigCacheHandler, 'eveai_config')
cache_manager.register_handler(AgentConfigTypesCacheHandler, 'eveai_config')
cache_manager.register_handler(AgentConfigVersionTreeCacheHandler, 'eveai_config')
cache_manager.register_handler(PartnerServiceConfigCacheHandler, 'eveai_config')
cache_manager.register_handler(PartnerServiceConfigTypesCacheHandler, 'eveai_config')
cache_manager.register_handler(PartnerServiceConfigVersionTreeCacheHandler, 'eveai_config')
cache_manager.agents_config_cache.set_version_tree_cache(cache_manager.agents_version_tree_cache)
cache_manager.tasks_config_cache.set_version_tree_cache(cache_manager.tasks_version_tree_cache)
@@ -466,3 +475,4 @@ def register_config_cache_handlers(cache_manager) -> None:
cache_manager.specialists_config_cache.set_version_tree_cache(cache_manager.specialists_version_tree_cache)
cache_manager.retrievers_config_cache.set_version_tree_cache(cache_manager.retrievers_version_tree_cache)
cache_manager.prompts_config_cache.set_version_tree_cache(cache_manager.prompts_version_tree_cache)
cache_manager.partner_services_config_cache.set_version_tree_cache(cache_manager.partner_services_version_tree_cache)

View File

@@ -136,3 +136,14 @@ class EveAIInvalidEmbeddingModel(EveAIException):
# Construct the message dynamically
message = f"Tenant with ID '{tenant_id}' has no or an invalid embedding model in Catalog {catalog_id}."
super().__init__(message, status_code, payload)
class EveAIDoublePartner(EveAIException):
"""Raised when there is already a partner defined for a given tenant (while registering a partner)"""
def __init__(self, tenant_id, status_code=400, payload=None):
self.tenant_id = tenant_id
# Construct the message dynamically
message = f"Tenant with ID '{tenant_id}' is already defined as a Partner."
super().__init__(message, status_code, payload)

View File

@@ -0,0 +1,11 @@
import json
from wtforms.validators import ValidationError
def validate_json(form, field):
if field.data:
try:
json.loads(field.data)
except json.JSONDecodeError:
raise ValidationError('Invalid JSON format')

57
common/utils/log_utils.py Normal file
View File

@@ -0,0 +1,57 @@
import pandas as pd
from sqlalchemy import inspect
from typing import Any, List, Union, Optional
def format_query_results(query_results: Any) -> str:
"""
Format query results as a readable string using pandas
Args:
query_results: SQLAlchemy query, query results, or model instance(s)
Returns:
Formatted string representation of the query results
"""
try:
# If it's a query object, execute it
if hasattr(query_results, 'all'):
results = query_results.all()
elif not isinstance(query_results, list):
results = [query_results]
else:
results = query_results
# Handle different types of results
if results and hasattr(results[0], '__table__'):
# SQLAlchemy ORM objects
data = []
for item in results:
row = {}
for column in inspect(item).mapper.column_attrs:
row[column.key] = getattr(item, column.key)
data.append(row)
df = pd.DataFrame(data)
elif results and isinstance(results[0], tuple):
# Join query results (tuples)
if hasattr(results[0], '_fields'): # Named tuples
df = pd.DataFrame(results)
else:
# Regular tuples - try to get column names from query
if hasattr(query_results, 'statement'):
columns = query_results.statement.columns.keys()
df = pd.DataFrame(results, columns=columns)
else:
df = pd.DataFrame(results)
else:
# Fallback for other types
df = pd.DataFrame(results)
# Format the output with pandas
with pd.option_context('display.max_rows', 20, 'display.max_columns', None,
'display.width', 1000):
formatted_output = f"Query returned {len(df)} results:\n{df}"
return formatted_output
except Exception as e:
return f"Error formatting query results: {str(e)}"

View File

@@ -44,4 +44,5 @@ def perform_startup_invalidation(app):
except Exception as e:
app.logger.error(f"Error during startup invalidation: {e}")
# In case of error, we don't want to block the application startup
pass
pass

View File

@@ -0,0 +1,15 @@
version: "1.0.0"
name: "Management Service"
configuration:
admin_roles:
name: "Administrative Roles"
type: "enum"
description: "Administrative Roles that can be assigned to the partner"
required: true
allowed_values: ["Partner Admin"]
default: "Partner Admin"
metadata:
author: "Josako"
date_added: "2025-04-02"
changes: "Initial version"
description: "Initial definition of the management service"

View File

@@ -0,0 +1,24 @@
# config/type_defs/partner_service_types.py
PARTNER_SERVICE_TYPES = {
"REFERRAL_SERVICE": {
"name": "Referral Service",
"description": "Partner referring new customers",
},
"KNOWLEDGE_SERVICE": {
"name": "Knowledge Service",
"description": "Partner providing catalog content",
},
"SPECIALIST_SERVICE": {
"name": "Specialist Service",
"description": "Partner providing specialist solutions",
},
"MANAGEMENT_SERVICE": {
"name": "Management Service",
"description": "Partner managing customer instances",
},
"WHITE_LABEL_SERVICE": {
"name": "White Label Service",
"description": "Partner reselling under their own brand",
}
}

View File

@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% from "macros.html" import render_field %}
{% block title %}Update Partner{% endblock %}
{% block content_title %}Update Partner{% endblock %}
{% block content_description %}Update partner{% endblock %}
{% block content %}
<form method="post">
{{ form.hidden_tag() }}
{% set disabled_fields = ['tenant', 'code'] %}
{% set exclude_fields = [] %}
{% for field in form %}
{{ render_field(field, disabled_fields, exclude_fields) }}
{% endfor %}
<button type="submit" class="btn btn-primary">Update Partner</button>
</form>
{% endblock %}

View File

@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% from "macros.html" import render_field %}
{% block title %}Register Partner Service{% endblock %}
{% block content_title %}Register Partner Service{% endblock %}
{% block content_description %}Register Partner Service{% endblock %}
{% block content %}
<form method="post">
{{ form.hidden_tag() }}
{% set disabled_fields = ['type'] %}
{% set exclude_fields = [] %}
{% for field in form %}
{{ render_field(field, disabled_fields, exclude_fields) }}
{% endfor %}
<button type="submit" class="btn btn-primary">Register Partner Service</button>
</form>
{% endblock %}

View File

@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% from "macros.html" import render_field %}
{% block title %}Register Partner Service{% endblock %}
{% block content_title %}Register Partner Service{% endblock %}
{% block content_description %}Register Partner Service{% 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 Partner Service</button>
</form>
{% endblock %}

View File

@@ -0,0 +1,26 @@
{% extends 'base.html' %}
{% from 'macros.html' import render_selectable_table, render_pagination %}
{% block title %}Partner Services{% endblock %}
{% block content_title %}Partner Services{% endblock %}
{% block content_description %}View Partner Services for active Partner{% 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('administration_bp.handle_partner_service_selection') }}" id="partnerServicesForm">
{{ render_selectable_table(headers=["Partner Service ID", "Name", "Type"], rows=rows, selectable=True, id="retrieversTable") }}
<div class="form-group mt-3 d-flex justify-content-between">
<div>
<button type="submit" name="action" value="edit_partner_service" class="btn btn-primary" onclick="return validateTableSelection('partnerServicesForm')">Edit Partner Service</button>
</div>
<button type="submit" name="action" value="create_partner_service" class="btn btn-success">Register Partner Service</button>
</div>
</form>
</div>
{% endblock %}
{% block content_footer %}
{{ render_pagination(pagination, 'document_bp.retrievers') }}
{% endblock %}

View File

@@ -0,0 +1,26 @@
{% extends 'base.html' %}
{% from 'macros.html' import render_selectable_table, render_pagination %}
{% block title %}Partners{% endblock %}
{% block content_title %}Partners{% endblock %}
{% block content_description %}View Partners{% 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('administration_bp.handle_partner_selection') }}" id="partnersForm">
{{ render_selectable_table(headers=["Partner ID", "Name"], rows=rows, selectable=True, id="partnersTable") }}
<div class="form-group mt-3 d-flex justify-content-between">
<div>
<button type="submit" name="action" value="edit_partner" class="btn btn-primary" onclick="return validateTableSelection('partnersForm')">Edit Partner</button>
<button type="submit" name="action" value="set_session_partner" class="btn btn-primary" onclick="return validateTableSelection('partnersForm')">Set Session Partner</button>
</div>
</div>
</form>
</div>
{% endblock %}
{% block content_footer %}
{{ render_pagination(pagination, 'document_bp.retrievers') }}
{% endblock %}

View File

@@ -8,6 +8,7 @@
<!-- Trigger action Form -->
<form method="POST" action="{{ url_for('administration_bp.handle_trigger_action') }}">
<div class="form-group mt-3">
<button type="submit" name="action" value="register_partner" class="btn btn-secondary">Register Partner</button>
<button type="submit" name="action" value="update_usages" class="btn btn-secondary">Update Usages</button>
</div>
</form>

View File

@@ -9,7 +9,7 @@
{% block content %}
<form method="post">
{{ form.hidden_tag() }}
{% set disabled_fields = ['user_name', 'user_email', 'tenant_name'] %}
{% set disabled_fields = ['user_name', 'user_email', 'tenant_name', 'partner_name'] %}
{% set exclude_fields = [] %}
{% for field in form %}
{{ render_field(field, disabled_fields, exclude_fields) }}

View File

@@ -101,6 +101,8 @@
{'name': 'Trigger Actions', 'url': '/administration/trigger_actions', 'roles': ['Super User']},
{'name': 'Licenses', 'url': '/entitlements/view_licenses', 'roles': ['Super User', 'Tenant Admin']},
{'name': 'Usage', 'url': '/entitlements/view_usages', 'roles': ['Super User', 'Tenant Admin']},
{'name': 'Partners', 'url': '/administration/partners', 'roles': ['Super User']},
{'name': 'Partner Services', 'url': '/administration/partner_services', 'roles': ['Super User']},
]) }}
{% endif %}
{% if current_user.is_authenticated %}
@@ -123,6 +125,13 @@
{% endif %}
</a>
</li>
{% if current_user.has_roles('Super User') and 'partner' in session %}
<li class="nav-item mt-2">
<a href="/session_defaults" class="btn btn-sm bg-gradient-success mb-0">
PARTNER {{ session['partner'].get('id', 'None') }}: {{ session['partner'].get('name', 'None') }}
</a>
</li>
{% endif %}
</ul>
{% endif %}
</div>

View File

@@ -9,7 +9,7 @@
{% block content %}
<form method="post">
{{ form.hidden_tag() }}
{% set disabled_fields = ['name', 'llm_model'] %}
{% set disabled_fields = ['name', 'code', 'llm_model'] %}
{% set exclude_fields = [] %}
{% for field in form %}
{{ render_field(field, disabled_fields, exclude_fields) }}

View File

@@ -9,7 +9,7 @@
{% block content %}
<form method="post">
{{ form.hidden_tag() }}
{% set disabled_fields = [] %}
{% set disabled_fields = ['code'] %}
{% set exclude_fields = [] %}
{% for field in form %}
{{ render_field(field, disabled_fields, exclude_fields) }}

View File

@@ -1,7 +1,46 @@
from flask import current_app
from flask_wtf import FlaskForm
from wtforms.fields.simple import SubmitField
from wtforms.fields.choices import SelectField
from wtforms.fields.simple import SubmitField, StringField, BooleanField, TextAreaField
from wtforms.validators import DataRequired, Optional, Length
from common.extensions import cache_manager
from common.utils.form_assistants import validate_json
from eveai_app.views.dynamic_form_base import DynamicFormBase
class TriggerActionForm(FlaskForm):
submit = SubmitField('Submit')
class EditPartnerForm(FlaskForm):
tenant = StringField('Tenant', validators=[DataRequired()], render_kw={'readonly': True})
code = StringField('Code', description="Referral Code", validators=[DataRequired()], render_kw={'readonly': True})
logo_url = StringField('Logo URL', validators=[Optional(), Length(max=255)])
active = BooleanField('Active', validators=[DataRequired()], default=True)
class PartnerServiceForm(FlaskForm):
name = StringField('Name', validators=[DataRequired(), Length(max=50)])
description = TextAreaField('Description', validators=[Optional()])
type = SelectField('Partner Service Type', validators=[DataRequired()])
active = BooleanField('Active', validators=[DataRequired()], default=True)
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)
# Dynamically populate the 'type' field
types_dict = cache_manager.partner_services_types_cache.get_types()
self.type.choices = [(key, value['name']) for key, value in types_dict.items()]
class EditPartnerServiceForm(DynamicFormBase):
name = StringField('Name', validators=[DataRequired(), Length(max=50)])
description = TextAreaField('Description', validators=[Optional()])
type = StringField('Partner Service Type', validators=[DataRequired()], render_kw={'readonly': True})
active = BooleanField('Active', validators=[DataRequired()], default=True)
user_metadata = TextAreaField('User Metadata', validators=[Optional(), validate_json])
system_metadata = TextAreaField('System Metadata', validators=[Optional(), validate_json])

View File

@@ -1,3 +1,4 @@
import ast
import uuid
from datetime import datetime as dt, timezone as tz
from flask import request, redirect, flash, render_template, Blueprint, session, current_app, jsonify
@@ -5,10 +6,15 @@ from flask_security import hash_password, roles_required, roles_accepted, curren
from itsdangerous import URLSafeTimedSerializer
from sqlalchemy.exc import SQLAlchemyError
from common.extensions import db, cache_manager
from common.models.user import Partner, Tenant, PartnerService
from common.utils.celery_utils import current_celery
from common.utils.eveai_exceptions import EveAIException
from common.utils.log_utils import format_query_results
from common.utils.model_logging_utils import update_logging_information, set_logging_information
from common.utils.view_assistants import prepare_table_for_macro, form_validation_failed
from common.utils.nginx_utils import prefixed_url_for
from .administration_forms import TriggerActionForm
from .administration_forms import TriggerActionForm, EditPartnerForm, PartnerServiceForm, EditPartnerServiceForm
administration_bp = Blueprint('administration_bp', __name__, url_prefix='/administration')
@@ -35,5 +41,215 @@ def handle_trigger_action():
except Exception as e:
current_app.logger.error(f"Failed to trigger usage update task: {str(e)}")
flash(f'Failed to trigger usage update: {str(e)}', 'danger')
case 'register_partner':
try:
partner_id = register_partner_from_tenant(session['tenant']['id'])
return redirect(prefixed_url_for('administration_bp.edit_partner', partner_id=partner_id, ))
except EveAIException as e:
current_app.logger.error(f'Error registering partner for tenant {session['tenant']['id']}: {str(e)}')
flash('Error Registering Partner for Selected Tenant', 'danger')
return redirect(prefixed_url_for('user_bp.select_tenant'))
return redirect(prefixed_url_for('administration_bp.trigger_actions'))
@administration_bp.route('/partner/<int:partner_id>', methods=['GET', 'POST'])
@roles_accepted('Super User')
def edit_partner(partner_id):
partner = Partner.query.get_or_404(partner_id) # This will return a 404 if no partner is found
tenant = Tenant.query.get_or_404(partner.tenant_id)
form = EditPartnerForm(obj=partner)
form.tenant.data = tenant.name
if form.validate_on_submit():
# Populate the user with form data
form.populate_obj(partner)
update_logging_information(partner, dt.now(tz.utc))
db.session.commit()
flash('Partner updated successfully.', 'success')
return redirect(
prefixed_url_for('administration_bp.edit_partner',
partner_id=partner.id)) # Assuming there's a user profile view to redirect to
else:
form_validation_failed(request, form)
return render_template('administration/edit_partner.html', form=form, partner_id=partner_id)
@administration_bp.route('/partners', methods=['GET', 'POST'])
@roles_accepted('Super User')
def partners():
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 10, type=int)
query = (db.session.query(
Partner.id,
Partner.code,
Partner.active,
Partner.logo_url,
# Include all needed Partner columns here
Tenant.name.label('name')
).join(Tenant, Partner.tenant_id == Tenant.id).order_by(Partner.id))
current_app.logger.debug(f'{format_query_results(query)}')
pagination = query.paginate(page=page, per_page=per_page)
the_partners = pagination.items
# prepare table data
rows = prepare_table_for_macro(the_partners, [('id', ''), ('name', '')])
# Render the catalogs in a template
return render_template('administration/partners.html', rows=rows, pagination=pagination)
@administration_bp.route('/handle_partner_selection', methods=['POST'])
@roles_accepted('Super User')
def handle_partner_selection():
action = request.form['action']
partner_identification = request.form.get('selected_row')
partner_id = ast.literal_eval(partner_identification).get('value')
partner = Partner.query.get_or_404(partner_id)
if action == 'set_session_partner':
current_app.logger.info(f"Setting session partner: {partner.id}")
session['partner'] = partner.to_dict()
elif action == 'edit_partner':
return redirect(prefixed_url_for('administration_bp.edit_partner', partner_id=partner_id))
return redirect(prefixed_url_for('administration_bp.partners'))
@administration_bp.route('/partner_service', methods=['GET', 'POST'])
@roles_accepted('Super User')
def partner_service():
form = PartnerServiceForm()
if form.validate_on_submit():
partner_id = session.get('partner_id', None)
if not partner_id:
flash('No partner has been selected. Set partner before adding services.', 'warning')
return redirect(prefixed_url_for('administration_bp.partners'))
new_partner_service = PartnerService()
form.populate_obj(new_partner_service)
set_logging_information(new_partner_service, dt.now(tz.utc))
new_partner_service.partner_id = partner_id
try:
db.session.add(new_partner_service)
db.session.commit()
flash('Partner Service successfully added!', 'success')
current_app.logger.info(f"Partner Service {new_partner_service.name} added successfully for {partner_id}")
# Step 2 of the creation process (depending on type)
return redirect(prefixed_url_for('administration_bp.partner_service',
partner_service_id=new_partner_service.id))
except SQLAlchemyError as e:
db.session.rollback()
flash(f'Failed to add Partner Service: {str(e)}', 'danger')
current_app.logger.error(f"Failed to add Partner Service {new_partner_service.name} "
f"for partner {partner_id}. Error: {str(e)}")
return render_template('administration/partner_service.html', form=form)
@administration_bp.route('/partner_service/<int:partner_service_id>', methods=['GET', 'POST'])
@roles_accepted('Super User')
def edit_partner_service(partner_service_id):
partner_service = PartnerService.query.get_or_404(partner_service_id)
partner_id = session.get('partner_id', None)
form = EditPartnerServiceForm(obj=partner_service)
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)
if form.validate_on_submit():
form.populate_obj(partner_service)
partner_service.configuration = form.get_dynamic_data('configuration')
# update partner relationship
partner_service.partner_id = partner_id
update_logging_information(partner_service, dt.now(tz.utc))
try:
db.session.add(partner_service)
db.session.commit()
flash('Partner Service updated successfully.', 'success')
current_app.logger.info(f"Partner Service {partner_service.name} updated successfully! ")
except SQLAlchemyError as e:
db.session.rollback()
flash(f'Failed to update Partner Service: {str(e)}', 'danger')
current_app.logger.error(f"Failed to update Partner Service {partner_service.id} for partner {partner_id}. "
f"Error: {str(e)} ")
return render_template('administration/edit_partner_service.html', form=form,
partner_service_id=partner_service_id)
return redirect(prefixed_url_for('administration_bp.partner_services'))
else:
form_validation_failed(request, form)
return render_template('administration/edit_partner_service.html', form=form,
partner_service_id=partner_service_id)
@administration_bp.route('/partner_services', methods=['GET', 'POST'])
@roles_accepted('Super User')
def partner_services():
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 10, type=int)
partner_id = session.get('partner_id', None)
if not partner_id:
flash('No partner has been selected. Set partner before adding services.', 'warning')
return redirect(prefixed_url_for('administration_bp.partners'))
query = PartnerService.query.filter(PartnerService.partner_id == partner_id)
pagination = query.paginate(page=page, per_page=per_page)
the_partner_services = pagination.items
# prepare table data
rows = prepare_table_for_macro(the_partner_services, [('id', ''), ('name', ''), ('type', '')])
return render_template('administration/partner_services.html', rows=rows, pagination=pagination)
@administration_bp.route('/handle_partner_service_selection', methods=['POST'])
@roles_accepted('Super User')
def handle_partner_service_selection():
action = request.form['action']
if action == 'create_partner_service':
return redirect(prefixed_url_for('administration_bp.partner_service'))
partner_service_identification = request.form.get('selected_row')
partner_service_id = ast.literal_eval(partner_service_identification).get('value')
if action == 'edit_partner_service':
return redirect(prefixed_url_for('administration_bp.edit_partner_service',
partner_service_id=partner_service_id))
return redirect(prefixed_url_for('administration_bp.partner_services'))
def register_partner_from_tenant(tenant_id):
# check if there is already a partner defined for the tenant
partner = Partner.query.filter_by(tenant_id=tenant_id).first()
if partner:
return partner.id
try:
partner = Partner(
tenant_id=tenant_id,
code=f"PART-{str(uuid.uuid4())}",
)
set_logging_information(partner, dt.now(tz.utc))
db.session.add(partner)
db.session.commit()
return partner.id
except SQLAlchemyError as e:
db.session.rollback()
raise EveAIException(f"Failed to register partner for tenant {tenant_id}. Error: {str(e)}")

View File

@@ -18,6 +18,9 @@ class SessionDefaultsForm(FlaskForm):
tenant_name = StringField('Tenant Name', validators=[DataRequired()])
default_language = SelectField('Default Language', choices=[], validators=[DataRequired()])
# Partner Defaults
partner_name = StringField('Partner Name', validators=[DataRequired()])
# Default Catalog - initialize as a regular SelectField
catalog = SelectField('Catalog', choices=[], validators=[Optional()])
@@ -28,6 +31,10 @@ class SessionDefaultsForm(FlaskForm):
self.user_name.data = current_user.user_name
self.user_email.data = current_user.email
self.tenant_name.data = session.get('tenant').get('name')
if session.get('partner', None):
self.partner_name.data = session.get('partner').get('name')
else:
self.partner_name.data = ""
self.default_language.choices = [(lang, lang.lower()) for lang in
session.get('tenant').get('allowed_languages')]
self.default_language.data = session.get('default_language')

View File

@@ -10,21 +10,13 @@ from wtforms_sqlalchemy.fields import QuerySelectField
from common.extensions import cache_manager
from common.models.document import Catalog
from common.utils.form_assistants import validate_json
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
def validate_json(form, field):
if field.data:
try:
json.loads(field.data)
except json.JSONDecodeError:
raise ValidationError('Invalid JSON format')
class CatalogForm(FlaskForm):
name = StringField('Name', validators=[DataRequired(), Length(max=50)])
description = TextAreaField('Description', validators=[Optional()])

View File

@@ -29,7 +29,6 @@ from common.utils.view_assistants import form_validation_failed, prepare_table_f
from .document_list_view import DocumentListView
from .document_version_list_view import DocumentVersionListView
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')

View File

@@ -63,7 +63,7 @@ class DynamicFormBase(FlaskForm):
# Required validator
if field_def.get('required', False):
validators_list.append(validators.InputRequired())
validators_list.append(validators.DataRequired())
else:
validators_list.append(validators.Optional())

View File

@@ -11,6 +11,7 @@ from config.type_defs.service_types import SERVICE_TYPES
class TenantForm(FlaskForm):
name = StringField('Name', validators=[DataRequired(), Length(max=80)])
code = StringField('Code', validators=[DataRequired()], render_kw={'readonly': True})
type = SelectField('Tenant Type', validators=[Optional()], default='Active')
website = StringField('Website', validators=[DataRequired(), Length(max=255)])
# language fields
@@ -125,3 +126,6 @@ class EditTenantProjectForm(FlaskForm):
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

@@ -8,7 +8,7 @@ from itsdangerous import URLSafeTimedSerializer
from sqlalchemy.exc import SQLAlchemyError
import ast
from common.models.user import User, Tenant, Role, TenantDomain, TenantProject
from common.models.user import User, Tenant, Role, TenantDomain, TenantProject, Partner
from common.extensions import db, security, minio_client, simple_encryption
from common.utils.security_utils import send_confirmation_email, send_reset_email
from config.type_defs.service_types import SERVICE_TYPES
@@ -18,6 +18,8 @@ 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
from common.utils.nginx_utils import prefixed_url_for
from common.utils.eveai_exceptions import EveAIDoublePartner, EveAIException
from common.utils.document_utils import set_logging_information, update_logging_information
user_bp = Blueprint('user_bp', __name__, url_prefix='/user')
@@ -36,6 +38,10 @@ def log_after_request(response):
@roles_required('Super User')
def tenant():
form = TenantForm()
if request.method == 'GET':
code = f"TENANT-{str(uuid.uuid4())}"
form.code.data = code
if form.validate_on_submit():
# Handle the required attributes
new_tenant = Tenant()
@@ -244,6 +250,7 @@ def handle_tenant_selection():
return redirect(prefixed_url_for('user_bp.tenant_overview'))
case 'new_tenant':
return redirect(prefixed_url_for('user_bp.tenant'))
# Add more conditions for other actions
return redirect(prefixed_url_for('select_tenant'))

BIN
migrations/.DS_Store vendored

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,82 @@
"""Initialize empty Tenant codes
Revision ID: 867deef0888b
Revises: cab899dbb213
Create Date: 2025-04-03 09:01:20.446536
"""
from alembic import op
import sqlalchemy as sa
import uuid
from sqlalchemy.sql import table, column, select, or_
from sqlalchemy.orm import Session
# revision identifiers, used by Alembic.
revision = '867deef0888b'
down_revision = 'cab899dbb213'
branch_labels = None
depends_on = None
def upgrade():
# Create a reference to the tenant table
tenant_table = table('tenant',
column('id', sa.Integer),
column('code', sa.String(50)),
schema='public' # Assuming the table is in the 'public' schema
)
# Get a connection
connection = op.get_bind()
session = Session(bind=connection)
try:
# Find all tenants with empty or null code
# Note the updated select syntax for SQLAlchemy 2.0
query = select(tenant_table.c.id).where(
or_(
tenant_table.c.code == None,
tenant_table.c.code == ''
)
)
results = connection.execute(query)
# Update each tenant with a UUID-based code
for row in results:
tenant_id = row[0]
code = f"TENANT-{str(uuid.uuid4())}"
# Update the tenant record
update_stmt = tenant_table.update().where(
tenant_table.c.id == tenant_id
).values(
code=code
)
connection.execute(update_stmt)
# Commit changes
session.commit()
# Log how many records were updated
count_query = select(sa.func.count()).select_from(tenant_table).where(
tenant_table.c.code.like('TENANT-%')
)
updated_count = connection.execute(count_query).scalar()
print(f"Updated {updated_count} tenant records with UUID-based codes")
except Exception as e:
session.rollback()
print(f"Error updating tenant codes: {str(e)}")
raise e
finally:
session.close()
def downgrade():
# No downgrade needed for this data migration
pass

View File

@@ -0,0 +1,86 @@
"""Add Partner Models
Revision ID: 98adf66ce189
Revises: 03a1e7633c01
Create Date: 2025-03-31 14:43:06.833648
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '98adf66ce189'
down_revision = '03a1e7633c01'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('partner',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=50), nullable=False),
sa.Column('logo_url', sa.String(length=255), nullable=True),
sa.Column('active', sa.Boolean(), 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(['tenant_id'], ['public.tenant.id'], ),
sa.ForeignKeyConstraint(['updated_by'], ['public.user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('code'),
sa.UniqueConstraint('tenant_id'),
schema='public'
)
op.create_table('partner_service',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('partner_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=50), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('type', sa.String(length=50), nullable=False),
sa.Column('type_version', sa.String(length=20), nullable=False),
sa.Column('active', sa.Boolean(), nullable=True),
sa.Column('configuration', sa.JSON(), nullable=True),
sa.Column('system_metadata', sa.JSON(), nullable=True),
sa.Column('user_metadata', sa.JSON(), 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(['partner_id'], ['public.partner.id'], ),
sa.ForeignKeyConstraint(['updated_by'], ['public.user.id'], ),
sa.PrimaryKeyConstraint('id'),
schema='public'
)
op.create_table('partner_tenant',
sa.Column('partner_service_id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=False),
sa.Column('relationship_type', sa.String(length=20), nullable=False),
sa.Column('configuration', sa.JSON(), 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(['partner_service_id'], ['public.partner_service.id'], ),
sa.ForeignKeyConstraint(['tenant_id'], ['public.tenant.id'], ),
sa.ForeignKeyConstraint(['updated_by'], ['public.user.id'], ),
sa.PrimaryKeyConstraint('partner_service_id', 'tenant_id'),
schema='public'
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('partner_tenant', schema='public')
op.drop_table('partner_service', schema='public')
op.drop_table('partner', schema='public')
# ### end Alembic commands ###

View File

@@ -0,0 +1,34 @@
"""Add code to Tenant
Revision ID: cab899dbb213
Revises: 98adf66ce189
Create Date: 2025-04-02 16:08:06.597183
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'cab899dbb213'
down_revision = '98adf66ce189'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('tenant', schema=None) as batch_op:
batch_op.add_column(sa.Column('code', sa.String(length=50), nullable=True))
batch_op.create_unique_constraint(None, ['code'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('tenant', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='unique')
batch_op.drop_column('code')
# ### end Alembic commands ###

Binary file not shown.

Binary file not shown.

View File

@@ -89,4 +89,5 @@ sseclient~=0.0.27
termcolor~=2.5.0
mistral-common~=1.5.3
mistralai~=1.5.0
contextvars~=2.4
contextvars~=2.4
pandas~=2.2.3