- Improvement of Entitlements Domain

- Introduction of LicensePeriod
  - Introduction of Payments
  - Introduction of Invoices
- Services definitions for Entitlements Domain
This commit is contained in:
Josako
2025-05-16 09:06:13 +02:00
parent 1b1eef0d2e
commit b4f7b210e0
15 changed files with 717 additions and 201 deletions

View File

@@ -151,6 +151,8 @@ def register_cache_handlers(app):
register_config_cache_handlers(cache_manager) register_config_cache_handlers(cache_manager)
from common.utils.cache.crewai_processed_config_cache import register_specialist_cache_handlers from common.utils.cache.crewai_processed_config_cache import register_specialist_cache_handlers
register_specialist_cache_handlers(cache_manager) register_specialist_cache_handlers(cache_manager)
from common.utils.cache.license_cache import register_license_cache_handlers
register_license_cache_handlers(cache_manager)

View File

@@ -9,7 +9,7 @@
{% block content %} {% block content %}
<form method="post"> <form method="post">
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
{% set main_fields = ['start_date', 'end_date', 'currency', 'yearly_payment', 'basic_fee'] %} {% set main_fields = ['start_date', 'nr_of_periods', 'currency', 'yearly_payment', 'basic_fee'] %}
{% for field in form %} {% for field in form %}
{{ render_included_field(field, readonly_fields=ext_readonly_fields + ['currency'], include_fields=main_fields) }} {{ render_included_field(field, readonly_fields=ext_readonly_fields + ['currency'], include_fields=main_fields) }}
{% endfor %} {% endfor %}

View File

@@ -9,7 +9,7 @@
{% block content %} {% block content %}
<form method="post"> <form method="post">
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
{% set main_fields = ['start_date', 'end_date', 'currency', 'yearly_payment', 'basic_fee'] %} {% set main_fields = ['start_date', 'nr_of_periods', 'currency', 'yearly_payment', 'basic_fee'] %}
{% for field in form %} {% for field in form %}
{{ render_included_field(field, readonly_fields=ext_readonly_fields + ['currency'], include_fields=main_fields) }} {{ render_included_field(field, readonly_fields=ext_readonly_fields + ['currency'], include_fields=main_fields) }}
{% endfor %} {% endfor %}

View File

