- Partner model additions
- menu changes to allow for partners - partner views and forms now in partner_forms.py and partner_views.py - Introduction of services layer - Allow all configuration to handle partner configurations, and adaptation of caching to allow for this
This commit is contained in:
@@ -57,6 +57,12 @@ class License(db.Model):
|
|||||||
overage_embedding = db.Column(db.Float, nullable=False, default=0)
|
overage_embedding = db.Column(db.Float, nullable=False, default=0)
|
||||||
overage_interaction = db.Column(db.Float, nullable=False, default=0)
|
overage_interaction = db.Column(db.Float, nullable=False, default=0)
|
||||||
|
|
||||||
|
# Versioning Information
|
||||||
|
created_at = db.Column(db.DateTime, nullable=True, 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=True, server_default=db.func.now(), onupdate=db.func.now())
|
||||||
|
updated_by = db.Column(db.Integer, db.ForeignKey('public.user.id'), nullable=True)
|
||||||
|
|
||||||
tenant = db.relationship('Tenant', back_populates='licenses')
|
tenant = db.relationship('Tenant', back_populates='licenses')
|
||||||
license_tier = db.relationship('LicenseTier', back_populates='licenses')
|
license_tier = db.relationship('LicenseTier', back_populates='licenses')
|
||||||
usages = db.relationship('LicenseUsage', order_by='LicenseUsage.period_start_date', back_populates='license')
|
usages = db.relationship('LicenseUsage', order_by='LicenseUsage.period_start_date', back_populates='license')
|
||||||
@@ -88,7 +94,33 @@ class LicenseTier(db.Model):
|
|||||||
standard_overage_embedding = db.Column(db.Float, nullable=False, default=0)
|
standard_overage_embedding = db.Column(db.Float, nullable=False, default=0)
|
||||||
standard_overage_interaction = db.Column(db.Float, nullable=False, default=0)
|
standard_overage_interaction = db.Column(db.Float, nullable=False, default=0)
|
||||||
|
|
||||||
|
# Versioning Information
|
||||||
|
created_at = db.Column(db.DateTime, nullable=True, 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=True, server_default=db.func.now(), onupdate=db.func.now())
|
||||||
|
updated_by = db.Column(db.Integer, db.ForeignKey('public.user.id'), nullable=True)
|
||||||
|
|
||||||
licenses = db.relationship('License', back_populates='license_tier')
|
licenses = db.relationship('License', back_populates='license_tier')
|
||||||
|
partner_services = db.relationship('PartnerServiceLicenseTier', back_populates='license_tier')
|
||||||
|
|
||||||
|
|
||||||
|
class PartnerServiceLicenseTier(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,
|
||||||
|
nullable=False)
|
||||||
|
license_tier_id = db.Column(db.Integer, db.ForeignKey('public.license_tier.id'), primary_key=True,
|
||||||
|
nullable=False)
|
||||||
|
|
||||||
|
# Versioning Information
|
||||||
|
created_at = db.Column(db.DateTime, nullable=True, 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=True, server_default=db.func.now(), onupdate=db.func.now())
|
||||||
|
updated_by = db.Column(db.Integer, db.ForeignKey('public.user.id'), nullable=True)
|
||||||
|
|
||||||
|
license_tier = db.relationship('LicenseTier', back_populates='partner_services')
|
||||||
|
partner_service = db.relationship('PartnerService', back_populates='license_tiers')
|
||||||
|
|
||||||
|
|
||||||
class LicenseUsage(db.Model):
|
class LicenseUsage(db.Model):
|
||||||
|
|||||||
@@ -211,7 +211,8 @@ class Partner(db.Model):
|
|||||||
'type': service.type,
|
'type': service.type,
|
||||||
'type_version': service.type_version,
|
'type_version': service.type_version,
|
||||||
'active': service.active,
|
'active': service.active,
|
||||||
'configuration': service.configuration
|
'configuration': service.configuration,
|
||||||
|
'permissions': service.permissions,
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
'id': self.id,
|
'id': self.id,
|
||||||
@@ -250,15 +251,16 @@ class PartnerService(db.Model):
|
|||||||
system_metadata = db.Column(db.JSON, nullable=True)
|
system_metadata = db.Column(db.JSON, nullable=True)
|
||||||
user_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
|
# Versioning Information
|
||||||
created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now())
|
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)
|
created_by = db.Column(db.Integer, db.ForeignKey('public.user.id'), nullable=True)
|
||||||
updated_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now(), onupdate=db.func.now())
|
updated_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now(), onupdate=db.func.now())
|
||||||
updated_by = db.Column(db.Integer, db.ForeignKey('public.user.id'), nullable=True)
|
updated_by = db.Column(db.Integer, db.ForeignKey('public.user.id'), nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
partner = db.relationship('Partner', back_populates='services')
|
||||||
|
license_tiers = db.relationship('PartnerServiceLicenseTier', back_populates='partner_service')
|
||||||
|
|
||||||
|
|
||||||
class PartnerTenant(db.Model):
|
class PartnerTenant(db.Model):
|
||||||
__bind_key__ = 'public'
|
__bind_key__ = 'public'
|
||||||
@@ -267,9 +269,6 @@ class PartnerTenant(db.Model):
|
|||||||
partner_service_id = db.Column(db.Integer, db.ForeignKey('public.partner_service.id'), primary_key=True)
|
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)
|
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
|
# JSONB for flexible configuration specific to this relationship
|
||||||
configuration = db.Column(db.JSON, nullable=True)
|
configuration = db.Column(db.JSON, nullable=True)
|
||||||
|
|
||||||
|
|||||||
68
common/services/entitlement_services.py
Normal file
68
common/services/entitlement_services.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
from flask import session, current_app, flash
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
|
from common.extensions import db
|
||||||
|
from common.models.entitlements import PartnerServiceLicenseTier
|
||||||
|
from common.models.user import Partner, PartnerTenant
|
||||||
|
from common.utils.eveai_exceptions import EveAINoManagementPartnerService
|
||||||
|
from common.utils.model_logging_utils import set_logging_information
|
||||||
|
from datetime import datetime as dt, timezone as tz
|
||||||
|
|
||||||
|
from common.utils.security_utils import current_user_has_role
|
||||||
|
|
||||||
|
|
||||||
|
class EntitlementServices:
|
||||||
|
@staticmethod
|
||||||
|
def associate_license_tier_with_partner(license_tier_id):
|
||||||
|
"""Associate a license tier with a partner"""
|
||||||
|
try:
|
||||||
|
partner_id = session['partner']['id']
|
||||||
|
# Get partner service (MANAGEMENT_SERVICE type)
|
||||||
|
partner = Partner.query.get(partner_id)
|
||||||
|
if not partner:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Find a management service for this partner
|
||||||
|
management_service = next((service for service in session['partner']['services']
|
||||||
|
if service.get('type') == 'MANAGEMENT_SERVICE'), None)
|
||||||
|
|
||||||
|
if not management_service:
|
||||||
|
flash("Cannot associate license tier with partner. No management service defined for partner", "danger")
|
||||||
|
current_app.logger.error(f"No Management Service defined for partner {partner_id}"
|
||||||
|
f"trying to associate license tier {license_tier_id}.")
|
||||||
|
raise EveAINoManagementPartnerService()
|
||||||
|
# Check if the association already exists
|
||||||
|
existing_association = PartnerServiceLicenseTier.query.filter_by(
|
||||||
|
partner_service_id=management_service['id'],
|
||||||
|
license_tier_id=license_tier_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_association:
|
||||||
|
# Association already exists, nothing to do
|
||||||
|
flash("License tier was already associated with partner", "info")
|
||||||
|
current_app.logger.info(f"Association between partner service {management_service['id']} and "
|
||||||
|
f"license tier {license_tier_id} already exists.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create the association
|
||||||
|
association = PartnerServiceLicenseTier(
|
||||||
|
partner_service_id=management_service['id'],
|
||||||
|
license_tier_id=license_tier_id
|
||||||
|
)
|
||||||
|
set_logging_information(association, dt.now(tz.utc))
|
||||||
|
|
||||||
|
db.session.add(association)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash("Successfully associated license tier to partner", "success")
|
||||||
|
current_app.logger.info(f"Successfully associated license tier {license_tier_id} with "
|
||||||
|
f"partner service {management_service['id']}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash("Failed to associated license tier with partner service due to an internal error. "
|
||||||
|
"Please contact the System Administrator", "danger")
|
||||||
|
current_app.logger.error(f"Error associating license tier {license_tier_id} with partner: {str(e)}")
|
||||||
|
raise e
|
||||||
47
common/services/partner_services.py
Normal file
47
common/services/partner_services.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
|
from flask import session
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
|
from common.models.entitlements import PartnerServiceLicenseTier
|
||||||
|
from common.utils.eveai_exceptions import EveAINoManagementPartnerService, EveAINoSessionPartner
|
||||||
|
|
||||||
|
from common.utils.security_utils import current_user_has_role
|
||||||
|
|
||||||
|
|
||||||
|
class PartnerServices:
|
||||||
|
@staticmethod
|
||||||
|
def get_allowed_license_tier_ids() -> List[int]:
|
||||||
|
"""
|
||||||
|
Retrieve IDs of all License Tiers associated with the partner's management service
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of license tier IDs
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
EveAINoSessionPartner: If no partner is in the session
|
||||||
|
EveAINoManagementPartnerService: If partner has no management service
|
||||||
|
"""
|
||||||
|
partner = session.get("partner", None)
|
||||||
|
if not partner:
|
||||||
|
raise EveAINoSessionPartner()
|
||||||
|
|
||||||
|
# Find a management service for this partner
|
||||||
|
management_service = next((service for service in session['partner']['services']
|
||||||
|
if service.get('type') == 'MANAGEMENT_SERVICE'), None)
|
||||||
|
if not management_service:
|
||||||
|
raise EveAINoManagementPartnerService()
|
||||||
|
management_service_id = management_service['id']
|
||||||
|
|
||||||
|
# Query for all license tiers associated with this management service
|
||||||
|
associations = PartnerServiceLicenseTier.query.filter_by(
|
||||||
|
partner_service_id=management_service_id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Extract the license tier IDs
|
||||||
|
license_tier_ids = [assoc.license_tier_id for assoc in associations]
|
||||||
|
|
||||||
|
return license_tier_ids
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
from flask import session, current_app
|
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
|
||||||
|
|
||||||
from common.extensions import db
|
|
||||||
from common.models.user import Partner, PartnerTenant
|
|
||||||
from common.utils.eveai_exceptions import EveAINoManagementPartnerService
|
|
||||||
from common.utils.model_logging_utils import set_logging_information
|
|
||||||
from datetime import datetime as dt, timezone as tz
|
|
||||||
|
|
||||||
from common.utils.security_utils import current_user_has_role
|
|
||||||
|
|
||||||
|
|
||||||
class TenantService:
|
|
||||||
@staticmethod
|
|
||||||
def associate_tenant_with_partner(tenant_id):
|
|
||||||
"""Associate a tenant with a partner"""
|
|
||||||
try:
|
|
||||||
partner_id = session['partner']['id']
|
|
||||||
# Get partner service (MANAGEMENT_SERVICE type)
|
|
||||||
partner = Partner.query.get(partner_id)
|
|
||||||
if not partner:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Find a management service for this partner
|
|
||||||
management_service = next((service for service in session['partner']['services']
|
|
||||||
if service.get('type') == 'MANAGEMENT_SERVICE'), None)
|
|
||||||
|
|
||||||
if not management_service:
|
|
||||||
current_app.logger.error(f"No Management Service defined for partner {partner_id}"
|
|
||||||
f"while associating tenant {tenant_id} with partner.")
|
|
||||||
raise EveAINoManagementPartnerService()
|
|
||||||
|
|
||||||
# Create the association
|
|
||||||
tenant_partner = PartnerTenant(
|
|
||||||
partner_service_id=management_service['id'],
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
relationship_type='MANAGED',
|
|
||||||
)
|
|
||||||
set_logging_information(tenant_partner, dt.now(tz.utc))
|
|
||||||
|
|
||||||
db.session.add(tenant_partner)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
except SQLAlchemyError as e:
|
|
||||||
db.session.rollback()
|
|
||||||
current_app.logger.error(f"Error associating tenant {tenant_id} with partner: {str(e)}")
|
|
||||||
raise e
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def can_user_edit_tenant(tenant_id) -> bool:
|
|
||||||
if current_user_has_role('Super User'):
|
|
||||||
return True
|
|
||||||
elif current_user_has_role('Partner Admin'):
|
|
||||||
partner_id = session['partner']['id']
|
|
||||||
partner_service = next((service for service in session['partner']['services']
|
|
||||||
if service.get('type') == 'MANAGEMENT_SERVICE'), None)
|
|
||||||
if not partner_service:
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
partner_tenant = PartnerTenant.query.filter(
|
|
||||||
PartnerTenant.tenant_id == tenant_id,
|
|
||||||
PartnerTenant.partner_service_id == partner_service['id'],
|
|
||||||
).first()
|
|
||||||
if partner_tenant:
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
176
common/services/tenant_services.py
Normal file
176
common/services/tenant_services.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
from flask import session, current_app
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
|
from common.extensions import db, cache_manager
|
||||||
|
from common.models.user import Partner, PartnerTenant, PartnerService, Tenant
|
||||||
|
from common.utils.eveai_exceptions import EveAINoManagementPartnerService
|
||||||
|
from common.utils.model_logging_utils import set_logging_information
|
||||||
|
from datetime import datetime as dt, timezone as tz
|
||||||
|
|
||||||
|
from common.utils.security_utils import current_user_has_role
|
||||||
|
|
||||||
|
|
||||||
|
class TenantServices:
|
||||||
|
@staticmethod
|
||||||
|
def associate_tenant_with_partner(tenant_id):
|
||||||
|
"""Associate a tenant with a partner"""
|
||||||
|
try:
|
||||||
|
partner_id = session['partner']['id']
|
||||||
|
# Get partner service (MANAGEMENT_SERVICE type)
|
||||||
|
partner = Partner.query.get(partner_id)
|
||||||
|
if not partner:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Find a management service for this partner
|
||||||
|
management_service = next((service for service in session['partner']['services']
|
||||||
|
if service.get('type') == 'MANAGEMENT_SERVICE'), None)
|
||||||
|
|
||||||
|
if not management_service:
|
||||||
|
current_app.logger.error(f"No Management Service defined for partner {partner_id}"
|
||||||
|
f"while associating tenant {tenant_id} with partner.")
|
||||||
|
raise EveAINoManagementPartnerService()
|
||||||
|
|
||||||
|
# Create the association
|
||||||
|
tenant_partner = PartnerTenant(
|
||||||
|
partner_service_id=management_service['id'],
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
relationship_type='MANAGED',
|
||||||
|
)
|
||||||
|
set_logging_information(tenant_partner, dt.now(tz.utc))
|
||||||
|
|
||||||
|
db.session.add(tenant_partner)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
db.session.rollback()
|
||||||
|
current_app.logger.error(f"Error associating tenant {tenant_id} with partner: {str(e)}")
|
||||||
|
raise e
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_available_types_for_tenant(tenant_id: int, config_type: str) -> Dict[str, Dict[str, str]]:
|
||||||
|
"""
|
||||||
|
Get available configuration types for a tenant based on partner relationships
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: The tenant ID
|
||||||
|
config_type: The configuration type ('specialists', 'agents', 'tasks', etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary of available types for the tenant
|
||||||
|
"""
|
||||||
|
# Get the appropriate cache handler based on config_type
|
||||||
|
cache_handler = None
|
||||||
|
if config_type == 'specialists':
|
||||||
|
cache_handler = cache_manager.specialists_types_cache
|
||||||
|
elif config_type == 'agents':
|
||||||
|
cache_handler = cache_manager.agents_types_cache
|
||||||
|
elif config_type == 'tasks':
|
||||||
|
cache_handler = cache_manager.tasks_types_cache
|
||||||
|
elif config_type == 'tools':
|
||||||
|
cache_handler = cache_manager.tools_types_cache
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported config type: {config_type}")
|
||||||
|
|
||||||
|
# Get all types with their metadata (including partner info)
|
||||||
|
all_types = cache_handler.get_types()
|
||||||
|
|
||||||
|
# Filter to include:
|
||||||
|
# 1. Types with no partner (global)
|
||||||
|
# 2. Types with partners that have a SPECIALIST_SERVICE relationship with this tenant
|
||||||
|
available_partners = TenantServices.get_tenant_partner_names(tenant_id)
|
||||||
|
|
||||||
|
available_types = {
|
||||||
|
type_id: info for type_id, info in all_types.items()
|
||||||
|
if info.get('partner') is None or info.get('partner') in available_partners
|
||||||
|
}
|
||||||
|
|
||||||
|
return available_types
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_tenant_partner_names(tenant_id: int) -> List[str]:
|
||||||
|
"""
|
||||||
|
Get names of partners that have a SPECIALIST_SERVICE relationship with this tenant
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: The tenant ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of partner names (tenant names)
|
||||||
|
"""
|
||||||
|
# Find all PartnerTenant relationships for this tenant
|
||||||
|
partner_names = []
|
||||||
|
try:
|
||||||
|
# Get all partner services of type SPECIALIST_SERVICE
|
||||||
|
specialist_services = (
|
||||||
|
PartnerService.query
|
||||||
|
.filter_by(type='SPECIALIST_SERVICE')
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not specialist_services:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Find tenant relationships with these services
|
||||||
|
partner_tenants = (
|
||||||
|
PartnerTenant.query
|
||||||
|
.filter_by(tenant_id=tenant_id)
|
||||||
|
.filter(PartnerTenant.partner_service_id.in_([svc.id for svc in specialist_services]))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the partner names (their tenant names)
|
||||||
|
for pt in partner_tenants:
|
||||||
|
partner_service = (
|
||||||
|
PartnerService.query
|
||||||
|
.filter_by(id=pt.partner_service_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if partner_service:
|
||||||
|
partner = Partner.query.get(partner_service.partner_id)
|
||||||
|
if partner:
|
||||||
|
# Get the tenant associated with this partner
|
||||||
|
partner_tenant = Tenant.query.get(partner.tenant_id)
|
||||||
|
if partner_tenant:
|
||||||
|
partner_names.append(partner_tenant.name)
|
||||||
|
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
current_app.logger.error(f"Database error retrieving partner names: {str(e)}")
|
||||||
|
|
||||||
|
return partner_names
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def can_use_specialist_type(tenant_id: int, specialist_type: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a tenant can use a specific specialist type
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: The tenant ID
|
||||||
|
specialist_type: The specialist type ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the tenant can use the specialist type, False otherwise
|
||||||
|
"""
|
||||||
|
# Get the specialist type definition
|
||||||
|
try:
|
||||||
|
specialist_types = cache_manager.specialists_types_cache.get_types()
|
||||||
|
specialist_def = specialist_types.get(specialist_type)
|
||||||
|
|
||||||
|
if not specialist_def:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# If it's a global specialist, anyone can use it
|
||||||
|
if specialist_def.get('partner') is None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# If it's a partner-specific specialist, check if tenant has access
|
||||||
|
partner_name = specialist_def.get('partner')
|
||||||
|
available_partners = TenantServices.get_tenant_partner_names(tenant_id)
|
||||||
|
|
||||||
|
return partner_name in available_partners
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f"Error checking specialist type access: {str(e)}")
|
||||||
|
return False
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
from flask import session
|
|
||||||
|
|
||||||
from common.models.user import Partner, Role
|
|
||||||
|
|
||||||
# common/services/user_service.py
|
|
||||||
from common.utils.eveai_exceptions import EveAIRoleAssignmentException
|
|
||||||
from common.utils.security_utils import current_user_has_role, all_user_roles
|
|
||||||
|
|
||||||
|
|
||||||
class UserService:
|
|
||||||
@staticmethod
|
|
||||||
def get_assignable_roles():
|
|
||||||
"""Retrieves roles that can be assigned to a user depending on the current user logged in,
|
|
||||||
and the active tenant for the session"""
|
|
||||||
current_tenant_id = session.get('tenant').get('id', None)
|
|
||||||
effective_role_names = []
|
|
||||||
if current_tenant_id == 1:
|
|
||||||
if current_user_has_role("Super User"):
|
|
||||||
effective_role_names.append("Super User")
|
|
||||||
elif current_tenant_id:
|
|
||||||
if current_user_has_role("Tenant Admin"):
|
|
||||||
effective_role_names.append("Tenant Admin")
|
|
||||||
if current_user_has_role("Partner Admin") or current_user_has_role("Super User"):
|
|
||||||
effective_role_names.append("Tenant Admin")
|
|
||||||
if session.get('partner'):
|
|
||||||
if session.get('partner').get('tenant_id') == current_tenant_id:
|
|
||||||
effective_role_names.append("Partner Admin")
|
|
||||||
effective_role_names = list(set(effective_role_names))
|
|
||||||
effective_roles = [(role.id, role.name) for role in
|
|
||||||
Role.query.filter(Role.name.in_(effective_role_names)).all()]
|
|
||||||
return effective_roles
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def validate_role_assignments(role_ids):
|
|
||||||
"""Validate a set of role assignments, raising exception for first invalid role"""
|
|
||||||
assignable_roles = UserService.get_assignable_roles()
|
|
||||||
assignable_role_ids = {role[0] for role in assignable_roles}
|
|
||||||
role_id_set = set(role_ids)
|
|
||||||
return role_id_set.issubset(assignable_role_ids)
|
|
||||||
95
common/services/user_services.py
Normal file
95
common/services/user_services.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
from flask import session
|
||||||
|
|
||||||
|
from common.models.user import Partner, Role, PartnerTenant
|
||||||
|
|
||||||
|
from common.utils.eveai_exceptions import EveAIRoleAssignmentException
|
||||||
|
from common.utils.security_utils import current_user_has_role
|
||||||
|
|
||||||
|
|
||||||
|
class UserServices:
|
||||||
|
@staticmethod
|
||||||
|
def get_assignable_roles():
|
||||||
|
"""Retrieves roles that can be assigned to a user depending on the current user logged in,
|
||||||
|
and the active tenant for the session"""
|
||||||
|
current_tenant_id = session.get('tenant').get('id', None)
|
||||||
|
effective_role_names = []
|
||||||
|
if current_tenant_id == 1:
|
||||||
|
if current_user_has_role("Super User"):
|
||||||
|
effective_role_names.append("Super User")
|
||||||
|
elif current_tenant_id:
|
||||||
|
if current_user_has_role("Tenant Admin"):
|
||||||
|
effective_role_names.append("Tenant Admin")
|
||||||
|
if current_user_has_role("Partner Admin") or current_user_has_role("Super User"):
|
||||||
|
effective_role_names.append("Tenant Admin")
|
||||||
|
if session.get('partner'):
|
||||||
|
if session.get('partner').get('tenant_id') == current_tenant_id:
|
||||||
|
effective_role_names.append("Partner Admin")
|
||||||
|
effective_role_names = list(set(effective_role_names))
|
||||||
|
effective_roles = [(role.id, role.name) for role in
|
||||||
|
Role.query.filter(Role.name.in_(effective_role_names)).all()]
|
||||||
|
return effective_roles
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_role_assignments(role_ids):
|
||||||
|
"""Validate a set of role assignments, raising exception for first invalid role"""
|
||||||
|
assignable_roles = UserServices.get_assignable_roles()
|
||||||
|
assignable_role_ids = {role[0] for role in assignable_roles}
|
||||||
|
role_id_set = set(role_ids)
|
||||||
|
return role_id_set.issubset(assignable_role_ids)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def can_user_edit_tenant(tenant_id) -> bool:
|
||||||
|
if current_user_has_role('Super User'):
|
||||||
|
return True
|
||||||
|
elif current_user_has_role('Partner Admin'):
|
||||||
|
partner = session.get('partner', None)
|
||||||
|
if partner and partner["tenant_id"] == tenant_id:
|
||||||
|
return True
|
||||||
|
partner_service = next((service for service in session['partner']['services']
|
||||||
|
if service.get('type') == 'MANAGEMENT_SERVICE'), None)
|
||||||
|
if not partner_service:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
partner_tenant = PartnerTenant.query.filter(
|
||||||
|
PartnerTenant.tenant_id == tenant_id,
|
||||||
|
PartnerTenant.partner_service_id == partner_service['id'],
|
||||||
|
).first()
|
||||||
|
if partner_tenant:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def can_user_create_tenant() -> bool:
|
||||||
|
if current_user_has_role('Super User'):
|
||||||
|
return True
|
||||||
|
elif current_user_has_role('Partner Admin'):
|
||||||
|
partner_id = session['partner']['id']
|
||||||
|
partner_service = next((service for service in session['partner']['services']
|
||||||
|
if service.get('type') == 'MANAGEMENT_SERVICE'), None)
|
||||||
|
if not partner_service:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
partner_permissions = partner_service.get('permissions', None)
|
||||||
|
return partner_permissions.get('can_create_tenant', False)
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def can_user_assign_license() -> bool:
|
||||||
|
if current_user_has_role('Super User'):
|
||||||
|
return True
|
||||||
|
elif current_user_has_role('Partner Admin'):
|
||||||
|
partner_id = session['partner']['id']
|
||||||
|
partner_service = next((service for service in session['partner']['services']
|
||||||
|
if service.get('type') == 'MANAGEMENT_SERVICE'), None)
|
||||||
|
if not partner_service:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
partner_permissions = partner_service.get('permissions', None)
|
||||||
|
return partner_permissions.get('can_assign_license', False)
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
65
common/utils/cache/config_cache.py
vendored
65
common/utils/cache/config_cache.py
vendored
@@ -62,13 +62,7 @@ class BaseConfigCacheHandler(CacheHandler[Dict[str, Any]]):
|
|||||||
def _load_specific_config(self, type_name: str, version_str: str) -> Dict[str, Any]:
|
def _load_specific_config(self, type_name: str, version_str: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Load a specific configuration version
|
Load a specific configuration version
|
||||||
|
Automatically handles global vs partner-specific configs
|
||||||
Args:
|
|
||||||
type_name: Type name
|
|
||||||
version_str: Version string
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Configuration data
|
|
||||||
"""
|
"""
|
||||||
version_tree = self.version_tree_cache.get_versions(type_name)
|
version_tree = self.version_tree_cache.get_versions(type_name)
|
||||||
versions = version_tree['versions']
|
versions = version_tree['versions']
|
||||||
@@ -79,11 +73,16 @@ class BaseConfigCacheHandler(CacheHandler[Dict[str, Any]]):
|
|||||||
if version_str not in versions:
|
if version_str not in versions:
|
||||||
raise ValueError(f"Version {version_str} not found for {type_name}")
|
raise ValueError(f"Version {version_str} not found for {type_name}")
|
||||||
|
|
||||||
file_path = versions[version_str]['file_path']
|
version_info = versions[version_str]
|
||||||
|
file_path = version_info['file_path']
|
||||||
|
partner = version_info.get('partner')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(file_path) as f:
|
with open(file_path) as f:
|
||||||
config = yaml.safe_load(f)
|
config = yaml.safe_load(f)
|
||||||
|
# Add partner information to the config
|
||||||
|
if partner:
|
||||||
|
config['partner'] = partner
|
||||||
return config
|
return config
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"Error loading config from {file_path}: {e}")
|
raise ValueError(f"Error loading config from {file_path}: {e}")
|
||||||
@@ -133,20 +132,37 @@ class BaseConfigVersionTreeCacheHandler(CacheHandler[Dict[str, Any]]):
|
|||||||
def _load_version_tree(self, type_name: str) -> Dict[str, Any]:
|
def _load_version_tree(self, type_name: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Load version tree for a specific type without loading full configurations
|
Load version tree for a specific type without loading full configurations
|
||||||
|
Checks both global and partner-specific directories
|
||||||
Args:
|
|
||||||
type_name: Name of configuration type
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict containing available versions and their metadata
|
|
||||||
"""
|
"""
|
||||||
type_path = Path(self._config_dir) / type_name
|
# First check the global path
|
||||||
if not type_path.exists():
|
global_path = Path(self._config_dir) / "global" / type_name
|
||||||
|
|
||||||
|
# If global path doesn't exist, check if the type exists directly in the root
|
||||||
|
# (for backward compatibility)
|
||||||
|
if not global_path.exists():
|
||||||
|
global_path = Path(self._config_dir) / type_name
|
||||||
|
|
||||||
|
if not global_path.exists():
|
||||||
|
# Check if it exists in any partner subdirectories
|
||||||
|
partner_dirs = [d for d in Path(self._config_dir).iterdir()
|
||||||
|
if d.is_dir() and d.name != "global"]
|
||||||
|
|
||||||
|
for partner_dir in partner_dirs:
|
||||||
|
partner_type_path = partner_dir / type_name
|
||||||
|
if partner_type_path.exists():
|
||||||
|
# Found in partner directory
|
||||||
|
return self._load_versions_from_path(partner_type_path)
|
||||||
|
|
||||||
|
# If we get here, the type wasn't found anywhere
|
||||||
raise ValueError(f"No configuration found for type {type_name}")
|
raise ValueError(f"No configuration found for type {type_name}")
|
||||||
|
|
||||||
version_files = list(type_path.glob('*.yaml'))
|
return self._load_versions_from_path(global_path)
|
||||||
|
|
||||||
|
def _load_versions_from_path(self, path: Path) -> Dict[str, Any]:
|
||||||
|
"""Load all versions from a specific path"""
|
||||||
|
version_files = list(path.glob('*.yaml'))
|
||||||
if not version_files:
|
if not version_files:
|
||||||
raise ValueError(f"No versions found for type {type_name}")
|
raise ValueError(f"No versions found in {path}")
|
||||||
|
|
||||||
versions = {}
|
versions = {}
|
||||||
latest_version = None
|
latest_version = None
|
||||||
@@ -160,9 +176,17 @@ class BaseConfigVersionTreeCacheHandler(CacheHandler[Dict[str, Any]]):
|
|||||||
with open(file_path) as f:
|
with open(file_path) as f:
|
||||||
yaml_data = yaml.safe_load(f)
|
yaml_data = yaml.safe_load(f)
|
||||||
metadata = yaml_data.get('metadata', {})
|
metadata = yaml_data.get('metadata', {})
|
||||||
|
# Add partner information if available
|
||||||
|
partner = None
|
||||||
|
if "global" not in str(file_path):
|
||||||
|
# Extract partner name from path
|
||||||
|
# Path format: config_dir/partner_name/type_name/version.yaml
|
||||||
|
partner = file_path.parent.parent.name
|
||||||
|
|
||||||
versions[ver] = {
|
versions[ver] = {
|
||||||
'metadata': metadata,
|
'metadata': metadata,
|
||||||
'file_path': str(file_path)
|
'file_path': str(file_path),
|
||||||
|
'partner': partner
|
||||||
}
|
}
|
||||||
|
|
||||||
# Track latest version
|
# Track latest version
|
||||||
@@ -316,7 +340,8 @@ class BaseConfigTypesCacheHandler(CacheHandler[Dict[str, Any]]):
|
|||||||
type_definitions = {
|
type_definitions = {
|
||||||
type_id: {
|
type_id: {
|
||||||
'name': info['name'],
|
'name': info['name'],
|
||||||
'description': info['description']
|
'description': info['description'],
|
||||||
|
'partner': info.get('partner') # Include partner info if available
|
||||||
}
|
}
|
||||||
for type_id, info in self._types_module.items()
|
for type_id, info in self._types_module.items()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from flask import session, current_app, redirect
|
|||||||
from .database import Database
|
from .database import Database
|
||||||
from .eveai_exceptions import EveAINoSessionTenant, EveAINoSessionPartner, EveAINoManagementPartnerService, \
|
from .eveai_exceptions import EveAINoSessionTenant, EveAINoSessionPartner, EveAINoManagementPartnerService, \
|
||||||
EveAINoManagementPartnerForTenant
|
EveAINoManagementPartnerForTenant
|
||||||
from ..services.tenant_service import TenantService
|
from ..services.user_services import UserServices
|
||||||
|
|
||||||
|
|
||||||
def mw_before_request():
|
def mw_before_request():
|
||||||
@@ -36,7 +36,7 @@ def mw_before_request():
|
|||||||
if service.get('type') == 'MANAGEMENT_SERVICE'), None)
|
if service.get('type') == 'MANAGEMENT_SERVICE'), None)
|
||||||
if not management_service:
|
if not management_service:
|
||||||
raise EveAINoManagementPartnerService()
|
raise EveAINoManagementPartnerService()
|
||||||
if not TenantService.can_user_edit_tenant(tenant_id):
|
if not UserServices.can_user_edit_tenant(tenant_id):
|
||||||
raise EveAINoManagementPartnerForTenant()
|
raise EveAINoManagementPartnerForTenant()
|
||||||
|
|
||||||
Database(tenant_id).switch_schema()
|
Database(tenant_id).switch_schema()
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
version: "1.0.0"
|
||||||
|
name: "Management Service"
|
||||||
|
configuration: {}
|
||||||
|
permissions: {}
|
||||||
|
metadata:
|
||||||
|
author: "Josako"
|
||||||
|
date_added: "2025-04-02"
|
||||||
|
changes: "Initial version"
|
||||||
|
description: "Initial definition of the management service"
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
version: "1.0.0"
|
||||||
|
name: "Traicie Vacature Specialist"
|
||||||
|
framework: "crewai"
|
||||||
|
configuration:
|
||||||
|
ko_criteria:
|
||||||
|
name: "Knock-out criteria"
|
||||||
|
type: "text"
|
||||||
|
description: "The knock-out criteria (1 per line)"
|
||||||
|
required: true
|
||||||
|
hard_skills:
|
||||||
|
name: "Hard Skills"
|
||||||
|
type: "text"
|
||||||
|
description: "The hard skills to be checked with the applicant (1 per line)"
|
||||||
|
required: false
|
||||||
|
soft_skills:
|
||||||
|
name: "Soft Skills"
|
||||||
|
type: "text"
|
||||||
|
description: "The soft skills required for the job (1 per line)"
|
||||||
|
required: false
|
||||||
|
tone_of_voice:
|
||||||
|
name: "Tone of Voice"
|
||||||
|
type: "enum"
|
||||||
|
description: "Tone of voice to be used in communicating with the applicant"
|
||||||
|
required: false
|
||||||
|
default: "formal"
|
||||||
|
allowed_values: [ "formal", "informal", "dynamic" ]
|
||||||
|
vacancy_text:
|
||||||
|
name: "Vacancy Text"
|
||||||
|
type: "text"
|
||||||
|
description: "The vacancy for this specialist"
|
||||||
|
arguments:
|
||||||
|
language:
|
||||||
|
name: "Language"
|
||||||
|
type: "str"
|
||||||
|
description: "Language code to be used for receiving questions and giving answers"
|
||||||
|
required: true
|
||||||
|
results:
|
||||||
|
rag_output:
|
||||||
|
answer:
|
||||||
|
name: "answer"
|
||||||
|
type: "str"
|
||||||
|
description: "Answer to the query"
|
||||||
|
required: true
|
||||||
|
citations:
|
||||||
|
name: "citations"
|
||||||
|
type: "List[str]"
|
||||||
|
description: "List of citations"
|
||||||
|
required: false
|
||||||
|
insufficient_info:
|
||||||
|
name: "insufficient_info"
|
||||||
|
type: "bool"
|
||||||
|
description: "Whether or not the query is insufficient info"
|
||||||
|
required: true
|
||||||
|
spin:
|
||||||
|
situation:
|
||||||
|
name: "situation"
|
||||||
|
type: "str"
|
||||||
|
description: "A description of the customer's current situation / context"
|
||||||
|
required: false
|
||||||
|
problem:
|
||||||
|
name: "problem"
|
||||||
|
type: "str"
|
||||||
|
description: "The current problems the customer is facing, for which he/she seeks a solution"
|
||||||
|
required: false
|
||||||
|
implication:
|
||||||
|
name: "implication"
|
||||||
|
type: "str"
|
||||||
|
description: "A list of implications"
|
||||||
|
required: false
|
||||||
|
needs:
|
||||||
|
name: "needs"
|
||||||
|
type: "str"
|
||||||
|
description: "A list of needs"
|
||||||
|
required: false
|
||||||
|
additional_info:
|
||||||
|
name: "additional_info"
|
||||||
|
type: "str"
|
||||||
|
description: "Additional information that may be commercially interesting"
|
||||||
|
required: false
|
||||||
|
lead_info:
|
||||||
|
lead_personal_info:
|
||||||
|
name:
|
||||||
|
name: "name"
|
||||||
|
type: "str"
|
||||||
|
description: "name of the lead"
|
||||||
|
required: "true"
|
||||||
|
job_title:
|
||||||
|
name: "job_title"
|
||||||
|
type: "str"
|
||||||
|
description: "job title"
|
||||||
|
required: false
|
||||||
|
email:
|
||||||
|
name: "email"
|
||||||
|
type: "str"
|
||||||
|
description: "lead email"
|
||||||
|
required: "false"
|
||||||
|
phone:
|
||||||
|
name: "phone"
|
||||||
|
type: "str"
|
||||||
|
description: "lead phone"
|
||||||
|
required: false
|
||||||
|
additional_info:
|
||||||
|
name: "additional_info"
|
||||||
|
type: "str"
|
||||||
|
description: "additional info on the lead"
|
||||||
|
required: false
|
||||||
|
lead_company_info:
|
||||||
|
company_name:
|
||||||
|
name: "company_name"
|
||||||
|
type: "str"
|
||||||
|
description: "Name of the lead company"
|
||||||
|
required: false
|
||||||
|
industry:
|
||||||
|
name: "industry"
|
||||||
|
type: "str"
|
||||||
|
description: "The industry of the company"
|
||||||
|
required: false
|
||||||
|
company_size:
|
||||||
|
name: "company_size"
|
||||||
|
type: "int"
|
||||||
|
description: "The size of the company"
|
||||||
|
required: false
|
||||||
|
company_website:
|
||||||
|
name: "company_website"
|
||||||
|
type: "str"
|
||||||
|
description: "The main website for the company"
|
||||||
|
required: false
|
||||||
|
additional_info:
|
||||||
|
name: "additional_info"
|
||||||
|
type: "str"
|
||||||
|
description: "Additional information that may be commercially interesting"
|
||||||
|
required: false
|
||||||
|
agents:
|
||||||
|
- type: "RAG_AGENT"
|
||||||
|
version: "1.0"
|
||||||
|
- type: "RAG_COMMUNICATION_AGENT"
|
||||||
|
version: "1.0"
|
||||||
|
- type: "SPIN_DETECTION_AGENT"
|
||||||
|
version: "1.0"
|
||||||
|
- type: "SPIN_SALES_SPECIALIST_AGENT"
|
||||||
|
version: "1.0"
|
||||||
|
- type: "IDENTIFICATION_AGENT"
|
||||||
|
version: "1.0"
|
||||||
|
- type: "RAG_COMMUNICATION_AGENT"
|
||||||
|
version: "1.0"
|
||||||
|
tasks:
|
||||||
|
- type: "RAG_TASK"
|
||||||
|
version: "1.0"
|
||||||
|
- type: "SPIN_DETECT_TASK"
|
||||||
|
version: "1.0"
|
||||||
|
- type: "SPIN_QUESTIONS_TASK"
|
||||||
|
version: "1.0"
|
||||||
|
- type: "IDENTIFICATION_DETECTION_TASK"
|
||||||
|
version: "1.0"
|
||||||
|
- type: "IDENTIFICATION_QUESTIONS_TASK"
|
||||||
|
version: "1.0"
|
||||||
|
- type: "RAG_CONSOLIDATION_TASK"
|
||||||
|
version: "1.0"
|
||||||
|
metadata:
|
||||||
|
author: "Josako"
|
||||||
|
date_added: "2025-01-08"
|
||||||
|
changes: "Initial version"
|
||||||
|
description: "A Specialist that performs both Q&A as SPIN (Sales Process) activities"
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 812 KiB |
299
config/specialists/global/SPIN_SPECIALIST/1.0.0_overview.svg
Normal file
299
config/specialists/global/SPIN_SPECIALIST/1.0.0_overview.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 387 KiB |
@@ -11,5 +11,10 @@ SPECIALIST_TYPES = {
|
|||||||
"SPIN_SPECIALIST": {
|
"SPIN_SPECIALIST": {
|
||||||
"name": "Spin Sales Specialist",
|
"name": "Spin Sales Specialist",
|
||||||
"description": "A specialist that allows to answer user queries, try to get SPIN-information and Identification",
|
"description": "A specialist that allows to answer user queries, try to get SPIN-information and Identification",
|
||||||
|
},
|
||||||
|
"TRAICIE_VACATURE_SPECIALIST": {
|
||||||
|
"name": "Traicie Vacature Specialist",
|
||||||
|
"description": "Specialist configureerbaar voor een specifieke vacature",
|
||||||
|
"partner": "Traicie"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,8 +141,8 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(interaction_bp)
|
app.register_blueprint(interaction_bp)
|
||||||
from .views.entitlements_views import entitlements_bp
|
from .views.entitlements_views import entitlements_bp
|
||||||
app.register_blueprint(entitlements_bp)
|
app.register_blueprint(entitlements_bp)
|
||||||
from .views.administration_views import administration_bp
|
from .views.partner_views import partner_bp
|
||||||
app.register_blueprint(administration_bp)
|
app.register_blueprint(partner_bp)
|
||||||
from .views.healthz_views import healthz_bp, init_healtz
|
from .views.healthz_views import healthz_bp, init_healtz
|
||||||
app.register_blueprint(healthz_bp)
|
app.register_blueprint(healthz_bp)
|
||||||
init_healtz(app)
|
init_healtz(app)
|
||||||
|
|||||||
@@ -56,13 +56,13 @@ def attribute_error_handler(error):
|
|||||||
# Handle the SQLAlchemy relationship error specifically
|
# Handle the SQLAlchemy relationship error specifically
|
||||||
if "'str' object has no attribute '_sa_instance_state'" in error_msg:
|
if "'str' object has no attribute '_sa_instance_state'" in error_msg:
|
||||||
flash('Database relationship error. Please check your form inputs and try again.', 'error')
|
flash('Database relationship error. Please check your form inputs and try again.', 'error')
|
||||||
return render_template('errors/500.html',
|
return render_template('error/500.html',
|
||||||
error_type="Relationship Error",
|
error_type="Relationship Error",
|
||||||
error_details="A string value was provided where a database object was expected."), 500
|
error_details="A string value was provided where a database object was expected."), 500
|
||||||
|
|
||||||
# Handle other AttributeErrors
|
# Handle other AttributeErrors
|
||||||
flash('An application error occurred. The technical team has been notified.', 'error')
|
flash('An application error occurred. The technical team has been notified.', 'error')
|
||||||
return render_template('errors/500.html',
|
return render_template('error/500.html',
|
||||||
error_type="Attribute Error",
|
error_type="Attribute Error",
|
||||||
error_details=error_msg), 500
|
error_details=error_msg), 500
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ def attribute_error_handler(error):
|
|||||||
def general_exception(e):
|
def general_exception(e):
|
||||||
current_app.logger.error(f"Unhandled Exception: {e}", exc_info=True)
|
current_app.logger.error(f"Unhandled Exception: {e}", exc_info=True)
|
||||||
flash('An application error occurred. The technical team has been notified.', 'error')
|
flash('An application error occurred. The technical team has been notified.', 'error')
|
||||||
return render_template('errors/500.html',
|
return render_template('error/500.html',
|
||||||
error_type=type(e).__name__,
|
error_type=type(e).__name__,
|
||||||
error_details=str(e)), 500
|
error_details=str(e)), 500
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
{% set main_fields = ['start_date', 'end_date', 'currency', 'yearly_payment', 'basic_fee'] %}
|
{% set main_fields = ['start_date', 'end_date', 'currency', 'yearly_payment', 'basic_fee'] %}
|
||||||
{% for field in form %}
|
{% for field in form %}
|
||||||
{{ render_included_field(field, disabled_fields=['currency'], include_fields=main_fields) }}
|
{{ render_included_field(field, readonly_fields=ext_readonly_fields + ['currency'], include_fields=main_fields) }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<!-- Nav Tabs -->
|
<!-- Nav Tabs -->
|
||||||
<div class="row mt-5">
|
<div class="row mt-5">
|
||||||
@@ -40,21 +40,21 @@
|
|||||||
<div class="tab-pane fade show active" id="storage-tab" role="tabpanel">
|
<div class="tab-pane fade show active" id="storage-tab" role="tabpanel">
|
||||||
{% set storage_fields = ['max_storage_tokens', 'additional_storage_token_price', 'additional_storage_bucket'] %}
|
{% set storage_fields = ['max_storage_tokens', 'additional_storage_token_price', 'additional_storage_bucket'] %}
|
||||||
{% for field in form %}
|
{% for field in form %}
|
||||||
{{ render_included_field(field, disabled_fields=[], include_fields=storage_fields) }}
|
{{ render_included_field(field, readonly_fields=ext_readonly_fields, include_fields=storage_fields) }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<!-- Embedding Tab -->
|
<!-- Embedding Tab -->
|
||||||
<div class="tab-pane fade" id="embedding-tab" role="tabpanel">
|
<div class="tab-pane fade" id="embedding-tab" role="tabpanel">
|
||||||
{% set embedding_fields = ['included_embedding_tokens', 'additional_embedding_token_price', 'additional_embedding_bucket'] %}
|
{% set embedding_fields = ['included_embedding_tokens', 'additional_embedding_token_price', 'additional_embedding_bucket'] %}
|
||||||
{% for field in form %}
|
{% for field in form %}
|
||||||
{{ render_included_field(field, disabled_fields=[], include_fields=embedding_fields) }}
|
{{ render_included_field(field, readonly_fields=ext_readonly_fields, include_fields=embedding_fields) }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<!-- Interaction Tab -->
|
<!-- Interaction Tab -->
|
||||||
<div class="tab-pane fade" id="interaction-tab" role="tabpanel">
|
<div class="tab-pane fade" id="interaction-tab" role="tabpanel">
|
||||||
{% set interaction_fields = ['included_interaction_tokens', 'additional_interaction_token_price', 'additional_interaction_bucket'] %}
|
{% set interaction_fields = ['included_interaction_tokens', 'additional_interaction_token_price', 'additional_interaction_bucket'] %}
|
||||||
{% for field in form %}
|
{% for field in form %}
|
||||||
{{ render_included_field(field, disabled_fields=[], include_fields=interaction_fields) }}
|
{{ render_included_field(field, readonly_fields=ext_readonly_fields, include_fields=interaction_fields) }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
{% set main_fields = ['start_date', 'end_date', 'currency', 'yearly_payment', 'basic_fee'] %}
|
{% set main_fields = ['start_date', 'end_date', 'currency', 'yearly_payment', 'basic_fee'] %}
|
||||||
{% for field in form %}
|
{% for field in form %}
|
||||||
{{ render_included_field(field, disabled_fields=ext_disabled_fields + ['currency'], include_fields=main_fields) }}
|
{{ render_included_field(field, readonly_fields=ext_readonly_fields + ['currency'], include_fields=main_fields) }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<!-- Nav Tabs -->
|
<!-- Nav Tabs -->
|
||||||
<div class="row mt-5">
|
<div class="row mt-5">
|
||||||
@@ -40,21 +40,21 @@
|
|||||||
<div class="tab-pane fade show active" id="storage-tab" role="tabpanel">
|
<div class="tab-pane fade show active" id="storage-tab" role="tabpanel">
|
||||||
{% set storage_fields = ['max_storage_mb', 'additional_storage_price', 'additional_storage_bucket'] %}
|
{% set storage_fields = ['max_storage_mb', 'additional_storage_price', 'additional_storage_bucket'] %}
|
||||||
{% for field in form %}
|
{% for field in form %}
|
||||||
{{ render_included_field(field, disabled_fields=ext_disabled_fields, include_fields=storage_fields) }}
|
{{ render_included_field(field, readonly_fields=ext_readonly_fields, include_fields=storage_fields) }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<!-- Embedding Tab -->
|
<!-- Embedding Tab -->
|
||||||
<div class="tab-pane fade" id="embedding-tab" role="tabpanel">
|
<div class="tab-pane fade" id="embedding-tab" role="tabpanel">
|
||||||
{% set embedding_fields = ['included_embedding_mb', 'additional_embedding_price', 'additional_embedding_bucket', 'overage_embedding'] %}
|
{% set embedding_fields = ['included_embedding_mb', 'additional_embedding_price', 'additional_embedding_bucket', 'overage_embedding'] %}
|
||||||
{% for field in form %}
|
{% for field in form %}
|
||||||
{{ render_included_field(field, disabled_fields=ext_disabled_fields, include_fields=embedding_fields) }}
|
{{ render_included_field(field, readonly_fields=ext_readonly_fields, include_fields=embedding_fields) }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<!-- Interaction Tab -->
|
<!-- Interaction Tab -->
|
||||||
<div class="tab-pane fade" id="interaction-tab" role="tabpanel">
|
<div class="tab-pane fade" id="interaction-tab" role="tabpanel">
|
||||||
{% set interaction_fields = ['included_interaction_tokens', 'additional_interaction_token_price', 'additional_interaction_bucket', 'overage_interaction'] %}
|
{% set interaction_fields = ['included_interaction_tokens', 'additional_interaction_token_price', 'additional_interaction_bucket', 'overage_interaction'] %}
|
||||||
{% for field in form %}
|
{% for field in form %}
|
||||||
{{ render_included_field(field, disabled_fields=ext_disabled_fields, include_fields=interaction_fields) }}
|
{{ render_included_field(field, readonly_fields=ext_readonly_fields, include_fields=interaction_fields) }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,10 +10,19 @@
|
|||||||
{{ render_selectable_table(headers=["ID", "Name", "Version", "Start Date", "End Date"], rows=rows, selectable=True, id="licenseTierTable") }}
|
{{ render_selectable_table(headers=["ID", "Name", "Version", "Start Date", "End Date"], rows=rows, selectable=True, id="licenseTierTable") }}
|
||||||
<div class="form-group mt-3 d-flex justify-content-between">
|
<div class="form-group mt-3 d-flex justify-content-between">
|
||||||
<div>
|
<div>
|
||||||
|
{% if current_user.has_role('Super User') %}
|
||||||
<button type="submit" name="action" value="edit_license_tier" class="btn btn-primary" onclick="return validateTableSelection('licenseTiersForm')">Edit License Tier</button>
|
<button type="submit" name="action" value="edit_license_tier" class="btn btn-primary" onclick="return validateTableSelection('licenseTiersForm')">Edit License Tier</button>
|
||||||
<button type="submit" name="action" value="create_license_for_tenant" class="btn btn-secondary" onclick="return validateTableSelection('licenseTiersForm')">Create License for Current Tenant</button>
|
{% endif %}
|
||||||
|
{% if current_user.has_role('Super User') or (current_user.has_role('Partner Admin') and can_assign_license) %}
|
||||||
|
<button type="submit" name="action" value="create_license_for_tenant" class="btn btn-secondary" onclick="return validateTableSelection('licenseTiersForm')">Create Tenant License</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if current_user.has_role('Super User') %}
|
||||||
|
<button type="submit" name="action" value="associate_license_tier_to_partner" class="btn btn-secondary" onclick="return validateTableSelection('licenseTiersForm')">Associate to Partner</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if current_user.has_role('Super User') %}
|
||||||
<button type="submit" name="action" value="create_license_tier" class="btn btn-success">Register License Tier</button>
|
<button type="submit" name="action" value="create_license_tier" class="btn btn-success">Register License Tier</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{% macro render_field_content(field, disabled=False, class='') %}
|
{% macro render_field_content(field, disabled=False, readonly=False, class='') %}
|
||||||
{% if field.type == 'BooleanField' %}
|
{% if field.type == 'BooleanField' %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="form-check form-switch">
|
<div class="form-check form-switch">
|
||||||
{{ field(class="form-check-input " + class, disabled=disabled, required=False) }}
|
{{ field(class="form-check-input " + class, disabled=disabled, readonly=readonly, required=False) }}
|
||||||
{% if field.description %}
|
{% if field.description %}
|
||||||
{{ field.label(class="form-check-label",
|
{{ field.label(class="form-check-label",
|
||||||
**{'data-bs-toggle': 'tooltip',
|
**{'data-bs-toggle': 'tooltip',
|
||||||
@@ -57,11 +57,11 @@
|
|||||||
|
|
||||||
{% if field.type == 'TextAreaField' and 'json-editor' in class %}
|
{% if field.type == 'TextAreaField' and 'json-editor' in class %}
|
||||||
<div id="{{ field.id }}-editor" class="json-editor-container"></div>
|
<div id="{{ field.id }}-editor" class="json-editor-container"></div>
|
||||||
{{ field(class="form-control d-none " + class, disabled=disabled) }}
|
{{ field(class="form-control d-none " + class, disabled=disabled, readonly=readonly) }}
|
||||||
{% elif field.type == 'SelectField' %}
|
{% elif field.type == 'SelectField' %}
|
||||||
{{ field(class="form-control form-select " + class, disabled=disabled) }}
|
{{ field(class="form-control form-select " + class, disabled=disabled, readonly=readonly) }}
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ field(class="form-control " + class, disabled=disabled) }}
|
{{ field(class="form-control " + class, disabled=disabled, readonly=readonly) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if field.errors %}
|
{% if field.errors %}
|
||||||
@@ -76,21 +76,23 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
||||||
{% macro render_field(field, disabled_fields=[], exclude_fields=[], class='') %}
|
{% macro render_field(field, disabled_fields=[], readonly_fields=[], exclude_fields=[], class='') %}
|
||||||
<!-- Debug info -->
|
<!-- Debug info -->
|
||||||
<!-- Field name: {{ field.name }}, Field type: {{ field.__class__.__name__ }} -->
|
<!-- Field name: {{ field.name }}, Field type: {{ field.__class__.__name__ }} -->
|
||||||
|
|
||||||
{% set disabled = field.name in disabled_fields %}
|
{% set disabled = field.name in disabled_fields %}
|
||||||
|
{% set readonly = field.name in readonly_fields %}
|
||||||
{% set exclude_fields = exclude_fields + ['csrf_token', 'submit'] %}
|
{% set exclude_fields = exclude_fields + ['csrf_token', 'submit'] %}
|
||||||
{% if field.name not in exclude_fields %}
|
{% if field.name not in exclude_fields %}
|
||||||
{{ render_field_content(field, disabled, class) }}
|
{{ render_field_content(field, disabled, readonly, class) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro render_included_field(field, disabled_fields=[], include_fields=[], class='') %}
|
{% macro render_included_field(field, disabled_fields=[], readonly_fields=[], include_fields=[], class='') %}
|
||||||
{% set disabled = field.name in disabled_fields %}
|
{% set disabled = field.name in disabled_fields %}
|
||||||
|
{% set readonly = field.name in readonly_fields %}
|
||||||
{% if field.name in include_fields %}
|
{% if field.name in include_fields %}
|
||||||
{{ render_field_content(field, disabled, class) }}
|
{{ render_field_content(field, disabled, readonly, class) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@
|
|||||||
<div class="collapse navbar-collapse w-100 pt-3 pb-2 py-lg-0" id="navigation">
|
<div class="collapse navbar-collapse w-100 pt-3 pb-2 py-lg-0" id="navigation">
|
||||||
<ul class="navbar-nav navbar-nav-hover mx-auto">
|
<ul class="navbar-nav navbar-nav-hover mx-auto">
|
||||||
{% if current_user.is_authenticated %}
|
{% if current_user.is_authenticated %}
|
||||||
{{ dropdown('Tenant Configuration', 'source_environment', [
|
{{ dropdown('Tenants', 'source_environment', [
|
||||||
{'name': 'Tenants', 'url': '/user/select_tenant', 'roles': ['Super User', 'Partner Admin']},
|
{'name': 'Tenants', 'url': '/user/select_tenant', 'roles': ['Super User', 'Partner Admin']},
|
||||||
{'name': 'Tenant Overview', 'url': '/user/tenant_overview', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
|
{'name': 'Tenant Overview', 'url': '/user/tenant_overview', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
|
||||||
{'name': 'Edit Tenant', 'url': '/user/tenant/' ~ session['tenant'].get('id'), 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
|
{'name': 'Edit Tenant', 'url': '/user/tenant/' ~ session['tenant'].get('id'), 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
|
||||||
@@ -78,7 +78,14 @@
|
|||||||
]) }}
|
]) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.is_authenticated %}
|
{% if current_user.is_authenticated %}
|
||||||
{{ dropdown('Document Mgmt', 'note_stack', [
|
{{ dropdown('Partners', 'partner_exchange', [
|
||||||
|
{'name': 'Partners', 'url': '/partner/partners', 'roles': ['Super User']},
|
||||||
|
{'name': 'Partner Services', 'url': '/partner/partner_services', 'roles': ['Super User']},
|
||||||
|
{'name': 'Edit Partner', 'url': '/partner/partner/' ~ session['partner'].get('id'), 'roles': ['Super User', 'Partner Admin']},
|
||||||
|
]) }}
|
||||||
|
{% endif %}
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
{{ dropdown('Documents', 'note_stack', [
|
||||||
{'name': 'Catalogs', 'url': '/document/catalogs', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
|
{'name': 'Catalogs', 'url': '/document/catalogs', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
|
||||||
{'name': 'Processors', 'url': '/document/processors', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
|
{'name': 'Processors', 'url': '/document/processors', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
|
||||||
{'name': 'Retrievers', 'url': '/document/retrievers', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
|
{'name': 'Retrievers', 'url': '/document/retrievers', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
|
||||||
@@ -96,13 +103,11 @@
|
|||||||
]) }}
|
]) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.is_authenticated %}
|
{% if current_user.is_authenticated %}
|
||||||
{{ dropdown('Administration', 'settings', [
|
{{ dropdown('Entitlements', 'settings', [
|
||||||
{'name': 'License Tiers', 'url': '/entitlements/view_license_tiers', 'roles': ['Super User', 'Partner Admin']},
|
{'name': 'License Tiers', 'url': '/entitlements/view_license_tiers', 'roles': ['Super User', 'Partner Admin']},
|
||||||
{'name': 'Trigger Actions', 'url': '/administration/trigger_actions', 'roles': ['Super User']},
|
{'name': 'Trigger Actions', 'url': '/administration/trigger_actions', 'roles': ['Super User']},
|
||||||
{'name': 'Licenses', 'url': '/entitlements/view_licenses', 'roles': ['Super User', 'Tenant Admin', 'Partner Admin']},
|
{'name': 'Licenses', 'url': '/entitlements/view_licenses', 'roles': ['Super User', 'Tenant Admin', 'Partner Admin']},
|
||||||
{'name': 'Usage', 'url': '/entitlements/view_usages', 'roles': ['Super User', 'Tenant Admin', 'Partner Admin']},
|
{'name': 'Usage', 'url': '/entitlements/view_usages', 'roles': ['Super User', 'Tenant Admin', 'Partner Admin']},
|
||||||
{'name': 'Partners', 'url': '/administration/partners', 'roles': ['Super User']},
|
|
||||||
{'name': 'Partner Services', 'url': '/administration/partner_services', 'roles': ['Super User']},
|
|
||||||
]) }}
|
]) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.is_authenticated %}
|
{% if current_user.is_authenticated %}
|
||||||
|
|||||||
@@ -9,11 +9,12 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<form method="POST" action="{{ url_for('administration_bp.handle_partner_service_selection') }}" id="partnerServicesForm">
|
<form method="POST" action="{{ url_for('partner_bp.handle_partner_service_selection') }}" id="partnerServicesForm">
|
||||||
{{ render_selectable_table(headers=["Partner Service ID", "Name", "Type"], rows=rows, selectable=True, id="retrieversTable") }}
|
{{ 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 class="form-group mt-3 d-flex justify-content-between">
|
||||||
<div>
|
<div>
|
||||||
<button type="submit" name="action" value="edit_partner_service" class="btn btn-primary" onclick="return validateTableSelection('partnerServicesForm')">Edit Partner Service</button>
|
<button type="submit" name="action" value="edit_partner_service" class="btn btn-primary" onclick="return validateTableSelection('partnerServicesForm')">Edit Partner Service</button>
|
||||||
|
<button type="submit" name="action" value="add_partner_service_for_tenant" class="btn btn-primary" onclick="return validateTableSelection('partnerServicesForm')">Add Partner Service for Tenant</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" name="action" value="create_partner_service" class="btn btn-success">Register Partner Service</button>
|
<button type="submit" name="action" value="create_partner_service" class="btn btn-success">Register Partner Service</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<form method="POST" action="{{ url_for('administration_bp.handle_partner_selection') }}" id="partnersForm">
|
<form method="POST" action="{{ url_for('partner_bp.handle_partner_selection') }}" id="partnersForm">
|
||||||
{{ render_selectable_table(headers=["Partner ID", "Name"], rows=rows, selectable=True, id="partnersTable") }}
|
{{ 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 class="form-group mt-3 d-flex justify-content-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<!-- Trigger action Form -->
|
<!-- Trigger action Form -->
|
||||||
<form method="POST" action="{{ url_for('administration_bp.handle_trigger_action') }}">
|
<form method="POST" action="{{ url_for('partner_bp.handle_trigger_action') }}">
|
||||||
<div class="form-group mt-3">
|
<div class="form-group mt-3">
|
||||||
<button type="submit" name="action" value="update_usages" class="btn btn-secondary">Update Usages</button>
|
<button type="submit" name="action" value="update_usages" class="btn btn-secondary">Update Usages</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -28,13 +28,13 @@ class LicenseTierForm(FlaskForm):
|
|||||||
validators=[InputRequired(), NumberRange(min=0)])
|
validators=[InputRequired(), NumberRange(min=0)])
|
||||||
additional_embedding_bucket = IntegerField('Additional Embedding Bucket Size (MiB)',
|
additional_embedding_bucket = IntegerField('Additional Embedding Bucket Size (MiB)',
|
||||||
validators=[DataRequired(), NumberRange(min=1)])
|
validators=[DataRequired(), NumberRange(min=1)])
|
||||||
included_interaction_tokens = IntegerField('Included Embedding Tokens',
|
included_interaction_tokens = IntegerField('Included Embedding Tokens (M Tokens)',
|
||||||
validators=[DataRequired(), NumberRange(min=1)])
|
validators=[DataRequired(), NumberRange(min=1)])
|
||||||
additional_interaction_token_price_d = FloatField('Additional Interaction Token Fee ($)',
|
additional_interaction_token_price_d = FloatField('Additional Interaction Token Fee ($)',
|
||||||
validators=[InputRequired(), NumberRange(min=0)])
|
validators=[InputRequired(), NumberRange(min=0)])
|
||||||
additional_interaction_token_price_e = FloatField('Additional Interaction Token Fee (€)',
|
additional_interaction_token_price_e = FloatField('Additional Interaction Token Fee (€)',
|
||||||
validators=[InputRequired(), NumberRange(min=0)])
|
validators=[InputRequired(), NumberRange(min=0)])
|
||||||
additional_interaction_bucket = IntegerField('Additional Interaction Bucket Size',
|
additional_interaction_bucket = IntegerField('Additional Interaction Bucket Size (M Tokens)',
|
||||||
validators=[DataRequired(), NumberRange(min=1)])
|
validators=[DataRequired(), NumberRange(min=1)])
|
||||||
standard_overage_embedding = FloatField('Standard Overage Embedding (%)',
|
standard_overage_embedding = FloatField('Standard Overage Embedding (%)',
|
||||||
validators=[DataRequired(), NumberRange(min=0)],
|
validators=[DataRequired(), NumberRange(min=0)],
|
||||||
@@ -61,11 +61,11 @@ class LicenseForm(FlaskForm):
|
|||||||
validators=[InputRequired(), NumberRange(min=0)])
|
validators=[InputRequired(), NumberRange(min=0)])
|
||||||
additional_embedding_bucket = IntegerField('Additional Embedding Bucket Size (MiB)',
|
additional_embedding_bucket = IntegerField('Additional Embedding Bucket Size (MiB)',
|
||||||
validators=[DataRequired(), NumberRange(min=1)])
|
validators=[DataRequired(), NumberRange(min=1)])
|
||||||
included_interaction_tokens = IntegerField('Included Interaction Tokens',
|
included_interaction_tokens = IntegerField('Included Interaction Tokens (M Tokens)',
|
||||||
validators=[DataRequired(), NumberRange(min=1)])
|
validators=[DataRequired(), NumberRange(min=1)])
|
||||||
additional_interaction_token_price = FloatField('Additional Interaction Token Fee',
|
additional_interaction_token_price = FloatField('Additional Interaction Token Fee',
|
||||||
validators=[InputRequired(), NumberRange(min=0)])
|
validators=[InputRequired(), NumberRange(min=0)])
|
||||||
additional_interaction_bucket = IntegerField('Additional Interaction Bucket Size',
|
additional_interaction_bucket = IntegerField('Additional Interaction Bucket Size (M Tokens)',
|
||||||
validators=[DataRequired(), NumberRange(min=1)])
|
validators=[DataRequired(), NumberRange(min=1)])
|
||||||
overage_embedding = FloatField('Overage Embedding (%)',
|
overage_embedding = FloatField('Overage Embedding (%)',
|
||||||
validators=[DataRequired(), NumberRange(min=0)],
|
validators=[DataRequired(), NumberRange(min=0)],
|
||||||
|
|||||||
@@ -8,9 +8,17 @@ import ast
|
|||||||
|
|
||||||
from common.models.entitlements import License, LicenseTier, LicenseUsage, BusinessEventLog
|
from common.models.entitlements import License, LicenseTier, LicenseUsage, BusinessEventLog
|
||||||
from common.extensions import db, security, minio_client, simple_encryption
|
from common.extensions import db, security, minio_client, simple_encryption
|
||||||
|
|
||||||
|
from common.services.entitlement_services import EntitlementServices
|
||||||
|
from common.services.partner_services import PartnerServices
|
||||||
|
from common.services.tenant_services import TenantServices
|
||||||
|
from common.services.user_services import UserServices
|
||||||
|
from common.utils.eveai_exceptions import EveAIException
|
||||||
|
from common.utils.security_utils import current_user_has_role
|
||||||
from .entitlements_forms import LicenseTierForm, LicenseForm
|
from .entitlements_forms import LicenseTierForm, LicenseForm
|
||||||
from common.utils.view_assistants import prepare_table_for_macro, form_validation_failed
|
from common.utils.view_assistants import prepare_table_for_macro, form_validation_failed
|
||||||
from common.utils.nginx_utils import prefixed_url_for
|
from common.utils.nginx_utils import prefixed_url_for
|
||||||
|
from common.utils.document_utils import set_logging_information, update_logging_information
|
||||||
|
|
||||||
entitlements_bp = Blueprint('entitlements_bp', __name__, url_prefix='/entitlements')
|
entitlements_bp = Blueprint('entitlements_bp', __name__, url_prefix='/entitlements')
|
||||||
|
|
||||||
@@ -25,6 +33,8 @@ def license_tier():
|
|||||||
new_license_tier = LicenseTier()
|
new_license_tier = LicenseTier()
|
||||||
form.populate_obj(new_license_tier)
|
form.populate_obj(new_license_tier)
|
||||||
|
|
||||||
|
set_logging_information(new_license_tier, dt.now(tz.utc))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
db.session.add(new_license_tier)
|
db.session.add(new_license_tier)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -45,7 +55,7 @@ def license_tier():
|
|||||||
|
|
||||||
|
|
||||||
@entitlements_bp.route('/view_license_tiers', methods=['GET', 'POST'])
|
@entitlements_bp.route('/view_license_tiers', methods=['GET', 'POST'])
|
||||||
@roles_accepted('Super User')
|
@roles_accepted('Super User', 'Partner Admin')
|
||||||
def view_license_tiers():
|
def view_license_tiers():
|
||||||
page = request.args.get('page', 1, type=int)
|
page = request.args.get('page', 1, type=int)
|
||||||
per_page = request.args.get('per_page', 10, type=int)
|
per_page = request.args.get('per_page', 10, type=int)
|
||||||
@@ -56,7 +66,18 @@ def view_license_tiers():
|
|||||||
LicenseTier.end_date == None,
|
LicenseTier.end_date == None,
|
||||||
LicenseTier.end_date >= today
|
LicenseTier.end_date >= today
|
||||||
)
|
)
|
||||||
).order_by(LicenseTier.start_date.desc(), LicenseTier.id)
|
)
|
||||||
|
if current_user_has_role('Partner Admin'):
|
||||||
|
try:
|
||||||
|
license_tier_ids = PartnerServices.get_allowed_license_tier_ids()
|
||||||
|
except EveAIException as e:
|
||||||
|
flash(f"Cannot retrieve License Tiers: {str(e)}", 'danger')
|
||||||
|
current_app.logger.error(f'Cannot retrieve License Tiers for partner: {str(e)}')
|
||||||
|
return render_template("index.html")
|
||||||
|
if license_tier_ids and len(license_tier_ids) > 0:
|
||||||
|
query = query.filter(LicenseTier.id.in_(license_tier_ids))
|
||||||
|
|
||||||
|
query = query.order_by(LicenseTier.start_date.desc(), LicenseTier.id)
|
||||||
|
|
||||||
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
|
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||||
license_tiers = pagination.items
|
license_tiers = pagination.items
|
||||||
@@ -64,11 +85,14 @@ def view_license_tiers():
|
|||||||
rows = prepare_table_for_macro(license_tiers, [('id', ''), ('name', ''), ('version', ''), ('start_date', ''),
|
rows = prepare_table_for_macro(license_tiers, [('id', ''), ('name', ''), ('version', ''), ('start_date', ''),
|
||||||
('end_date', '')])
|
('end_date', '')])
|
||||||
|
|
||||||
return render_template('entitlements/view_license_tiers.html', rows=rows, pagination=pagination)
|
return render_template('entitlements/view_license_tiers.html',
|
||||||
|
rows=rows,
|
||||||
|
pagination=pagination,
|
||||||
|
can_assign_license=UserServices.can_user_assign_license())
|
||||||
|
|
||||||
|
|
||||||
@entitlements_bp.route('/handle_license_tier_selection', methods=['POST'])
|
@entitlements_bp.route('/handle_license_tier_selection', methods=['POST'])
|
||||||
@roles_accepted('Super User')
|
@roles_accepted('Super User', 'Partner Admin')
|
||||||
def handle_license_tier_selection():
|
def handle_license_tier_selection():
|
||||||
action = request.form['action']
|
action = request.form['action']
|
||||||
if action == 'create_license_tier':
|
if action == 'create_license_tier':
|
||||||
@@ -76,7 +100,6 @@ def handle_license_tier_selection():
|
|||||||
|
|
||||||
license_tier_identification = request.form['selected_row']
|
license_tier_identification = request.form['selected_row']
|
||||||
license_tier_id = ast.literal_eval(license_tier_identification).get('value')
|
license_tier_id = ast.literal_eval(license_tier_identification).get('value')
|
||||||
the_license_tier = LicenseTier.query.get(license_tier_id)
|
|
||||||
|
|
||||||
match action:
|
match action:
|
||||||
case 'edit_license_tier':
|
case 'edit_license_tier':
|
||||||
@@ -85,6 +108,9 @@ def handle_license_tier_selection():
|
|||||||
case 'create_license_for_tenant':
|
case 'create_license_for_tenant':
|
||||||
return redirect(prefixed_url_for('entitlements_bp.create_license',
|
return redirect(prefixed_url_for('entitlements_bp.create_license',
|
||||||
license_tier_id=license_tier_id))
|
license_tier_id=license_tier_id))
|
||||||
|
case 'associate_license_tier_to_partner':
|
||||||
|
EntitlementServices.associate_license_tier_with_partner(license_tier_id)
|
||||||
|
|
||||||
# Add more conditions for other actions
|
# Add more conditions for other actions
|
||||||
return redirect(prefixed_url_for('entitlements_bp.view_license_tiers'))
|
return redirect(prefixed_url_for('entitlements_bp.view_license_tiers'))
|
||||||
|
|
||||||
@@ -99,6 +125,8 @@ def edit_license_tier(license_tier_id):
|
|||||||
# Populate the license_tier with form data
|
# Populate the license_tier with form data
|
||||||
form.populate_obj(license_tier)
|
form.populate_obj(license_tier)
|
||||||
|
|
||||||
|
update_logging_information(license_tier, dt.now(tz.utc))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
db.session.add(license_tier)
|
db.session.add(license_tier)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -118,12 +146,15 @@ def edit_license_tier(license_tier_id):
|
|||||||
|
|
||||||
|
|
||||||
@entitlements_bp.route('/create_license/<int:license_tier_id>', methods=['GET', 'POST'])
|
@entitlements_bp.route('/create_license/<int:license_tier_id>', methods=['GET', 'POST'])
|
||||||
@roles_accepted('Super User')
|
@roles_accepted('Super User', 'Partner Admin')
|
||||||
def create_license(license_tier_id):
|
def create_license(license_tier_id):
|
||||||
form = LicenseForm()
|
form = LicenseForm()
|
||||||
tenant_id = session.get('tenant').get('id')
|
tenant_id = session.get('tenant').get('id')
|
||||||
currency = session.get('tenant').get('currency')
|
currency = session.get('tenant').get('currency')
|
||||||
|
|
||||||
|
if current_user_has_role("Partner Admin"): # The Partner Admin can only set the end date
|
||||||
|
readonly_fields = [field.name for field in form if (field.name != 'end_date' and field.name != 'start_date')]
|
||||||
|
|
||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
# Fetch the LicenseTier
|
# Fetch the LicenseTier
|
||||||
license_tier = LicenseTier.query.get_or_404(license_tier_id)
|
license_tier = LicenseTier.query.get_or_404(license_tier_id)
|
||||||
@@ -167,6 +198,8 @@ def create_license(license_tier_id):
|
|||||||
# Currency is added here again, as a form doesn't include disabled fields when passing it in the request
|
# Currency is added here again, as a form doesn't include disabled fields when passing it in the request
|
||||||
new_license.currency = currency
|
new_license.currency = currency
|
||||||
|
|
||||||
|
set_logging_information(new_license, dt.now(tz.utc))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
db.session.add(new_license)
|
db.session.add(new_license)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -178,23 +211,27 @@ def create_license(license_tier_id):
|
|||||||
else:
|
else:
|
||||||
form_validation_failed(request, form)
|
form_validation_failed(request, form)
|
||||||
|
|
||||||
return render_template('entitlements/license.html', form=form, ext_disabled_fields=[])
|
return render_template('entitlements/license.html', form=form, ext_readonly_fields=readonly_fields)
|
||||||
|
|
||||||
|
|
||||||
@entitlements_bp.route('/license/<int:license_id>', methods=['GET', 'POST'])
|
@entitlements_bp.route('/license/<int:license_id>', methods=['GET', 'POST'])
|
||||||
@roles_accepted('Super User')
|
@roles_accepted('Super User', 'Partner Admin')
|
||||||
def edit_license(license_id):
|
def edit_license(license_id):
|
||||||
license = License.query.get_or_404(license_id) # This will return a 404 if no license tier is found
|
license = License.query.get_or_404(license_id) # This will return a 404 if no license tier is found
|
||||||
form = LicenseForm(obj=license)
|
form = LicenseForm(obj=license)
|
||||||
disabled_fields = []
|
readonly_fields = []
|
||||||
if len(license.usages) > 0: # There already are usage records linked to this license
|
if len(license.usages) > 0: # There already are usage records linked to this license
|
||||||
# Define which fields should be disabled
|
# Define which fields should be disabled
|
||||||
disabled_fields = [field.name for field in form if field.name != 'end_date']
|
readonly_fields = [field.name for field in form if field.name != 'end_date']
|
||||||
|
if current_user_has_role("Partner Admin"): # The Partner Admin can only set the end date
|
||||||
|
readonly_fields = [field.name for field in form if field.name != 'end_date']
|
||||||
|
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
# Populate the license with form data
|
# Populate the license with form data
|
||||||
form.populate_obj(license)
|
form.populate_obj(license)
|
||||||
|
|
||||||
|
update_logging_information(license, dt.now(tz.utc))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
db.session.add(license)
|
db.session.add(license)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -210,7 +247,7 @@ def edit_license(license_id):
|
|||||||
else:
|
else:
|
||||||
form_validation_failed(request, form)
|
form_validation_failed(request, form)
|
||||||
|
|
||||||
return render_template('entitlements/license.html', form=form, ext_disabled_fields=disabled_fields)
|
return render_template('entitlements/license.html', form=form, ext_readonly_fields=readonly_fields)
|
||||||
|
|
||||||
|
|
||||||
@entitlements_bp.route('/view_usages')
|
@entitlements_bp.route('/view_usages')
|
||||||
|
|||||||
@@ -7,26 +7,26 @@ from itsdangerous import URLSafeTimedSerializer
|
|||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
from common.extensions import db, cache_manager
|
from common.extensions import db, cache_manager
|
||||||
from common.models.user import Partner, Tenant, PartnerService
|
from common.models.user import Partner, Tenant, PartnerService, PartnerTenant
|
||||||
from common.utils.celery_utils import current_celery
|
from common.utils.celery_utils import current_celery
|
||||||
from common.utils.eveai_exceptions import EveAIException
|
from common.utils.eveai_exceptions import EveAIException
|
||||||
from common.utils.log_utils import format_query_results
|
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.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.view_assistants import prepare_table_for_macro, form_validation_failed
|
||||||
from common.utils.nginx_utils import prefixed_url_for
|
from common.utils.nginx_utils import prefixed_url_for
|
||||||
from .administration_forms import TriggerActionForm, EditPartnerForm, PartnerServiceForm, EditPartnerServiceForm
|
from .partner_forms import TriggerActionForm, EditPartnerForm, PartnerServiceForm, EditPartnerServiceForm
|
||||||
|
|
||||||
administration_bp = Blueprint('administration_bp', __name__, url_prefix='/administration')
|
partner_bp = Blueprint('partner_bp', __name__, url_prefix='/partner')
|
||||||
|
|
||||||
|
|
||||||
@administration_bp.route('/trigger_actions', methods=['GET'])
|
@partner_bp.route('/trigger_actions', methods=['GET'])
|
||||||
@roles_accepted('Super User')
|
@roles_accepted('Super User')
|
||||||
def trigger_actions():
|
def trigger_actions():
|
||||||
form = TriggerActionForm()
|
form = TriggerActionForm()
|
||||||
return render_template('administration/trigger_actions.html', form=form)
|
return render_template('partner/trigger_actions.html', form=form)
|
||||||
|
|
||||||
|
|
||||||
@administration_bp.route('/handle_trigger_action', methods=['POST'])
|
@partner_bp.route('/handle_trigger_action', methods=['POST'])
|
||||||
@roles_accepted('Super User')
|
@roles_accepted('Super User')
|
||||||
def handle_trigger_action():
|
def handle_trigger_action():
|
||||||
action = request.form['action']
|
action = request.form['action']
|
||||||
@@ -42,10 +42,10 @@ def handle_trigger_action():
|
|||||||
current_app.logger.error(f"Failed to trigger usage update task: {str(e)}")
|
current_app.logger.error(f"Failed to trigger usage update task: {str(e)}")
|
||||||
flash(f'Failed to trigger usage update: {str(e)}', 'danger')
|
flash(f'Failed to trigger usage update: {str(e)}', 'danger')
|
||||||
|
|
||||||
return redirect(prefixed_url_for('administration_bp.trigger_actions'))
|
return redirect(prefixed_url_for('partner_bp.trigger_actions'))
|
||||||
|
|
||||||
|
|
||||||
@administration_bp.route('/partner/<int:partner_id>', methods=['GET', 'POST'])
|
@partner_bp.route('/partner/<int:partner_id>', methods=['GET', 'POST'])
|
||||||
@roles_accepted('Super User')
|
@roles_accepted('Super User')
|
||||||
def edit_partner(partner_id):
|
def edit_partner(partner_id):
|
||||||
partner = Partner.query.get_or_404(partner_id) # This will return a 404 if no partner is found
|
partner = Partner.query.get_or_404(partner_id) # This will return a 404 if no partner is found
|
||||||
@@ -63,15 +63,15 @@ def edit_partner(partner_id):
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash('Partner updated successfully.', 'success')
|
flash('Partner updated successfully.', 'success')
|
||||||
return redirect(
|
return redirect(
|
||||||
prefixed_url_for('administration_bp.edit_partner',
|
prefixed_url_for('partner_bp.edit_partner',
|
||||||
partner_id=partner.id)) # Assuming there's a user profile view to redirect to
|
partner_id=partner.id)) # Assuming there's a user profile view to redirect to
|
||||||
else:
|
else:
|
||||||
form_validation_failed(request, form)
|
form_validation_failed(request, form)
|
||||||
|
|
||||||
return render_template('administration/edit_partner.html', form=form, partner_id=partner_id)
|
return render_template('partner/edit_partner.html', form=form, partner_id=partner_id)
|
||||||
|
|
||||||
|
|
||||||
@administration_bp.route('/partners', methods=['GET', 'POST'])
|
@partner_bp.route('/partners', methods=['GET', 'POST'])
|
||||||
@roles_accepted('Super User')
|
@roles_accepted('Super User')
|
||||||
def partners():
|
def partners():
|
||||||
page = request.args.get('page', 1, type=int)
|
page = request.args.get('page', 1, type=int)
|
||||||
@@ -95,21 +95,21 @@ def partners():
|
|||||||
rows = prepare_table_for_macro(the_partners, [('id', ''), ('name', '')])
|
rows = prepare_table_for_macro(the_partners, [('id', ''), ('name', '')])
|
||||||
|
|
||||||
# Render the catalogs in a template
|
# Render the catalogs in a template
|
||||||
return render_template('administration/partners.html', rows=rows, pagination=pagination)
|
return render_template('partner/partners.html', rows=rows, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
@administration_bp.route('/handle_partner_selection', methods=['POST'])
|
@partner_bp.route('/handle_partner_selection', methods=['POST'])
|
||||||
@roles_accepted('Super User')
|
@roles_accepted('Super User')
|
||||||
def handle_partner_selection():
|
def handle_partner_selection():
|
||||||
action = request.form['action']
|
action = request.form['action']
|
||||||
if action == 'create_partner':
|
if action == 'create_partner':
|
||||||
try:
|
try:
|
||||||
partner_id = register_partner_from_tenant(session['tenant']['id'])
|
partner_id = register_partner_from_tenant(session['tenant']['id'])
|
||||||
return redirect(prefixed_url_for('administration_bp.edit_partner', partner_id=partner_id, ))
|
return redirect(prefixed_url_for('partner_bp.edit_partner', partner_id=partner_id, ))
|
||||||
except EveAIException as e:
|
except EveAIException as e:
|
||||||
current_app.logger.error(f'Error registering partner for tenant {session['tenant']['id']}: {str(e)}')
|
current_app.logger.error(f'Error registering partner for tenant {session['tenant']['id']}: {str(e)}')
|
||||||
flash('Error Registering Partner for Selected Tenant', 'danger')
|
flash('Error Registering Partner for Selected Tenant', 'danger')
|
||||||
return redirect(prefixed_url_for('administration_bp.partners'))
|
return redirect(prefixed_url_for('partner_bp.partners'))
|
||||||
partner_identification = request.form.get('selected_row')
|
partner_identification = request.form.get('selected_row')
|
||||||
partner_id = ast.literal_eval(partner_identification).get('value')
|
partner_id = ast.literal_eval(partner_identification).get('value')
|
||||||
partner = Partner.query.get_or_404(partner_id)
|
partner = Partner.query.get_or_404(partner_id)
|
||||||
@@ -118,12 +118,12 @@ def handle_partner_selection():
|
|||||||
current_app.logger.info(f"Setting session partner: {partner.id}")
|
current_app.logger.info(f"Setting session partner: {partner.id}")
|
||||||
session['partner'] = partner.to_dict()
|
session['partner'] = partner.to_dict()
|
||||||
elif action == 'edit_partner':
|
elif action == 'edit_partner':
|
||||||
return redirect(prefixed_url_for('administration_bp.edit_partner', partner_id=partner_id))
|
return redirect(prefixed_url_for('partner_bp.edit_partner', partner_id=partner_id))
|
||||||
|
|
||||||
return redirect(prefixed_url_for('administration_bp.partners'))
|
return redirect(prefixed_url_for('partner_bp.partners'))
|
||||||
|
|
||||||
|
|
||||||
@administration_bp.route('/partner_service', methods=['GET', 'POST'])
|
@partner_bp.route('/partner_service', methods=['GET', 'POST'])
|
||||||
@roles_accepted('Super User')
|
@roles_accepted('Super User')
|
||||||
def partner_service():
|
def partner_service():
|
||||||
form = PartnerServiceForm()
|
form = PartnerServiceForm()
|
||||||
@@ -132,7 +132,7 @@ def partner_service():
|
|||||||
partner = session.get('partner', None)
|
partner = session.get('partner', None)
|
||||||
if not partner:
|
if not partner:
|
||||||
flash('No partner has been selected. Set partner before adding services.', 'warning')
|
flash('No partner has been selected. Set partner before adding services.', 'warning')
|
||||||
return redirect(prefixed_url_for('administration_bp.partners'))
|
return redirect(prefixed_url_for('partner_bp.partners'))
|
||||||
partner_id = partner['id']
|
partner_id = partner['id']
|
||||||
new_partner_service = PartnerService()
|
new_partner_service = PartnerService()
|
||||||
form.populate_obj(new_partner_service)
|
form.populate_obj(new_partner_service)
|
||||||
@@ -145,17 +145,17 @@ def partner_service():
|
|||||||
flash('Partner Service successfully added!', 'success')
|
flash('Partner Service successfully added!', 'success')
|
||||||
current_app.logger.info(f"Partner Service {new_partner_service.name} added successfully for {partner_id}")
|
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)
|
# Step 2 of the creation process (depending on type)
|
||||||
return redirect(prefixed_url_for('administration_bp.partner_service',
|
return redirect(prefixed_url_for('partner_bp.partner_service',
|
||||||
partner_service_id=new_partner_service.id))
|
partner_service_id=new_partner_service.id))
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
flash(f'Failed to add Partner Service: {str(e)}', 'danger')
|
flash(f'Failed to add Partner Service: {str(e)}', 'danger')
|
||||||
current_app.logger.error(f"Failed to add Partner Service {new_partner_service.name} "
|
current_app.logger.error(f"Failed to add Partner Service {new_partner_service.name} "
|
||||||
f"for partner {partner_id}. Error: {str(e)}")
|
f"for partner {partner_id}. Error: {str(e)}")
|
||||||
return render_template('administration/partner_service.html', form=form)
|
return render_template('partner/partner_service.html', form=form)
|
||||||
|
|
||||||
|
|
||||||
@administration_bp.route('/partner_service/<int:partner_service_id>', methods=['GET', 'POST'])
|
@partner_bp.route('/partner_service/<int:partner_service_id>', methods=['GET', 'POST'])
|
||||||
@roles_accepted('Super User')
|
@roles_accepted('Super User')
|
||||||
def edit_partner_service(partner_service_id):
|
def edit_partner_service(partner_service_id):
|
||||||
partner_service = PartnerService.query.get_or_404(partner_service_id)
|
partner_service = PartnerService.query.get_or_404(partner_service_id)
|
||||||
@@ -200,18 +200,18 @@ def edit_partner_service(partner_service_id):
|
|||||||
flash(f'Failed to update Partner Service: {str(e)}', 'danger')
|
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}. "
|
current_app.logger.error(f"Failed to update Partner Service {partner_service.id} for partner {partner_id}. "
|
||||||
f"Error: {str(e)} ")
|
f"Error: {str(e)} ")
|
||||||
return render_template('administration/edit_partner_service.html', form=form,
|
return render_template('partner/edit_partner_service.html', form=form,
|
||||||
partner_service_id=partner_service_id)
|
partner_service_id=partner_service_id)
|
||||||
|
|
||||||
return redirect(prefixed_url_for('administration_bp.partner_services'))
|
return redirect(prefixed_url_for('partner_bp.partner_services'))
|
||||||
else:
|
else:
|
||||||
form_validation_failed(request, form)
|
form_validation_failed(request, form)
|
||||||
|
|
||||||
return render_template('administration/edit_partner_service.html', form=form,
|
return render_template('partner/edit_partner_service.html', form=form,
|
||||||
partner_service_id=partner_service_id)
|
partner_service_id=partner_service_id)
|
||||||
|
|
||||||
|
|
||||||
@administration_bp.route('/partner_services', methods=['GET', 'POST'])
|
@partner_bp.route('/partner_services', methods=['GET', 'POST'])
|
||||||
@roles_accepted('Super User')
|
@roles_accepted('Super User')
|
||||||
def partner_services():
|
def partner_services():
|
||||||
page = request.args.get('page', 1, type=int)
|
page = request.args.get('page', 1, type=int)
|
||||||
@@ -219,7 +219,7 @@ def partner_services():
|
|||||||
partner = session.get('partner', None)
|
partner = session.get('partner', None)
|
||||||
if not partner:
|
if not partner:
|
||||||
flash('No partner has been selected. Set partner before adding services.', 'warning')
|
flash('No partner has been selected. Set partner before adding services.', 'warning')
|
||||||
return redirect(prefixed_url_for('administration_bp.partners'))
|
return redirect(prefixed_url_for('partner_bp.partners'))
|
||||||
partner_id = session['partner']['id']
|
partner_id = session['partner']['id']
|
||||||
|
|
||||||
query = PartnerService.query.filter(PartnerService.partner_id == partner_id)
|
query = PartnerService.query.filter(PartnerService.partner_id == partner_id)
|
||||||
@@ -230,24 +230,28 @@ def partner_services():
|
|||||||
# prepare table data
|
# prepare table data
|
||||||
rows = prepare_table_for_macro(the_partner_services, [('id', ''), ('name', ''), ('type', '')])
|
rows = prepare_table_for_macro(the_partner_services, [('id', ''), ('name', ''), ('type', '')])
|
||||||
|
|
||||||
return render_template('administration/partner_services.html', rows=rows, pagination=pagination)
|
return render_template('partner/partner_services.html', rows=rows, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
@administration_bp.route('/handle_partner_service_selection', methods=['POST'])
|
@partner_bp.route('/handle_partner_service_selection', methods=['POST'])
|
||||||
@roles_accepted('Super User')
|
@roles_accepted('Super User')
|
||||||
def handle_partner_service_selection():
|
def handle_partner_service_selection():
|
||||||
action = request.form['action']
|
action = request.form['action']
|
||||||
if action == 'create_partner_service':
|
if action == 'create_partner_service':
|
||||||
return redirect(prefixed_url_for('administration_bp.partner_service'))
|
return redirect(prefixed_url_for('partner_bp.partner_service'))
|
||||||
|
|
||||||
partner_service_identification = request.form.get('selected_row')
|
partner_service_identification = request.form.get('selected_row')
|
||||||
partner_service_id = ast.literal_eval(partner_service_identification).get('value')
|
partner_service_id = ast.literal_eval(partner_service_identification).get('value')
|
||||||
|
|
||||||
if action == 'edit_partner_service':
|
if action == 'edit_partner_service':
|
||||||
return redirect(prefixed_url_for('administration_bp.edit_partner_service',
|
return redirect(prefixed_url_for('partner_bp.edit_partner_service',
|
||||||
|
partner_service_id=partner_service_id))
|
||||||
|
elif action == 'add_partner_service_for_tenant':
|
||||||
|
add_partner_service_for_tenant(partner_service_id)
|
||||||
|
return redirect(prefixed_url_for('partner_bp.edit_partner_service',
|
||||||
partner_service_id=partner_service_id))
|
partner_service_id=partner_service_id))
|
||||||
|
|
||||||
return redirect(prefixed_url_for('administration_bp.partner_services'))
|
return redirect(prefixed_url_for('partner_bp.partner_services'))
|
||||||
|
|
||||||
|
|
||||||
def register_partner_from_tenant(tenant_id):
|
def register_partner_from_tenant(tenant_id):
|
||||||
@@ -270,3 +274,67 @@ def register_partner_from_tenant(tenant_id):
|
|||||||
raise EveAIException(f"Failed to register partner for tenant {tenant_id}. Error: {str(e)}")
|
raise EveAIException(f"Failed to register partner for tenant {tenant_id}. Error: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
def add_partner_service_for_tenant(partner_service_id):
|
||||||
|
"""
|
||||||
|
Associate a partner service with the current tenant
|
||||||
|
|
||||||
|
Args:
|
||||||
|
partner_service_id: ID of the partner service to associate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Redirect to appropriate page based on result
|
||||||
|
"""
|
||||||
|
tenant = session.get('tenant', None)
|
||||||
|
if not tenant:
|
||||||
|
flash('No tenant has been selected. Set tenant before adding services.', 'warning')
|
||||||
|
return redirect(prefixed_url_for('user_bp.tenants'))
|
||||||
|
|
||||||
|
tenant_id = tenant['id']
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if the partner service exists
|
||||||
|
partner_service = PartnerService.query.get(partner_service_id)
|
||||||
|
if not partner_service:
|
||||||
|
flash(f'Partner service with ID {partner_service_id} not found.', 'danger')
|
||||||
|
return redirect(prefixed_url_for('partner_bp.partner_services'))
|
||||||
|
|
||||||
|
# Check if the association already exists
|
||||||
|
existing = PartnerTenant.query.filter_by(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
partner_service_id=partner_service_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
flash(f'This tenant already has access to this partner service.', 'warning')
|
||||||
|
return redirect(prefixed_url_for('partner_bp.partner_services'))
|
||||||
|
|
||||||
|
# Create new association
|
||||||
|
new_partner_tenant = PartnerTenant(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
partner_service_id=partner_service_id,
|
||||||
|
# Add any additional fields needed for your model
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set logging information
|
||||||
|
set_logging_information(new_partner_tenant, dt.now(tz.utc))
|
||||||
|
|
||||||
|
# Save to database
|
||||||
|
db.session.add(new_partner_tenant)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Get partner name for the flash message
|
||||||
|
partner = Partner.query.get(partner_service.partner_id)
|
||||||
|
partner_tenant = Tenant.query.get(partner.tenant_id) if partner else None
|
||||||
|
partner_name = partner_tenant.name if partner_tenant else 'Unknown Partner'
|
||||||
|
|
||||||
|
flash(f'Successfully added {partner_service.type} service from {partner_name} to this tenant.', 'success')
|
||||||
|
return redirect(prefixed_url_for('partner_bp.partner_services'))
|
||||||
|
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
db.session.rollback()
|
||||||
|
current_app.logger.error(f"Database error adding partner service: {str(e)}")
|
||||||
|
flash(f'Error adding partner service: {str(e)}', 'danger')
|
||||||
|
return redirect(prefixed_url_for('partner_bp.partner_services'))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ import pytz
|
|||||||
from flask_security import current_user
|
from flask_security import current_user
|
||||||
|
|
||||||
from common.models.user import Role
|
from common.models.user import Role
|
||||||
from common.services.user_service import UserService
|
from common.services.user_services import UserServices
|
||||||
from config.type_defs.service_types import SERVICE_TYPES
|
from config.type_defs.service_types import SERVICE_TYPES
|
||||||
|
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ class BaseUserForm(FlaskForm):
|
|||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super(BaseUserForm, self).__init__(*args, **kwargs)
|
super(BaseUserForm, self).__init__(*args, **kwargs)
|
||||||
self.roles.choices = UserService.get_assignable_roles()
|
self.roles.choices = UserServices.get_assignable_roles()
|
||||||
|
|
||||||
|
|
||||||
class CreateUserForm(BaseUserForm):
|
class CreateUserForm(BaseUserForm):
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import ast
|
|||||||
|
|
||||||
from common.models.user import User, Tenant, Role, TenantDomain, TenantProject, PartnerTenant
|
from common.models.user import User, Tenant, Role, TenantDomain, TenantProject, PartnerTenant
|
||||||
from common.extensions import db, security, minio_client, simple_encryption
|
from common.extensions import db, security, minio_client, simple_encryption
|
||||||
from common.services.user_service import UserService
|
from common.services.user_services import UserServices
|
||||||
from common.utils.security_utils import send_confirmation_email, send_reset_email
|
from common.utils.security_utils import send_confirmation_email, send_reset_email
|
||||||
from config.type_defs.service_types import SERVICE_TYPES
|
from config.type_defs.service_types import SERVICE_TYPES
|
||||||
from .user_forms import TenantForm, CreateUserForm, EditUserForm, TenantDomainForm, TenantSelectionForm, \
|
from .user_forms import TenantForm, CreateUserForm, EditUserForm, TenantDomainForm, TenantSelectionForm, \
|
||||||
@@ -19,7 +19,8 @@ from common.utils.simple_encryption import generate_api_key
|
|||||||
from common.utils.nginx_utils import prefixed_url_for
|
from common.utils.nginx_utils import prefixed_url_for
|
||||||
from common.utils.eveai_exceptions import EveAIException
|
from common.utils.eveai_exceptions import EveAIException
|
||||||
from common.utils.document_utils import set_logging_information, update_logging_information
|
from common.utils.document_utils import set_logging_information, update_logging_information
|
||||||
from common.services.tenant_service import TenantService
|
from common.services.tenant_services import TenantServices
|
||||||
|
from common.services.user_services import UserServices
|
||||||
|
|
||||||
user_bp = Blueprint('user_bp', __name__, url_prefix='/user')
|
user_bp = Blueprint('user_bp', __name__, url_prefix='/user')
|
||||||
|
|
||||||
@@ -37,6 +38,10 @@ def log_after_request(response):
|
|||||||
@user_bp.route('/tenant', methods=['GET', 'POST'])
|
@user_bp.route('/tenant', methods=['GET', 'POST'])
|
||||||
@roles_accepted('Super User', 'Partner Admin')
|
@roles_accepted('Super User', 'Partner Admin')
|
||||||
def tenant():
|
def tenant():
|
||||||
|
if not current_user.has_roles('Partner Admin') and UserServices.can_user_create_tenant():
|
||||||
|
current_app.logger.error(f'User {current_user.email} cannot create tenant in the current user')
|
||||||
|
flash(f"You don't have the appropriate permissions to create a tenant", 'danger')
|
||||||
|
return redirect(prefixed_url_for('user_bp.select_tenant'))
|
||||||
form = TenantForm()
|
form = TenantForm()
|
||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
code = f"TENANT-{str(uuid.uuid4())}"
|
code = f"TENANT-{str(uuid.uuid4())}"
|
||||||
@@ -58,10 +63,10 @@ def tenant():
|
|||||||
|
|
||||||
if current_user.has_roles('Partner Admin') and 'partner' in session:
|
if current_user.has_roles('Partner Admin') and 'partner' in session:
|
||||||
# Always associate with the partner for Partner Admins
|
# Always associate with the partner for Partner Admins
|
||||||
TenantService.associate_tenant_with_partner(new_tenant.id)
|
TenantServices.associate_tenant_with_partner(new_tenant.id)
|
||||||
elif current_user.has_roles('Super User') and form.assign_to_partner.data and 'partner' in session:
|
elif current_user.has_roles('Super User') and form.assign_to_partner.data and 'partner' in session:
|
||||||
# Super User chose to associate with partner
|
# Super User chose to associate with partner
|
||||||
TenantService.associate_tenant_with_partner(new_tenant.id)
|
TenantServices.associate_tenant_with_partner(new_tenant.id)
|
||||||
|
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
current_app.logger.error(f'Failed to add tenant to database. Error: {str(e)}')
|
current_app.logger.error(f'Failed to add tenant to database. Error: {str(e)}')
|
||||||
@@ -183,7 +188,7 @@ def edit_user(user_id):
|
|||||||
# Update roles
|
# Update roles
|
||||||
current_roles = set(role.id for role in user.roles)
|
current_roles = set(role.id for role in user.roles)
|
||||||
selected_roles = set(form.roles.data)
|
selected_roles = set(form.roles.data)
|
||||||
if UserService.validate_role_assignments(selected_roles):
|
if UserServices.validate_role_assignments(selected_roles):
|
||||||
# Add new roles
|
# Add new roles
|
||||||
for role_id in selected_roles - current_roles:
|
for role_id in selected_roles - current_roles:
|
||||||
role = Role.query.get(role_id)
|
role = Role.query.get(role_id)
|
||||||
@@ -273,10 +278,10 @@ def handle_tenant_selection():
|
|||||||
|
|
||||||
tenant_identification = request.form['selected_row']
|
tenant_identification = request.form['selected_row']
|
||||||
tenant_id = ast.literal_eval(tenant_identification).get('value')
|
tenant_id = ast.literal_eval(tenant_identification).get('value')
|
||||||
if not TenantService.can_user_edit_tenant(tenant_id):
|
if not UserServices.can_user_edit_tenant(tenant_id):
|
||||||
current_app.logger.info(f"User not authenticated to edit tenant {tenant_id}.")
|
current_app.logger.info(f"User not authenticated to edit tenant {tenant_id}.")
|
||||||
flash(f"You are not authenticated to manage tenant {tenant_id}", 'danger')
|
flash(f"You are not authenticated to manage tenant {tenant_id}", 'danger')
|
||||||
return redirect(prefixed_url_for('select_tenant'))
|
return redirect(prefixed_url_for('user_bp.select_tenant'))
|
||||||
the_tenant = Tenant.query.get(tenant_id)
|
the_tenant = Tenant.query.get(tenant_id)
|
||||||
|
|
||||||
# set tenant information in the session
|
# set tenant information in the session
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Add logging information to LicenseTiers and Licenses
|
||||||
|
|
||||||
|
Revision ID: 43a9f29fb214
|
||||||
|
Revises: bb27b5e41854
|
||||||
|
Create Date: 2025-04-23 05:53:55.682044
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '43a9f29fb214'
|
||||||
|
down_revision = 'bb27b5e41854'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('license', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('created_by', sa.Integer(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('updated_by', sa.Integer(), nullable=True))
|
||||||
|
batch_op.create_foreign_key(None, 'user', ['created_by'], ['id'], referent_schema='public')
|
||||||
|
batch_op.create_foreign_key(None, 'user', ['updated_by'], ['id'], referent_schema='public')
|
||||||
|
|
||||||
|
with op.batch_alter_table('license_tier', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('created_by', sa.Integer(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('updated_by', sa.Integer(), nullable=True))
|
||||||
|
batch_op.create_foreign_key(None, 'user', ['created_by'], ['id'], referent_schema='public')
|
||||||
|
batch_op.create_foreign_key(None, 'user', ['updated_by'], ['id'], referent_schema='public')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('license_tier', schema=None) as batch_op:
|
||||||
|
batch_op.drop_constraint(None, type_='foreignkey')
|
||||||
|
batch_op.drop_constraint(None, type_='foreignkey')
|
||||||
|
batch_op.drop_column('updated_by')
|
||||||
|
batch_op.drop_column('updated_at')
|
||||||
|
batch_op.drop_column('created_by')
|
||||||
|
batch_op.drop_column('created_at')
|
||||||
|
|
||||||
|
with op.batch_alter_table('license', schema=None) as batch_op:
|
||||||
|
batch_op.drop_constraint(None, type_='foreignkey')
|
||||||
|
batch_op.drop_constraint(None, type_='foreignkey')
|
||||||
|
batch_op.drop_constraint(None, type_='foreignkey')
|
||||||
|
batch_op.drop_constraint(None, type_='foreignkey')
|
||||||
|
batch_op.create_foreign_key('license_tenant_id_fkey', 'tenant', ['tenant_id'], ['id'])
|
||||||
|
batch_op.create_foreign_key('license_tier_id_fkey', 'license_tier', ['tier_id'], ['id'])
|
||||||
|
batch_op.drop_column('updated_by')
|
||||||
|
batch_op.drop_column('updated_at')
|
||||||
|
batch_op.drop_column('created_by')
|
||||||
|
batch_op.drop_column('created_at')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Adding table to associate License Tiers with Partner Services
|
||||||
|
|
||||||
|
Revision ID: bb27b5e41854
|
||||||
|
Revises: 605395afc22f
|
||||||
|
Create Date: 2025-04-22 20:20:34.526308
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'bb27b5e41854'
|
||||||
|
down_revision = '605395afc22f'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('partner_service_license_tier',
|
||||||
|
sa.Column('partner_service_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('license_tier_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
|
||||||
|
sa.Column('created_by', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
|
||||||
|
sa.Column('updated_by', sa.Integer(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['created_by'], ['public.user.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['license_tier_id'], ['public.license_tier.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['partner_service_id'], ['public.partner_service.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['updated_by'], ['public.user.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('partner_service_id', 'license_tier_id'),
|
||||||
|
schema='public'
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('partner_service_license_tier', schema='public')
|
||||||
|
# ### end Alembic commands ###
|
||||||
Reference in New Issue
Block a user