diff --git a/coldfront/config/plugins/ifx.py b/coldfront/config/plugins/ifx.py index 616fb534c..b75993786 100644 --- a/coldfront/config/plugins/ifx.py +++ b/coldfront/config/plugins/ifx.py @@ -25,6 +25,9 @@ class GROUPS(): class RATES(): INTERNAL_RATE_NAME = 'Harvard Internal Rate' +class EMAILS(): + DEFAULT_EMAIL_FROM_ADDRESS = 'rchelp@rc.fas.harvard.edu' + # Ignore billing models in the django-author pre-save so that values are set directly AUTHOR_IGNORE_MODELS = [ 'ifxbilling.BillingRecord', @@ -38,3 +41,7 @@ class RATES(): IFXREPORT_FILE_ROOT = os.path.join(MEDIA_ROOT, 'reports') IFXREPORT_URL_ROOT = f'{MEDIA_URL}reports' + +# Class to be used for rebalancing +REBALANCER_CLASS = 'coldfront.plugins.ifx.calculator.ColdfrontRebalance' + diff --git a/coldfront/plugins/ifx/calculator.py b/coldfront/plugins/ifx/calculator.py index 0adf7209e..a8526d302 100644 --- a/coldfront/plugins/ifx/calculator.py +++ b/coldfront/plugins/ifx/calculator.py @@ -10,7 +10,7 @@ from django.utils import timezone from django.contrib.auth import get_user_model from django.conf import settings -from ifxbilling.calculator import BasicBillingCalculator, NewBillingCalculator +from ifxbilling.calculator import BasicBillingCalculator, NewBillingCalculator, Rebalance from ifxbilling.models import Account, Product, ProductUsage, Rate, BillingRecord from ifxuser.models import Organization from coldfront.core.allocation.models import Allocation, AllocationStatusChoice @@ -29,7 +29,7 @@ class NewColdfrontBillingCalculator(NewBillingCalculator): STORAGE_QUOTA_ATTRIBUTE = 'Storage Quota (TB)' STORAGE_RESOURCE_TYPE = 'Storage' - def calculate_billing_month(self, year, month, organizations=None, recalculate=False, verbosity=0): + def calculate_billing_month(self, year, month, organizations=None, user=None, recalculate=False, verbosity=0): ''' Calculate a month of billing for the given year and month @@ -47,6 +47,9 @@ def calculate_billing_month(self, year, month, organizations=None, recalculate=F :param organizations: List of specific organizations to process. If not set, all Harvard org_tree organizations will be processed. :type organizations: list, optional + :param user: Limit billing to this year. If not set, all users will be processed. + :type user: :class:`~ifxuser.models.IfxUser`, optional + :param recalculate: If set to True, will delete existing :class:`~ifxbilling.models.BillingRecord` objects :type recalculate: bool, optional @@ -66,7 +69,7 @@ def calculate_billing_month(self, year, month, organizations=None, recalculate=F results = {} for organization in organizations_to_process: - result = self.generate_billing_records_for_organization(year, month, organization, recalculate) + result = self.generate_billing_records_for_organization(year, month, organization, user, recalculate) results[organization.name] = result if year == 2023 and (month == 3 or month == 4): @@ -74,7 +77,7 @@ def calculate_billing_month(self, year, month, organizations=None, recalculate=F return Resultinator(results) - def generate_billing_records_for_organization(self, year, month, organization, recalculate, **kwargs): + def generate_billing_records_for_organization(self, year, month, organization, user, recalculate, **kwargs): ''' Create and save all of the :class:`~ifxbilling.models.BillingRecord` objects for the month for an organization. @@ -102,6 +105,9 @@ def generate_billing_records_for_organization(self, year, month, organization, r :param organization: The organization whose :class:`~ifxbilling.models.BillingRecord` objects should be generated :type organization: list + :param user: Limit billing to this user. If not set, all users will be processed. + :type user: :class:`~ifxuser.models.IfxUser` + :param recalculate: If True, will delete existing :class:`~ifxbilling.models.BillingRecord` objects if possible :type recalculate: bool @@ -587,7 +593,8 @@ def generate_billing_records_for_allocation_user(self, year, month, user, organi if BillingRecord.objects.filter(product_usage=product_usage).exists(): if recalculate: - BillingRecord.objects.filter(product_usage=product_usage).delete() + for br in BillingRecord.objects.filter(product_usage=product_usage): + br.delete() else: msg = f'Billing record already exists for usage {product_usage}' raise Exception(msg) @@ -733,6 +740,24 @@ def get_errors_by_organization(self, organization_name=None): errors_by_lab[lab] = output[1] return errors_by_lab + def get_other_errors_by_organization(self, organization_name=None): + ''' + Return dict of all of the "Other" errors keyed by lab + ''' + errors_by_lab = {} + for lab, output in self.results.items(): + if output[1] and 'No project' not in output[1][0]: + if organization_name is None or lab == organization_name: + for error in output[1]: + for error_type, regex in self.error_types.items(): + if error_type == 'Other' and re.search(regex, error): + if lab not in errors_by_lab: + errors_by_lab[lab] = [] + errors_by_lab[lab].append(error) + elif re.search(regex, error): + break + return errors_by_lab + def get_successes_by_organization(self, organization_name=None): ''' Return dict of successes keyed by lab @@ -771,3 +796,59 @@ def get_organizations_by_error_type(self): errors_by_type[error_type].append(lab) break return errors_by_type + + +class ColdfrontRebalance(Rebalance): + ''' + Coldfront Rebalance. Does not do a user-specific rebalance, but rather the entire organization so that offer letter reprocessing is done. + ''' + + def get_recalculate_body(self, user, account_data): + ''' + Get the body of the recalculate POST + ''' + if not account_data or not len(account_data): + raise Exception('No account data provided') + + # Figure out the organization that needs to be rebalanced from the account_data + organization = None + try: + account = Account.objects.filter(ifxacct=account_data[0]['account']).first() + organization = account.organization + except Account.DoesNotExist: + raise Exception(f'Account {account_data[0]["account"]} not found') + + return { + 'recalculate': False, + 'user_ifxorg': organization.ifxorg, + } + + def remove_billing_records(self, user, account_data): + ''' + Remove the billing records for the given facility, year, month, and organization (as determined by the account_data) + Need to clear out the whole org so that offer letter allocations can be properly credited + ''' + if not account_data or not len(account_data): + raise Exception('No account data provided') + + # Figure out the organization that needs to be rebalanced from the account_data + organization = None + try: + account = Account.objects.filter(ifxacct=account_data[0]['account']).first() + organization = account.organization + except Account.DoesNotExist: + raise Exception(f'Account {account_data[0]["account"]} not found') + + if not organization: + raise Exception(f'Organization not found for account {account_data[0]["account"]}') + + # Remove the billing records for the organization + billing_records = BillingRecord.objects.filter( + product_usage__product__facility=self.facility, + account__organization=organization, + year=self.year, + month=self.month, + ).exclude(current_state='FINAL') + + for br in billing_records: + br.delete() diff --git a/coldfront/plugins/ifx/templates/plugins/ifx/calculate_billing_month.html b/coldfront/plugins/ifx/templates/plugins/ifx/calculate_billing_month.html index db386b09d..9c8214b11 100644 --- a/coldfront/plugins/ifx/templates/plugins/ifx/calculate_billing_month.html +++ b/coldfront/plugins/ifx/templates/plugins/ifx/calculate_billing_month.html @@ -138,7 +138,7 @@ $.ajax({ contentType: 'application/json', - url: `/ifx/api/calculate-billing-month/${year}/${month}/`, + url: `/ifx/api/billing/calculate-billing-month/RC/${year}/${month}/`, method: 'POST', headers: {'X-CSRFToken': '{{ csrf_token }}'}, data: data, @@ -161,9 +161,10 @@ error: function (jqXHR, status, error) { alert(status + ' ' + error) }, - }).success( - alert('Update started. You will receive an email when the update is complete.') - ) + success: function () { + alert('Update started. You will receive an email when the update is complete.') + } + }) }) }) })(jQuery) diff --git a/coldfront/plugins/ifx/urls.py b/coldfront/plugins/ifx/urls.py index 14521eaf8..b2172af71 100644 --- a/coldfront/plugins/ifx/urls.py +++ b/coldfront/plugins/ifx/urls.py @@ -21,7 +21,7 @@ path('api/billing/get-summary-by-account/', ifxbilling_views.get_summary_by_account), path('api/billing/get-pending-year-month//', ifxbilling_views.get_pending_year_month), path('api/billing/rebalance/', ifxbilling_views.rebalance), - path('api/calculate-billing-month///', calculate_billing_month, name='calculate-billing-month'), + path('api/billing/calculate-billing-month////', calculate_billing_month, name='calculate-billing-month'), path('api/run-report/', run_report), path('api/get-org-names/', get_org_names, name='get-org-names'), path('api/get-product-usages/', get_product_usages, name='get-product-usages'), diff --git a/coldfront/plugins/ifx/views.py b/coldfront/plugins/ifx/views.py index 30aef1136..c914fd54e 100644 --- a/coldfront/plugins/ifx/views.py +++ b/coldfront/plugins/ifx/views.py @@ -94,19 +94,20 @@ def get_billing_record_list(request): raise PermissionDenied return ifxbilling_get_billing_record_list(request._request) -@login_required @api_view(['POST',]) @permission_classes([AdminPermissions,]) -def calculate_billing_month(request, year, month): +def calculate_billing_month(request, invoice_prefix, year, month): ''' Calculate billing month view ''' logger.error('Calculating billing records for month %d of year %d', month, year) recalculate = False + user_ifxorg = None try: data = request.data - logger.error('Request data: %s', data) recalculate = data.get('recalculate') and data['recalculate'].lower() == 'true' + if data and 'user_ifxorg' in data: + user_ifxorg = data['user_ifxorg'] except Exception as e: logger.exception(e) return Response(data={'error': 'Cannot parse request body'}, status=status.HTTP_400_BAD_REQUEST) @@ -114,11 +115,24 @@ def calculate_billing_month(request, year, month): logger.debug('Calculating billing records for month %d of year %d, with recalculate flag %s', month, year, str(recalculate)) try: + organizations = ifxuser_models.Organization.objects.filter(org_tree='Harvard') + if user_ifxorg: + organizations = [ifxuser_models.Organization.objects.get(ifxorg=user_ifxorg)] + if recalculate: - ifxbilling_models.BillingRecord.objects.filter(year=year, month=month).delete() + for br in ifxbilling_models.BillingRecord.objects.filter(year=year, month=month): + br.delete() ifxbilling_models.ProductUsageProcessing.objects.filter(product_usage__year=year, product_usage__month=month).delete() calculator = NewColdfrontBillingCalculator() - calculator.calculate_billing_month(year, month, recalculate=recalculate) + resultinator = calculator.calculate_billing_month(year, month, organizations=organizations, recalculate=recalculate) + successes = 0 + errors = [] + for org, result in resultinator.results.items(): + if len(result[0]): + successes += len(result[0]) + errors = [v[0] for v in resultinator.get_other_errors_by_organization().values()] + + return Response(data={ 'successes': successes, 'errors': errors }, status=status.HTTP_200_OK) return Response('OK', status=status.HTTP_200_OK) # pylint: disable=broad-exception-caught except Exception as e: diff --git a/ifxbilling b/ifxbilling index 3a8575b2b..e8a11534f 160000 --- a/ifxbilling +++ b/ifxbilling @@ -1 +1 @@ -Subproject commit 3a8575b2ba26f1189a105e9b8b42ec0d199a8292 +Subproject commit e8a11534f22a0d05c08f7bf26c3d514ccccda29f diff --git a/ifxurls b/ifxurls index 3dd8a1e4c..60c0277f3 160000 --- a/ifxurls +++ b/ifxurls @@ -1 +1 @@ -Subproject commit 3dd8a1e4c47e55f54157fdb8c33c2162663aa789 +Subproject commit 60c0277f3663f02286c61009d4ba6207024abed1 diff --git a/ifxuser b/ifxuser index 49b5ab520..24daba8b4 160000 --- a/ifxuser +++ b/ifxuser @@ -1 +1 @@ -Subproject commit 49b5ab52031d39441f3f638fde2ed1849d86a9c6 +Subproject commit 24daba8b46187a3a1266ca356ce39b39f458f398