@@ -0,0 +1,398 @@
{% extends 'base.html' %}
{% from "macros.html" import render_selectable_table %}
{% block title %}License Periods - {{ license.id }}{% endblock %}
{% block content_title %}License Periods{% endblock %}
{% block content_description %}License: {{ license.id }} | Tier: {{ license.license_tier.name }} | Periods: {{ periods|length }}{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- License Summary Card -->
<div class="card mb-4">
<div class="card-body">
<div class="row">
<div class="col-md-3">
<strong>License ID:</strong> {{ license.id }}
</div>
<div class="col-md-3">
<strong>Start Date:</strong> {{ license.start_date }}
</div>
<div class="col-md-3">
<strong>Total Periods:</strong> {{ license.nr_of_periods }}
</div>
<div class="col-md-3">
<strong>Currency:</strong> {{ license.currency }}
</div>
</div>
</div>
</div>
<!-- Periods Table -->
<div class="card">
<div class="card-header">
<h5>License Periods</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Period</th>
<th>Start Date</th>
<th>End Date</th>
<th>Status</th>
<th>Usage</th>
<th>Payments</th>
<th>Invoices</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for period in periods %}
<tr class="period-row" data-period-id="{{ period.id }}">
<td>{{ period.period_number }}</td>
<td>{{ period.period_start }}</td>
<td>{{ period.period_end }}</td>
<td>
<span class="badge badge-{{ period.status.name|status_color }}">
{{ period.status.name }}
</span>
</td>
<td>
{% set usage = usage_by_period.get(period.id) %}
{% if usage %}
<small>
S: {{ "%.1f"|format(usage.storage_mb_used or 0) }}MB<br>
E: {{ "%.1f"|format(usage.embedding_mb_used or 0) }}MB<br>
I: {{ "{:,}"|format(usage.interaction_total_tokens_used or 0) }}
</small>
{% else %}
<span class="text-muted">No usage</span>
{% endif %}
</td>
<td>
{% set payments = payments_by_period.get(period.id, []) %}
<small>{{ payments|length }} payment(s)</small>
</td>
<td>
{% set invoices = invoices_by_period.get(period.id, []) %}
<small>{{ invoices|length }} invoice(s)</small>
</td>
<td>
<button class="btn btn-sm btn-info" onclick="showPeriodDetails({{ period.id }})">
Details
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Period Details Modal -->
<div class="modal fade" id="periodDetailsModal" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="periodModalTitle">Period Details</h5>
<button type="button" class="close" data-dismiss="modal">
<span>&times;</span>
</button>
</div>
<div class="modal-body">
<!-- Nav Tabs -->
<ul class="nav nav-tabs" id="periodTabs" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="status-tab" data-toggle="tab" href="#status" role="tab">
Status & Timeline
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="usage-tab" data-toggle="tab" href="#usage" role="tab">
Usage
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="financial-tab" data-toggle="tab" href="#financial" role="tab">
Financial
</a>
</li>
</ul>
<!-- Tab Content -->
<div class="tab-content mt-3">
<!-- Status Tab -->
<div class="tab-pane fade show active" id="status" role="tabpanel">
<div id="statusContent"></div>
</div>
<!-- Usage Tab -->
<div class="tab-pane fade" id="usage" role="tabpanel">
<div id="usageContent"></div>
</div>
<!-- Financial Tab -->
<div class="tab-pane fade" id="financial" role="tabpanel">
<div id="financialContent"></div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Period data for JavaScript access
const periodsData = {
{% for period in periods %}
{{ period.id }}: {
period_number: {{ period.period_number }},
period_start: "{{ period.period_start }}",
period_end: "{{ period.period_end }}",
status: "{{ period.status.name }}",
upcoming_at: "{{ period.upcoming_at or '' }}",
pending_at: "{{ period.pending_at or '' }}",
active_at: "{{ period.active_at or '' }}",
completed_at: "{{ period.completed_at or '' }}",
invoiced_at: "{{ period.invoiced_at or '' }}",
closed_at: "{{ period.closed_at or '' }}",
usage: {{ usage_by_period.get(period.id).to_dict() if usage_by_period.get(period.id) else 'null' }},
payments: [
{% for payment in payments_by_period.get(period.id, []) %}
{
id: {{ payment.id }},
type: "{{ payment.payment_type.name }}",
amount: {{ payment.amount }},
currency: "{{ payment.currency }}",
status: "{{ payment.status.name }}",
paid_at: "{{ payment.paid_at or '' }}"
}{% if not loop.last %},{% endif %}
{% endfor %}
],
invoices: [
{% for invoice in invoices_by_period.get(period.id, []) %}
{
id: {{ invoice.id }},
type: "{{ invoice.invoice_type.name }}",
number: "{{ invoice.invoice_number }}",
amount: {{ invoice.amount }},
currency: "{{ invoice.currency }}",
status: "{{ invoice.status.name }}",
due_date: "{{ invoice.due_date }}"
}{% if not loop.last %},{% endif %}
{% endfor %}
]
}{% if not loop.last %},{% endif %}
{% endfor %}
};
function showPeriodDetails(periodId) {
const period = periodsData[periodId];
if (!period) return;
// Update modal title
document.getElementById('periodModalTitle').textContent = `Period ${period.period_number} Details`;
// Update status content
updateStatusContent(period);
updateUsageContent(period);
updateFinancialContent(period);
// Show modal
$('#periodDetailsModal').modal('show');
}
function updateStatusContent(period) {
const statusContent = document.getElementById('statusContent');
const statusDates = [
{ label: 'Upcoming', date: period.upcoming_at },
{ label: 'Pending', date: period.pending_at },
{ label: 'Active', date: period.active_at },
{ label: 'Completed', date: period.completed_at },
{ label: 'Invoiced', date: period.invoiced_at },
{ label: 'Closed', date: period.closed_at }
];
let html = `
<div class="row">
<div class="col-md-6">
<h6>Current Status</h6>
<span class="badge badge-${getStatusColor(period.status)} badge-lg">${period.status}</span>
</div>
<div class="col-md-6">
<h6>Period Dates</h6>
<p><strong>Start:</strong> ${period.period_start}<br>
<strong>End:</strong> ${period.period_end}</p>
</div>
</div>
<hr>
<h6>Status Timeline</h6>
<div class="timeline">
`;
statusDates.forEach(item => {
if (item.date) {
html += `
<div class="timeline-item">
<strong>${item.label}:</strong> ${item.date}
</div>
`;
}
});
html += '</div>';
statusContent.innerHTML = html;
}
function updateUsageContent(period) {
const usageContent = document.getElementById('usageContent');
if (!period.usage) {
usageContent.innerHTML = '<p class="text-muted">No usage data available</p>';
return;
}
const usage = period.usage;
usageContent.innerHTML = `
<div class="row">
<div class="col-md-4">
<div class="card">
<div class="card-body text-center">
<h5>Storage</h5>
<h3>${(usage.storage_mb_used || 0).toFixed(1)} MB</h3>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body text-center">
<h5>Embedding</h5>
<h3>${(usage.embedding_mb_used || 0).toFixed(1)} MB</h3>
<small>Tokens: ${(usage.embedding_total_tokens_used || 0).toLocaleString()}</small>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body text-center">
<h5>Interaction</h5>
<h3>${(usage.interaction_total_tokens_used || 0).toLocaleString()}</h3>
<small>Prompt: ${(usage.interaction_prompt_tokens_used || 0).toLocaleString()}<br>
Completion: ${(usage.interaction_completion_tokens_used || 0).toLocaleString()}</small>
</div>
</div>
</div>
</div>
`;
}
function updateFinancialContent(period) {
const financialContent = document.getElementById('financialContent');
let html = '<div class="row">';
// Payments section
html += '<div class="col-md-6"><h6>Payments</h6>';
if (period.payments.length === 0) {
html += '<p class="text-muted">No payments</p>';
} else {
html += '<div class="table-responsive"><table class="table table-sm">';
html += '<thead><tr><th>Type</th><th>Amount</th><th>Status</th><th>Date</th></tr></thead><tbody>';
period.payments.forEach(payment => {
html += `
<tr>
<td>${payment.type}</td>
<td>${payment.amount} ${payment.currency}</td>
<td><span class="badge badge-${getPaymentStatusColor(payment.status)}">${payment.status}</span></td>
<td>${payment.paid_at || '-'}</td>
</tr>
`;
});
html += '</tbody></table></div>';
}
html += '</div>';
// Invoices section
html += '<div class="col-md-6"><h6>Invoices</h6>';
if (period.invoices.length === 0) {
html += '<p class="text-muted">No invoices</p>';
} else {
html += '<div class="table-responsive"><table class="table table-sm">';
html += '<thead><tr><th>Type</th><th>Number</th><th>Amount</th><th>Status</th><th>Due</th></tr></thead><tbody>';
period.invoices.forEach(invoice => {
html += `
<tr>
<td>${invoice.type}</td>
<td>${invoice.number}</td>
<td>${invoice.amount} ${invoice.currency}</td>
<td><span class="badge badge-${getInvoiceStatusColor(invoice.status)}">${invoice.status}</span></td>
<td>${invoice.due_date}</td>
</tr>
`;
});
html += '</tbody></table></div>';
}
html += '</div></div>';
financialContent.innerHTML = html;
}
function getStatusColor(status) {
const colors = {
'UPCOMING': 'secondary',
'PENDING': 'warning',
'ACTIVE': 'success',
'COMPLETED': 'info',
'INVOICED': 'primary',
'CLOSED': 'dark'
};
return colors[status] || 'secondary';
}
function getPaymentStatusColor(status) {
const colors = {
'PENDING': 'warning',
'PAID': 'success',
'FAILED': 'danger',
'CANCELLED': 'secondary'
};
return colors[status] || 'secondary';
}
function getInvoiceStatusColor(status) {
const colors = {
'DRAFT': 'secondary',
'SENT': 'info',
'PAID': 'success',
'OVERDUE': 'danger',
'CANCELLED': 'secondary'
};
return colors[status] || 'secondary';
}
</script>
<style>
.timeline-item {
padding: 5px 0;
border-left: 2px solid #e9ecef;
padding-left: 15px;
margin-left: 10px;
}
.period-row:hover {
background-color: #f8f9fa;
cursor: pointer;
}
.badge-lg {
font-size: 0.9em;
padding: 0.5em 0.75em;
}
</style>
{% endblock %}

