From b4f7b210e0de31dfad53529c211483b2449867a1 Mon Sep 17 00:00:00 2001 From: Josako Date: Fri, 16 May 2025 09:06:13 +0200 Subject: [PATCH] - Improvement of Entitlements Domain - Introduction of LicensePeriod - Introduction of Payments - Introduction of Invoices - Services definitions for Entitlements Domain --- eveai_app/__init__.py | 2 + .../templates/entitlements/edit_license.html | 2 +- eveai_app/templates/entitlements/license.html | 2 +- .../entitlements/license_periods.html | 398 ++++++++++++++++++ .../templates/entitlements/view_licenses.html | 3 +- eveai_app/views/entitlements_forms.py | 9 +- eveai_app/views/entitlements_views.py | 103 ++++- eveai_app/views/user_forms.py | 9 +- eveai_app/views/user_views.py | 5 +- eveai_beat/schedule.py | 2 +- eveai_entitlements/__init__.py | 10 +- eveai_entitlements/tasks.py | 203 ++------- ..._add_payments_invoices_to_entitlements_.py | 104 +++++ ...d_correct_usages_relationship_error_in_.py | 33 ++ ...6d_replace_license_end_date_with_nr_of_.py | 33 ++ 15 files changed, 717 insertions(+), 201 deletions(-) create mode 100644 eveai_app/templates/entitlements/license_periods.html create mode 100644 migrations/public/versions/26e20f27d399_add_payments_invoices_to_entitlements_.py create mode 100644 migrations/public/versions/638c4718005d_correct_usages_relationship_error_in_.py create mode 100644 migrations/public/versions/ef0aaf00f26d_replace_license_end_date_with_nr_of_.py diff --git a/eveai_app/__init__.py b/eveai_app/__init__.py index a2664cc..55ac192 100644 --- a/eveai_app/__init__.py +++ b/eveai_app/__init__.py @@ -151,6 +151,8 @@ def register_cache_handlers(app): register_config_cache_handlers(cache_manager) from common.utils.cache.crewai_processed_config_cache import register_specialist_cache_handlers register_specialist_cache_handlers(cache_manager) + from common.utils.cache.license_cache import register_license_cache_handlers + register_license_cache_handlers(cache_manager) diff --git a/eveai_app/templates/entitlements/edit_license.html b/eveai_app/templates/entitlements/edit_license.html index 735b70a..6502984 100644 --- a/eveai_app/templates/entitlements/edit_license.html +++ b/eveai_app/templates/entitlements/edit_license.html @@ -9,7 +9,7 @@ {% block content %}
{{ 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 %} {{ render_included_field(field, readonly_fields=ext_readonly_fields + ['currency'], include_fields=main_fields) }} {% endfor %} diff --git a/eveai_app/templates/entitlements/license.html b/eveai_app/templates/entitlements/license.html index 581a127..b437743 100644 --- a/eveai_app/templates/entitlements/license.html +++ b/eveai_app/templates/entitlements/license.html @@ -9,7 +9,7 @@ {% block content %} {{ 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 %} {{ render_included_field(field, readonly_fields=ext_readonly_fields + ['currency'], include_fields=main_fields) }} {% endfor %} diff --git a/eveai_app/templates/entitlements/license_periods.html b/eveai_app/templates/entitlements/license_periods.html new file mode 100644 index 0000000..7848758 --- /dev/null +++ b/eveai_app/templates/entitlements/license_periods.html @@ -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 %} +
+ +
+
+
+
+ License ID: {{ license.id }} +
+
+ Start Date: {{ license.start_date }} +
+
+ Total Periods: {{ license.nr_of_periods }} +
+
+ Currency: {{ license.currency }} +
+
+
+
+ + +
+
+
License Periods
+
+
+
+ + + + + + + + + + + + + + + {% for period in periods %} + + + + + + + + + + + {% endfor %} + +
PeriodStart DateEnd DateStatusUsagePaymentsInvoicesActions
{{ period.period_number }}{{ period.period_start }}{{ period.period_end }} + + {{ period.status.name }} + + + {% set usage = usage_by_period.get(period.id) %} + {% if usage %} + + S: {{ "%.1f"|format(usage.storage_mb_used or 0) }}MB
+ E: {{ "%.1f"|format(usage.embedding_mb_used or 0) }}MB
+ I: {{ "{:,}"|format(usage.interaction_total_tokens_used or 0) }} +
+ {% else %} + No usage + {% endif %} +
+ {% set payments = payments_by_period.get(period.id, []) %} + {{ payments|length }} payment(s) + + {% set invoices = invoices_by_period.get(period.id, []) %} + {{ invoices|length }} invoice(s) + + +
+
+
+
+
+ + + +{% endblock %} + +{% block scripts %} + + + +{% endblock %} \ No newline at end of file diff --git a/eveai_app/templates/entitlements/view_licenses.html b/eveai_app/templates/entitlements/view_licenses.html index 1aa8c5c..35f4e5b 100644 --- a/eveai_app/templates/entitlements/view_licenses.html +++ b/eveai_app/templates/entitlements/view_licenses.html @@ -8,10 +8,11 @@ {% block content %} - {{ 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") }}
+
diff --git a/eveai_app/views/entitlements_forms.py b/eveai_app/views/entitlements_forms.py index de767b2..a0961a4 100644 --- a/eveai_app/views/entitlements_forms.py +++ b/eveai_app/views/entitlements_forms.py @@ -37,16 +37,15 @@ class LicenseTierForm(FlaskForm): additional_interaction_bucket = IntegerField('Additional Interaction Bucket Size (M Tokens)', validators=[DataRequired(), NumberRange(min=1)]) standard_overage_embedding = FloatField('Standard Overage Embedding (%)', - validators=[DataRequired(), NumberRange(min=0)], - default=0) + validators=[DataRequired(), NumberRange(min=0)], default=0) standard_overage_interaction = FloatField('Standard Overage Interaction (%)', - validators=[DataRequired(), NumberRange(min=0)], - default=0) + validators=[DataRequired(), NumberRange(min=0)], default=0) class LicenseForm(FlaskForm): 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)]) yearly_payment = BooleanField('Yearly Payment', default=False) basic_fee = FloatField('Basic Fee', validators=[InputRequired(), NumberRange(min=0)]) diff --git a/eveai_app/views/entitlements_views.py b/eveai_app/views/entitlements_views.py index c88e602..9e5af5d 100644 --- a/eveai_app/views/entitlements_views.py +++ b/eveai_app/views/entitlements_views.py @@ -1,18 +1,16 @@ -import uuid from datetime import datetime as dt, timezone as tz -from flask import request, redirect, flash, render_template, Blueprint, session, current_app, jsonify -from flask_security import hash_password, roles_required, roles_accepted, current_user +from flask import request, redirect, flash, render_template, Blueprint, session, current_app +from flask_security import roles_accepted, current_user from sqlalchemy.exc import SQLAlchemyError from sqlalchemy import or_, desc import ast -from common.models.entitlements import License, LicenseTier, LicenseUsage, BusinessEventLog -from common.extensions import db, security, minio_client, simple_encryption +from common.models.entitlements import License, LicenseTier, LicenseUsage, LicensePeriod, PeriodStatus +from common.extensions import db, cache_manager -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.services.entitlements.license_tier_services import LicenseTierServices +from common.services.user.partner_services import PartnerServices +from common.services.user.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 @@ -109,7 +107,7 @@ def handle_license_tier_selection(): return redirect(prefixed_url_for('entitlements_bp.create_license', license_tier_id=license_tier_id)) 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 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') 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 - not field.name.endswith('allowed'))] + readonly_fields = [field.name for field in form if (field.name != 'nr_of_periods' and field.name != 'start_date' + and not field.name.endswith('allowed'))] if request.method == 'GET': # 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 form = LicenseForm(obj=license) 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 - 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'] + 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 nr_of_periods and allowed fields + 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(): # Populate the license with form data @@ -296,6 +297,7 @@ def view_licenses(): current_date = dt.now(tz=tz.utc).date() # Query licenses for the tenant, with ordering and active status + # TODO - Check validity query = ( License.query .join(LicenseTier) # Join with LicenseTier @@ -303,10 +305,9 @@ def view_licenses(): .add_columns( License.id, License.start_date, - License.end_date, + License.nr_of_periods, LicenseTier.name.label('license_tier_name'), # Access name through LicenseTier - ((License.start_date <= current_date) & - (or_(License.end_date.is_(None), License.end_date >= current_date))).label('active') + (License.start_date <= current_date).label('active') ) .order_by(License.start_date.desc()) ) @@ -315,8 +316,8 @@ def view_licenses(): lics = pagination.items # prepare table data - rows = prepare_table_for_macro(lics, [('id', ''), ('license_tier_name', ''), ('start_date', ''), ('end_date', ''), - ('active', '')]) + rows = prepare_table_for_macro(lics, [('id', ''), ('license_tier_name', ''), ('start_date', ''), + ('nr_of_periods', ''), ('active', '')]) # Render the licenses in a template return render_template('entitlements/view_licenses.html', rows=rows, pagination=pagination) @@ -333,4 +334,62 @@ def handle_license_selection(): match action: case 'edit_license': - return redirect(prefixed_url_for('entitlements_bp.edit_license', license_id=license_id)) \ No newline at end of file + 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//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//periods//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)) \ No newline at end of file diff --git a/eveai_app/views/user_forms.py b/eveai_app/views/user_forms.py index 18e6264..e437249 100644 --- a/eveai_app/views/user_forms.py +++ b/eveai_app/views/user_forms.py @@ -1,13 +1,12 @@ from flask import current_app, session from flask_wtf import FlaskForm -from wtforms import (StringField, PasswordField, BooleanField, SubmitField, EmailField, IntegerField, DateField, - SelectField, SelectMultipleField, FieldList, FormField, FloatField, TextAreaField) -from wtforms.validators import DataRequired, Length, Email, NumberRange, Optional, ValidationError +from wtforms import (StringField, BooleanField, SubmitField, EmailField, IntegerField, DateField, + SelectField, SelectMultipleField, FieldList, FormField, TextAreaField) +from wtforms.validators import DataRequired, Length, Email, NumberRange, Optional import pytz from flask_security import current_user -from common.models.user import Role -from common.services.user_services import UserServices +from common.services.user.user_services import UserServices from config.type_defs.service_types import SERVICE_TYPES diff --git a/eveai_app/views/user_views.py b/eveai_app/views/user_views.py index d7f02b0..244a69c 100644 --- a/eveai_app/views/user_views.py +++ b/eveai_app/views/user_views.py @@ -7,7 +7,6 @@ import ast from common.models.user import User, Tenant, Role, TenantDomain, TenantProject, PartnerTenant 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 config.type_defs.service_types import SERVICE_TYPES 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.eveai_exceptions import EveAIException from common.utils.document_utils import set_logging_information, update_logging_information -from common.services.tenant_services import TenantServices -from common.services.user_services import UserServices +from common.services.user.tenant_services import TenantServices +from common.services.user.user_services import UserServices from common.utils.mail_utils import send_email user_bp = Blueprint('user_bp', __name__, url_prefix='/user') diff --git a/eveai_beat/schedule.py b/eveai_beat/schedule.py index 6b79c5d..ac074d5 100644 --- a/eveai_beat/schedule.py +++ b/eveai_beat/schedule.py @@ -14,4 +14,4 @@ beat_schedule = { # 'args': () # }, # Add more schedules as needed -} \ No newline at end of file +} diff --git a/eveai_entitlements/__init__.py b/eveai_entitlements/__init__.py index e99a80a..ad20310 100644 --- a/eveai_entitlements/__init__.py +++ b/eveai_entitlements/__init__.py @@ -4,7 +4,7 @@ from flask import Flask import os 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.config import get_config @@ -26,6 +26,8 @@ def create_app(config_file=None): register_extensions(app) + register_cache_handlers(app) + celery = make_celery(app.name, app.config) init_celery(celery, app) @@ -39,6 +41,12 @@ def create_app(config_file=None): def register_extensions(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() diff --git a/eveai_entitlements/tasks.py b/eveai_entitlements/tasks.py index 2530d25..ea77717 100644 --- a/eveai_entitlements/tasks.py +++ b/eveai_entitlements/tasks.py @@ -2,14 +2,13 @@ import io import os 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 sqlalchemy import or_, and_, text from sqlalchemy.exc import SQLAlchemyError from common.extensions import db from common.models.user import Tenant 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.eveai_exceptions import EveAINoLicenseForTenant, EveAIException, EveAINoActiveLicense from common.utils.database import Database @@ -21,52 +20,6 @@ def ping(): 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') 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 """ try: - db_entries = [] + event_logs = [] for entry in log_entries: event_log = BusinessEventLog( timestamp=entry.pop('timestamp'), @@ -103,119 +56,44 @@ def persist_business_events(log_entries): llm_interaction_type=entry.pop('llm_interaction_type', None), message=entry.pop('message', None) ) - db_entries.append(event_log) + event_logs.append(event_log) # Perform a bulk insert of all entries - db.session.bulk_save_objects(db_entries) + db.session.bulk_save_objects(event_logs) 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: current_app.logger.error(f"Failed to persist business event logs: {e}") db.session.rollback() -def get_all_tenant_ids(): - 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.") - +def process_logs_for_license_usage(tenant_id, license_usage, logs): # Initialize variables to accumulate usage data embedding_mb_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_total_tokens_used = 0 + recalculate_storage = False + # Process each log for log in logs: # Case for 'Create Embeddings' event if log.event_type == 'Create Embeddings': + recalculate_storage = True if log.message == 'Starting Trace for Create Embeddings': embedding_mb_used += log.document_version_file_size 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 # 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 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_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 try: db.session.add(license_usage) @@ -279,7 +163,8 @@ def process_logs_for_license_usage(tenant_id, license_usage_id, logs): 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 total_storage = db.session.execute(text(f""" SELECT SUM(file_size) @@ -287,19 +172,15 @@ def recalculate_storage_for_tenant(tenant): """)).scalar() # 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 - # Reset the dirty flag after recalculating - tenant.storage_dirty = False - # Commit the changes try: - db.session.add(tenant) db.session.add(license_usage) db.session.commit() except SQLAlchemyError as e: 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. ") diff --git a/migrations/public/versions/26e20f27d399_add_payments_invoices_to_entitlements_.py b/migrations/public/versions/26e20f27d399_add_payments_invoices_to_entitlements_.py new file mode 100644 index 0000000..e1582e7 --- /dev/null +++ b/migrations/public/versions/26e20f27d399_add_payments_invoices_to_entitlements_.py @@ -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 ### diff --git a/migrations/public/versions/638c4718005d_correct_usages_relationship_error_in_.py b/migrations/public/versions/638c4718005d_correct_usages_relationship_error_in_.py new file mode 100644 index 0000000..28a0c11 --- /dev/null +++ b/migrations/public/versions/638c4718005d_correct_usages_relationship_error_in_.py @@ -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 ### diff --git a/migrations/public/versions/ef0aaf00f26d_replace_license_end_date_with_nr_of_.py b/migrations/public/versions/ef0aaf00f26d_replace_license_end_date_with_nr_of_.py new file mode 100644 index 0000000..48f3b00 --- /dev/null +++ b/migrations/public/versions/ef0aaf00f26d_replace_license_end_date_with_nr_of_.py @@ -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 ###