View File

@@ -8,10 +8,11 @@
{% block content %} {% block content %}
<form action="{{ url_for('entitlements_bp.handle_license_selection') }}" method="POST" id="licensesForm"> <form action="{{ url_for('entitlements_bp.handle_license_selection') }}" method="POST" id="licensesForm">
{{ render_selectable_table(headers=["License ID", "Name", "Start Date", "End Date", "Active"], rows=rows, selectable=True, id="licensesTable") }} {{ render_selectable_table(headers=["License ID", "Name", "Start Date", "Nr of Periods", "Active"], rows=rows, selectable=True, id="licensesTable") }}
<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_license" class="btn btn-primary" onclick="return validateTableSelection('licensesForm')">Edit License</button> <button type="submit" name="action" value="edit_license" class="btn btn-primary" onclick="return validateTableSelection('licensesForm')">Edit License</button>
<button type="submit" name="action" value="view_periods" class="btn btn-info" onclick="return validateTableSelection('licensesForm')">View Periods</button>
</div> </div>
<!-- Additional buttons can be added here for other actions --> <!-- Additional buttons can be added here for other actions -->
</div> </div>

View File

@@ -37,16 +37,15 @@ class LicenseTierForm(FlaskForm):
additional_interaction_bucket = IntegerField('Additional Interaction Bucket Size (M Tokens)', 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)], default=0)
default=0)
standard_overage_interaction = FloatField('Standard Overage Interaction (%)', standard_overage_interaction = FloatField('Standard Overage Interaction (%)',
validators=[DataRequired(), NumberRange(min=0)], validators=[DataRequired(), NumberRange(min=0)], default=0)
default=0)
class LicenseForm(FlaskForm): class LicenseForm(FlaskForm):
start_date = DateField('Start Date', id='form-control datepicker', validators=[DataRequired()]) start_date = DateField('Start Date', id='form-control datepicker', validators=[DataRequired()])
end_date = DateField('End Date', id='form-control datepicker', validators=[DataRequired()]) nr_of_periods = IntegerField('Number of Periods',
validators=[DataRequired(), NumberRange(min=1, max=12)], default=12)
currency = StringField('Currency', validators=[Optional(), Length(max=20)]) currency = StringField('Currency', validators=[Optional(), Length(max=20)])
yearly_payment = BooleanField('Yearly Payment', default=False) yearly_payment = BooleanField('Yearly Payment', default=False)
basic_fee = FloatField('Basic Fee', validators=[InputRequired(), NumberRange(min=0)]) basic_fee = FloatField('Basic Fee', validators=[InputRequired(), NumberRange(min=0)])

View File

@@ -1,18 +1,16 @@
import uuid
from datetime import datetime as dt, timezone as tz from datetime import datetime as dt, timezone as tz
from flask import request, redirect, flash, render_template, Blueprint, session, current_app, jsonify from flask import request, redirect, flash, render_template, Blueprint, session, current_app
from flask_security import hash_password, roles_required, roles_accepted, current_user from flask_security import roles_accepted, current_user
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy import or_, desc from sqlalchemy import or_, desc
import ast import ast
from common.models.entitlements import License, LicenseTier, LicenseUsage, BusinessEventLog from common.models.entitlements import License, LicenseTier, LicenseUsage, LicensePeriod, PeriodStatus
from common.extensions import db, security, minio_client, simple_encryption from common.extensions import db, cache_manager
from common.services.entitlement_services import EntitlementServices from common.services.entitlements.license_tier_services import LicenseTierServices
from common.services.partner_services import PartnerServices from common.services.user.partner_services import PartnerServices
from common.services.tenant_services import TenantServices from common.services.user.user_services import UserServices
from common.services.user_services import UserServices
from common.utils.eveai_exceptions import EveAIException from common.utils.eveai_exceptions import EveAIException
from common.utils.security_utils import current_user_has_role from common.utils.security_utils import current_user_has_role
from .entitlements_forms import LicenseTierForm, LicenseForm from .entitlements_forms import LicenseTierForm, LicenseForm
@@ -109,7 +107,7 @@ def handle_license_tier_selection():
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': case 'associate_license_tier_to_partner':
EntitlementServices.associate_license_tier_with_partner(license_tier_id) LicenseTierServices.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'))
@@ -153,8 +151,8 @@ def create_license(license_tier_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 start & end dates, and allowed fields if current_user_has_role("Partner Admin"): # The Partner Admin can only set start & end dates, and allowed fields
readonly_fields = [field.name for field in form if (field.name != 'end_date' and field.name != 'start_date' and readonly_fields = [field.name for field in form if (field.name != 'nr_of_periods' and field.name != 'start_date'
not field.name.endswith('allowed'))] and not field.name.endswith('allowed'))]
if request.method == 'GET': if request.method == 'GET':
# Fetch the LicenseTier # Fetch the LicenseTier
@@ -221,11 +219,14 @@ 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)
readonly_fields = [] readonly_fields = []
if len(license.usages) > 0: # There already are usage records linked to this license if len(license.periods) > 0: # There already are usage records linked to this license
# Define which fields should be disabled # Define which fields should be disabled
readonly_fields = [field.name for field in form if field.name != 'end_date'] readonly_fields = [field.name for field in form if field.name != 'nr_of_periods']
if current_user_has_role("Partner Admin"): # The Partner Admin can only set the end date if current_user_has_role("Partner Admin"): # The Partner Admin can only set the nr_of_periods and allowed fields
readonly_fields = [field.name for field in form if field.name != 'end_date'] readonly_fields = [field.name for field in form if (field.name != 'nr_of_periods'
and not field.name.endswith('allowed'))]
cache_manager.license_cache.invalidate_tenant_license(license.tenant_id)
if form.validate_on_submit(): if form.validate_on_submit():
# Populate the license with form data # Populate the license with form data
@@ -296,6 +297,7 @@ def view_licenses():
current_date = dt.now(tz=tz.utc).date() current_date = dt.now(tz=tz.utc).date()
# Query licenses for the tenant, with ordering and active status # Query licenses for the tenant, with ordering and active status
# TODO - Check validity
query = ( query = (
License.query License.query
.join(LicenseTier) # Join with LicenseTier .join(LicenseTier) # Join with LicenseTier
@@ -303,10 +305,9 @@ def view_licenses():
.add_columns( .add_columns(
License.id, License.id,
License.start_date, License.start_date,
License.end_date, License.nr_of_periods,
LicenseTier.name.label('license_tier_name'), # Access name through LicenseTier LicenseTier.name.label('license_tier_name'), # Access name through LicenseTier
((License.start_date <= current_date) & (License.start_date <= current_date).label('active')
(or_(License.end_date.is_(None), License.end_date >= current_date))).label('active')
) )
.order_by(License.start_date.desc()) .order_by(License.start_date.desc())
) )
@@ -315,8 +316,8 @@ def view_licenses():
lics = pagination.items lics = pagination.items
# prepare table data # prepare table data
rows = prepare_table_for_macro(lics, [('id', ''), ('license_tier_name', ''), ('start_date', ''), ('end_date', ''), rows = prepare_table_for_macro(lics, [('id', ''), ('license_tier_name', ''), ('start_date', ''),
('active', '')]) ('nr_of_periods', ''), ('active', '')])
# Render the licenses in a template # Render the licenses in a template
return render_template('entitlements/view_licenses.html', rows=rows, pagination=pagination) return render_template('entitlements/view_licenses.html', rows=rows, pagination=pagination)
@@ -334,3 +335,61 @@ def handle_license_selection():
match action: match action:
case 'edit_license': case 'edit_license':
return redirect(prefixed_url_for('entitlements_bp.edit_license', license_id=license_id)) return redirect(prefixed_url_for('entitlements_bp.edit_license', license_id=license_id))
case 'view_periods':
return redirect(prefixed_url_for('entitlements_bp.view_license_periods', license_id=license_id))
@entitlements_bp.route('/license/<int:license_id>/periods')
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def view_license_periods(license_id):
license = License.query.get_or_404(license_id)
# Verify user can access this license
if not current_user.has_role('Super User'):
tenant_id = session.get('tenant').get('id')
if license.tenant_id != tenant_id:
flash('Access denied to this license', 'danger')
return redirect(prefixed_url_for('entitlements_bp.view_licenses'))
# Get all periods for this license
periods = (LicensePeriod.query
.filter_by(license_id=license_id)
.order_by(LicensePeriod.period_number)
.all())
# Group related data for easy template access
usage_by_period = {}
payments_by_period = {}
invoices_by_period = {}
for period in periods:
usage_by_period[period.id] = period.license_usage
payments_by_period[period.id] = list(period.payments)
invoices_by_period[period.id] = list(period.invoices)
return render_template('entitlements/license_periods.html',
license=license,
periods=periods,
usage_by_period=usage_by_period,
payments_by_period=payments_by_period,
invoices_by_period=invoices_by_period)
@entitlements_bp.route('/license/<int:license_id>/periods/<int:period_id>/transition', methods=['POST'])
@roles_accepted('Super User', 'Partner Admin')
def transition_period_status(license_id, period_id):
"""Handle status transitions for license periods"""
period = LicensePeriod.query.get_or_404(period_id)
new_status = request.form.get('new_status')
try:
period.transition_status(PeriodStatus[new_status], current_user.id)
db.session.commit()
flash(f'Period {period.period_number} status updated to {new_status}', 'success')
except ValueError as e:
flash(f'Invalid status transition: {str(e)}', 'danger')
except Exception as e:
db.session.rollback()
flash(f'Error updating status: {str(e)}', 'danger')
return redirect(prefixed_url_for('entitlements_bp.view_license_periods', license_id=license_id))

View File

@@ -1,13 +1,12 @@
from flask import current_app, session from flask import current_app, session
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import (StringField, PasswordField, BooleanField, SubmitField, EmailField, IntegerField, DateField, from wtforms import (StringField, BooleanField, SubmitField, EmailField, IntegerField, DateField,
SelectField, SelectMultipleField, FieldList, FormField, FloatField, TextAreaField) SelectField, SelectMultipleField, FieldList, FormField, TextAreaField)
from wtforms.validators import DataRequired, Length, Email, NumberRange, Optional, ValidationError from wtforms.validators import DataRequired, Length, Email, NumberRange, Optional
import pytz import pytz
from flask_security import current_user from flask_security import current_user
from common.models.user import Role from common.services.user.user_services import UserServices
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

View File

@@ -7,7 +7,6 @@ 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_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, \
@@ -18,8 +17,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_services import TenantServices from common.services.user.tenant_services import TenantServices
from common.services.user_services import UserServices from common.services.user.user_services import UserServices
from common.utils.mail_utils import send_email from common.utils.mail_utils import send_email
user_bp = Blueprint('user_bp', __name__, url_prefix='/user') user_bp = Blueprint('user_bp', __name__, url_prefix='/user')

View File

@@ -4,7 +4,7 @@ from flask import Flask
import os import os
from common.utils.celery_utils import make_celery, init_celery from common.utils.celery_utils import make_celery, init_celery
from common.extensions import db, minio_client from common.extensions import db, minio_client, cache_manager
from config.logging_config import LOGGING from config.logging_config import LOGGING
from config.config import get_config from config.config import get_config
@@ -26,6 +26,8 @@ def create_app(config_file=None):
register_extensions(app) register_extensions(app)
register_cache_handlers(app)
celery = make_celery(app.name, app.config) celery = make_celery(app.name, app.config)
init_celery(celery, app) init_celery(celery, app)
@@ -39,6 +41,12 @@ def create_app(config_file=None):
def register_extensions(app): def register_extensions(app):
db.init_app(app) db.init_app(app)
cache_manager.init_app(app)
def register_cache_handlers(app):
from common.utils.cache.license_cache import register_license_cache_handlers
register_license_cache_handlers(cache_manager)
app, celery = create_app() app, celery = create_app()

View File

@@ -2,14 +2,13 @@ import io
import os import os
from datetime import datetime as dt, timezone as tz, datetime from datetime import datetime as dt, timezone as tz, datetime
from celery import states
from dateutil.relativedelta import relativedelta
from flask import current_app from flask import current_app
from sqlalchemy import or_, and_, text from sqlalchemy import or_, and_, text
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from common.extensions import db from common.extensions import db
from common.models.user import Tenant from common.models.user import Tenant
from common.models.entitlements import BusinessEventLog, LicenseUsage, License from common.models.entitlements import BusinessEventLog, LicenseUsage, License
from common.services.entitlements.license_period_services import LicensePeriodServices
from common.utils.celery_utils import current_celery from common.utils.celery_utils import current_celery
from common.utils.eveai_exceptions import EveAINoLicenseForTenant, EveAIException, EveAINoActiveLicense from common.utils.eveai_exceptions import EveAINoLicenseForTenant, EveAIException, EveAINoActiveLicense
from common.utils.database import Database from common.utils.database import Database
@@ -21,52 +20,6 @@ def ping():
return 'pong' return 'pong'
@current_celery.task(name='update_usages', queue='entitlements')
def update_usages():
current_timestamp = dt.now(tz.utc)
tenant_ids = get_all_tenant_ids()
# List to collect all errors
error_list = []
for tenant_id in tenant_ids:
if tenant_id == 1:
continue
try:
Database(tenant_id).switch_schema()
check_and_create_license_usage_for_tenant(tenant_id)
tenant = Tenant.query.get(tenant_id)
if tenant.storage_dirty:
recalculate_storage_for_tenant(tenant)
logs = get_logs_for_processing(tenant_id, current_timestamp)
if not logs:
continue # If no logs to be processed, continu to the next tenant
# Get the min and max timestamp from the logs
min_timestamp = min(log.timestamp for log in logs)
max_timestamp = max(log.timestamp for log in logs)
# Retrieve relevant LicenseUsage records
license_usages = get_relevant_license_usages(db.session, tenant_id, min_timestamp, max_timestamp)
# Split logs based on LicenseUsage periods
logs_by_usage = split_logs_by_license_usage(logs, license_usages)
# Now you can process logs for each LicenseUsage
for license_usage_id, logs in logs_by_usage.items():
process_logs_for_license_usage(tenant_id, license_usage_id, logs)
except Exception as e:
error = f"Usage Calculation error for Tenant {tenant_id}: {e}"
error_list.append(error)
current_app.logger.error(error)
continue
if error_list:
raise Exception('\n'.join(error_list))
return "Update Usages taks completed successfully"
@current_celery.task(name='persist_business_events', queue='entitlements') @current_celery.task(name='persist_business_events', queue='entitlements')
def persist_business_events(log_entries): def persist_business_events(log_entries):
""" """
@@ -76,7 +29,7 @@ def persist_business_events(log_entries):
log_entries: List of log event dictionaries to persist log_entries: List of log event dictionaries to persist
""" """
try: try:
db_entries = [] event_logs = []
for entry in log_entries: for entry in log_entries:
event_log = BusinessEventLog( event_log = BusinessEventLog(
timestamp=entry.pop('timestamp'), timestamp=entry.pop('timestamp'),
@@ -103,119 +56,44 @@ def persist_business_events(log_entries):
llm_interaction_type=entry.pop('llm_interaction_type', None), llm_interaction_type=entry.pop('llm_interaction_type', None),
message=entry.pop('message', None) message=entry.pop('message', None)
) )
db_entries.append(event_log) event_logs.append(event_log)
# Perform a bulk insert of all entries # Perform a bulk insert of all entries
db.session.bulk_save_objects(db_entries) db.session.bulk_save_objects(event_logs)
db.session.commit() db.session.commit()
current_app.logger.info(f"Successfully persisted {len(db_entries)} business event logs") current_app.logger.info(f"Successfully persisted {len(event_logs)} business event logs")
tenant_id = event_logs[0].tenant_id
try:
license_period = LicensePeriodServices.find_current_license_period_for_usage(tenant_id)
except EveAIException as e:
current_app.logger.error(f"Failed to find license period for tenant {tenant_id}: {str(e)}")
return
lic_usage = None
if not license_period.license_usage:
lic_usage = LicenseUsage(
tenant_id=tenant_id,
license_period_id=license_period.id,
)
try:
db.session.add(lic_usage)
db.session.commit()
current_app.logger.info(f"Created new license usage for tenant {tenant_id}")
except SQLAlchemyError as e:
db.session.rollback()
current_app.logger.error(f"Error trying to create license usage for tenant {tenant_id}: {str(e)}")
return
else:
lic_usage = license_period.license_usage
process_logs_for_license_usage(tenant_id, lic_usage, event_logs)
except Exception as e: except Exception as e:
current_app.logger.error(f"Failed to persist business event logs: {e}") current_app.logger.error(f"Failed to persist business event logs: {e}")
db.session.rollback() db.session.rollback()
def get_all_tenant_ids(): def process_logs_for_license_usage(tenant_id, license_usage, logs):
tenant_ids = db.session.query(Tenant.id).all()
return [tenant_id[0] for tenant_id in tenant_ids] # Extract tenant_id from tuples
def check_and_create_license_usage_for_tenant(tenant_id):
current_date = dt.now(tz.utc).date()
license_usages = (db.session.query(LicenseUsage)
.filter_by(tenant_id=tenant_id)
.filter(and_(LicenseUsage.period_start_date <= current_date,
LicenseUsage.period_end_date >= current_date))
.all())
if not license_usages:
active_license = (db.session.query(License).filter_by(tenant_id=tenant_id)
.filter(and_(License.start_date <= current_date,
License.end_date >= current_date))
.one_or_none())
if not active_license:
current_app.logger.error(f"No License defined for {tenant_id}. "
f"Impossible to calculate license usage.")
raise EveAINoActiveLicense(tenant_id)
start_date, end_date = calculate_valid_period(current_date, active_license.start_date)
new_license_usage = LicenseUsage(period_start_date=start_date,
period_end_date=end_date,
license_id=active_license.id,
tenant_id=tenant_id
)
try:
db.session.add(new_license_usage)
db.session.commit()
except SQLAlchemyError as e:
db.session.rollback()
current_app.logger.error(f"Error trying to create new license usage for tenant {tenant_id}. "
f"Error: {str(e)}")
raise e
def calculate_valid_period(given_date, original_start_date):
# Ensure both dates are of datetime.date type
if isinstance(given_date, datetime):
given_date = given_date.date()
if isinstance(original_start_date, datetime):
original_start_date = original_start_date.date()
# Step 1: Find the most recent start_date less than or equal to given_date
start_date = original_start_date
while start_date <= given_date:
next_start_date = start_date + relativedelta(months=1)
if next_start_date > given_date:
break
start_date = next_start_date
# Step 2: Calculate the end_date for this period
end_date = start_date + relativedelta(months=1, days=-1)
# Ensure the given date falls within the period
if start_date <= given_date <= end_date:
return start_date, end_date
else:
raise ValueError("Given date does not fall within a valid period.")
def get_logs_for_processing(tenant_id, end_time_stamp):
return (db.session.query(BusinessEventLog).filter(
BusinessEventLog.tenant_id == tenant_id,
BusinessEventLog.license_usage_id == None,
BusinessEventLog.timestamp <= end_time_stamp,
).all())
def get_relevant_license_usages(session, tenant_id, min_timestamp, max_timestamp):
# Fetch LicenseUsage records where the log timestamps fall between period_start_date and period_end_date
return session.query(LicenseUsage).filter(
LicenseUsage.tenant_id == tenant_id,
LicenseUsage.period_start_date <= max_timestamp.date(),
LicenseUsage.period_end_date >= min_timestamp.date()
).order_by(LicenseUsage.period_start_date).all()
def split_logs_by_license_usage(logs, license_usages):
# Dictionary to hold logs categorized by LicenseUsage
logs_by_usage = {lu.id: [] for lu in license_usages}
for log in logs:
# Find the corresponding LicenseUsage for each log based on the timestamp
for license_usage in license_usages:
if license_usage.period_start_date <= log.timestamp.date() <= license_usage.period_end_date:
logs_by_usage[license_usage.id].append(log)
break
return logs_by_usage
def process_logs_for_license_usage(tenant_id, license_usage_id, logs):
# Retrieve the LicenseUsage record
license_usage = db.session.query(LicenseUsage).filter_by(id=license_usage_id).first()
if not license_usage:
raise ValueError(f"LicenseUsage with id {license_usage_id} not found.")
# Initialize variables to accumulate usage data # Initialize variables to accumulate usage data
embedding_mb_used = 0 embedding_mb_used = 0
embedding_prompt_tokens_used = 0 embedding_prompt_tokens_used = 0
@@ -225,10 +103,13 @@ def process_logs_for_license_usage(tenant_id, license_usage_id, logs):
interaction_completion_tokens_used = 0 interaction_completion_tokens_used = 0
interaction_total_tokens_used = 0 interaction_total_tokens_used = 0
recalculate_storage = False
# Process each log # Process each log
for log in logs: for log in logs:
# Case for 'Create Embeddings' event # Case for 'Create Embeddings' event
if log.event_type == 'Create Embeddings': if log.event_type == 'Create Embeddings':
recalculate_storage = True
if log.message == 'Starting Trace for Create Embeddings': if log.message == 'Starting Trace for Create Embeddings':
embedding_mb_used += log.document_version_file_size embedding_mb_used += log.document_version_file_size
elif log.message == 'Final LLM Metrics': elif log.message == 'Final LLM Metrics':
@@ -256,7 +137,7 @@ def process_logs_for_license_usage(tenant_id, license_usage_id, logs):
interaction_total_tokens_used += log.llm_metrics_total_tokens interaction_total_tokens_used += log.llm_metrics_total_tokens
# Mark the log as processed by setting the license_usage_id # Mark the log as processed by setting the license_usage_id
log.license_usage_id = license_usage_id log.license_usage_id = license_usage.id
# Update the LicenseUsage record with the accumulated values # Update the LicenseUsage record with the accumulated values
license_usage.embedding_mb_used += embedding_mb_used license_usage.embedding_mb_used += embedding_mb_used
@@ -267,6 +148,9 @@ def process_logs_for_license_usage(tenant_id, license_usage_id, logs):
license_usage.interaction_completion_tokens_used += interaction_completion_tokens_used license_usage.interaction_completion_tokens_used += interaction_completion_tokens_used
license_usage.interaction_total_tokens_used += interaction_total_tokens_used license_usage.interaction_total_tokens_used += interaction_total_tokens_used
if recalculate_storage:
recalculate_storage_for_tenant(tenant_id)
# Commit the updates to the LicenseUsage and log records # Commit the updates to the LicenseUsage and log records
try: try:
db.session.add(license_usage) db.session.add(license_usage)
@@ -279,7 +163,8 @@ def process_logs_for_license_usage(tenant_id, license_usage_id, logs):
raise e raise e
def recalculate_storage_for_tenant(tenant): def recalculate_storage_for_tenant(tenant_id):
Database(tenant_id).switch_schema()
# Perform a SUM operation to get the total file size from document_versions # Perform a SUM operation to get the total file size from document_versions
total_storage = db.session.execute(text(f""" total_storage = db.session.execute(text(f"""
SELECT SUM(file_size) SELECT SUM(file_size)
@@ -287,19 +172,15 @@ def recalculate_storage_for_tenant(tenant):
""")).scalar() """)).scalar()
# Update the LicenseUsage with the recalculated storage # Update the LicenseUsage with the recalculated storage
license_usage = db.session.query(LicenseUsage).filter_by(tenant_id=tenant.id).first() license_usage = db.session.query(LicenseUsage).filter_by(tenant_id=tenant_id).first()
license_usage.storage_mb_used = total_storage license_usage.storage_mb_used = total_storage
# Reset the dirty flag after recalculating
tenant.storage_dirty = False
# Commit the changes # Commit the changes
try: try:
db.session.add(tenant)
db.session.add(license_usage) db.session.add(license_usage)
db.session.commit() db.session.commit()
except SQLAlchemyError as e: except SQLAlchemyError as e:
db.session.rollback() db.session.rollback()
current_app.logger.error(f"Error trying to update tenant {tenant.id} for Dirty Storage. ") current_app.logger.error(f"Error trying to update tenant {tenant_id} for Dirty Storage. ")

View File

@@ -0,0 +1,104 @@
"""add Payments & Invoices to Entitlements Domain
Revision ID: 26e20f27d399
Revises: fa6113ce4306
Create Date: 2025-05-13 15:54:51.069984
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '26e20f27d399'
down_revision = 'fa6113ce4306'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('license_period',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('license_id', sa.Integer(), nullable=False),
sa.Column('period_number', sa.Integer(), nullable=False),
sa.Column('period_start', sa.Date(), nullable=False),
sa.Column('period_end', sa.Date(), nullable=False),
sa.Column('status', sa.Enum('UPCOMING', 'PENDING', 'ACTIVE', 'COMPLETED', 'INVOICED', 'CLOSED', name='periodstatus'), nullable=False),
sa.Column('upcoming_at', sa.DateTime(), nullable=True),
sa.Column('pending_at', sa.DateTime(), nullable=True),
sa.Column('active_at', sa.DateTime(), nullable=True),
sa.Column('completed_at', sa.DateTime(), nullable=True),
sa.Column('invoiced_at', sa.DateTime(), nullable=True),
sa.Column('closed_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.Column('created_by', sa.Integer(), nullable=True),
sa.Column('updated_by', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['created_by'], ['public.user.id'], ),
sa.ForeignKeyConstraint(['license_id'], ['public.license.id'], ),
sa.ForeignKeyConstraint(['updated_by'], ['public.user.id'], ),
sa.PrimaryKeyConstraint('id'),
schema='public'
)
op.create_table('payment',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('license_period_id', sa.Integer(), nullable=True),
sa.Column('payment_type', sa.Enum('PREPAID', 'POSTPAID', name='paymenttype'), nullable=False),
sa.Column('amount', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('currency', sa.String(length=3), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('status', sa.Enum('PENDING', 'PAID', 'FAILED', 'CANCELLED', name='paymentstatus'), nullable=False),
sa.Column('external_payment_id', sa.String(length=255), nullable=True),
sa.Column('payment_method', sa.String(length=50), nullable=True),
sa.Column('provider_data', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('paid_at', sa.DateTime(), nullable=True),
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_period_id'], ['public.license_period.id'], ),
sa.ForeignKeyConstraint(['updated_by'], ['public.user.id'], ),
sa.PrimaryKeyConstraint('id'),
schema='public'
)
op.create_table('invoice',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('license_period_id', sa.Integer(), nullable=False),
sa.Column('payment_id', sa.Integer(), nullable=True),
sa.Column('invoice_type', sa.Enum('PREPAID', 'POSTPAID', name='paymenttype'), nullable=False),
sa.Column('invoice_number', sa.String(length=50), nullable=False),
sa.Column('invoice_date', sa.Date(), nullable=False),
sa.Column('due_date', sa.Date(), nullable=False),
sa.Column('amount', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('currency', sa.String(length=3), nullable=False),
sa.Column('tax_amount', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('status', sa.Enum('DRAFT', 'SENT', 'PAID', 'OVERDUE', 'CANCELLED', name='invoicestatus'), nullable=False),
sa.Column('sent_at', sa.DateTime(), nullable=True),
sa.Column('paid_at', sa.DateTime(), nullable=True),
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_period_id'], ['public.license_period.id'], ),
sa.ForeignKeyConstraint(['payment_id'], ['public.payment.id'], ),
sa.ForeignKeyConstraint(['updated_by'], ['public.user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('invoice_number'),
schema='public'
)
with op.batch_alter_table('license_usage', schema=None) as batch_op:
batch_op.add_column(sa.Column('license_period_id', sa.Integer(), nullable=False))
batch_op.create_foreign_key(None, 'license_period', ['license_period_id'], ['id'], referent_schema='public')
# ### end Alembic commands ###
def downgrade():
pass
# ### commands auto generated by Alembic - please adjust! ###
# ### end Alembic commands ###

View File

@@ -0,0 +1,33 @@
"""Correct usages relationship error in EntitlementsDomain
Revision ID: 638c4718005d
Revises: 26e20f27d399
Create Date: 2025-05-13 16:24:10.469700
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '638c4718005d'
down_revision = '26e20f27d399'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('license_usage', schema=None) as batch_op:
batch_op.drop_constraint('license_usage_license_id_fkey', type_='foreignkey')
batch_op.drop_column('license_id')
batch_op.drop_column('period_end_date')
batch_op.drop_column('period_start_date')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@@ -0,0 +1,33 @@
"""replace License End Date with Nr of Periods
Revision ID: ef0aaf00f26d
Revises: 638c4718005d
Create Date: 2025-05-14 04:04:54.535439
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'ef0aaf00f26d'
down_revision = '638c4718005d'
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table('license', schema=None) as batch_op:
# Add column with server default for existing rows
batch_op.add_column(sa.Column('nr_of_periods', sa.Integer(), nullable=False, server_default='12'))
batch_op.drop_column('end_date')
# Remove the server default after creation (optional)
with op.batch_alter_table('license', schema=None) as batch_op:
batch_op.alter_column('nr_of_periods', server_default=None)
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###