From 0099c9ad12c350b58a11aea2daaaba4eb33bc508 Mon Sep 17 00:00:00 2001 From: Anish Reddy Ravula <2anishreddy@gmail.com> Date: Mon, 11 Mar 2024 17:23:12 -0500 Subject: [PATCH] Default SU budget for project --- allocations/migrations/0009_chargebudget.py | 8 +-- allocations/models.py | 23 +++++--- .../enforcement/usage_enforcement.py | 31 ++++++++-- .../0021_project_default_su_budget.py | 19 ++++++ projects/models.py | 2 + projects/templates/projects/view_project.html | 59 ++++++++++++------- projects/views.py | 29 +++++---- 7 files changed, 118 insertions(+), 53 deletions(-) create mode 100644 projects/migrations/0021_project_default_su_budget.py diff --git a/allocations/migrations/0009_chargebudget.py b/allocations/migrations/0009_chargebudget.py index a509c434..fd37c23e 100644 --- a/allocations/migrations/0009_chargebudget.py +++ b/allocations/migrations/0009_chargebudget.py @@ -1,6 +1,7 @@ -# Generated by Django 3.2 on 2024-03-08 08:19 +# Generated by Django 3.2 on 2024-03-11 21:47 from django.conf import settings +import django.core.validators from django.db import migrations, models import django.db.models.deletion @@ -8,7 +9,7 @@ class Migration(migrations.Migration): dependencies = [ - ('projects', '0021_delete_status_and_approvedwith_blank'), + ('projects', '0021_project_default_su_budget'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('allocations', '0008_allocation_balance_service_version'), ] @@ -18,8 +19,7 @@ class Migration(migrations.Migration): name='ChargeBudget', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('su_budget', models.IntegerField(default=0)), - ('enforced_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('su_budget', models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)])), ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='projects.project')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projectbudgets', to=settings.AUTH_USER_MODEL)), ], diff --git a/allocations/models.py b/allocations/models.py index 63ef918b..2e4a27ac 100644 --- a/allocations/models.py +++ b/allocations/models.py @@ -3,7 +3,7 @@ from django.conf import settings from django.db import models from django.db.models import ExpressionWrapper, F, Sum, functions - +from django.core.validators import MinValueValidator from balance_service.utils import su_calculators from projects.models import Project from util.consts import allocation @@ -115,11 +115,7 @@ class ChargeBudget(models.Model): project = models.ForeignKey( Project, on_delete=models.CASCADE ) - su_budget = models.IntegerField(default=0) - enforced_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE - ) + su_budget = models.IntegerField(default=0, validators=[MinValueValidator(0)]) class Meta: unique_together = ('user', 'project',) @@ -138,13 +134,22 @@ def current_usage(self): if not charges.exists(): return 0 microseconds_per_hour = 1_000_000 * 3600 - return charges.annotate( + # could've used output field as DurationField and further annotate with + # Extract('charge_duration', 'hour') to get the duration in hours but + # Extract requires native DurationField database support. + charges_with_duration_in_ms = charges.annotate( charge_duration=ExpressionWrapper( F('end_time') - F('start_time'), output_field=models.FloatField() ) - ).annotate( + ) + # Calculate cost of each charge in SUs by converting ms to hours + charges_with_actual_cost = charges_with_duration_in_ms.annotate( charge_cost=F('charge_duration') / microseconds_per_hour * F('hourly_cost') - ).aggregate( + ) + # calculates the total cost of charges for the user on the project + # by summing up the charge_cost values calculated for each charge. + # If there are no charges, it returns 0.0 + return charges_with_actual_cost.aggregate( total_cost=functions.Coalesce(Sum('charge_cost'), 0.0, output_field=models.IntegerField()) )['total_cost'] diff --git a/balance_service/enforcement/usage_enforcement.py b/balance_service/enforcement/usage_enforcement.py index b04f4988..349c6d0a 100755 --- a/balance_service/enforcement/usage_enforcement.py +++ b/balance_service/enforcement/usage_enforcement.py @@ -25,6 +25,7 @@ from balance_service.enforcement import exceptions from balance_service.utils import su_calculators from projects.models import Project +from util.keycloak_client import KeycloakClient LOG = logging.getLogger(__name__) @@ -132,10 +133,25 @@ def get_balance_service_version(self, data): else: return 2 - def _check_usage_against_user_budget(self, user, allocation, new_charge): - user_budget = ChargeBudget.objects.get(user=user, project=allocation.project) + def _check_usage_against_user_budget(self, user, project, new_charge): + """Raises error if user charges exceed allocated budget + + Raises: + exceptions.BillingError: + """ + try: + user_budget = ChargeBudget.objects.get(user=user, project=project) + except ChargeBudget.DoesNotExist: + # if the default SU budget for project is 0, then there are no limits + if project.default_su_budget == 0: + return + else: + user_budget = ChargeBudget( + user=user, project=project, su_budget=project.default_su_budget + ) + user_budget.save() left = user_budget.su_left() - if left - new_charge < 0: + if left < new_charge: raise exceptions.BillingError( message=( "Reservation for user {} would spend {:.2f} SUs, " @@ -169,9 +185,14 @@ def check_usage_against_allocation(self, data): ) alloc = su_calculators.get_active_allocation(lease_eval.project) - self._check_usage_against_user_budget( - lease_eval.user, alloc, lease_eval.amount + keycloak_client = KeycloakClient() + role, scopes = keycloak_client.get_user_project_role_scopes( + lease_eval.user.username, lease_eval.project.charge_code ) + if role == 'member': + self._check_usage_against_user_budget( + lease_eval.user, lease_eval.project, lease_eval.amount + ) approved_alloc = su_calculators.get_consecutive_approved_allocation( lease_eval.project, alloc ) diff --git a/projects/migrations/0021_project_default_su_budget.py b/projects/migrations/0021_project_default_su_budget.py new file mode 100644 index 00000000..faf88685 --- /dev/null +++ b/projects/migrations/0021_project_default_su_budget.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2 on 2024-03-11 21:47 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0020_duplicate_publications'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='default_su_budget', + field=models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)]), + ), + ] diff --git a/projects/models.py b/projects/models.py index ade28c4a..88782e34 100644 --- a/projects/models.py +++ b/projects/models.py @@ -12,6 +12,7 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ from django.contrib.sites.models import Site +from django.core.validators import MinValueValidator from projects.user_publication.utils import PublicationUtils @@ -47,6 +48,7 @@ class Project(models.Model): title = models.TextField(blank=False) nickname = models.CharField(max_length=255, blank=False, unique=True) charge_code = models.CharField(max_length=50, blank=False, unique=True) + default_su_budget = models.IntegerField(default=0, validators=[MinValueValidator(0)]) def __str__(self) -> str: return self.charge_code diff --git a/projects/templates/projects/view_project.html b/projects/templates/projects/view_project.html index cb1d0721..d1fdf7e7 100644 --- a/projects/templates/projects/view_project.html +++ b/projects/templates/projects/view_project.html @@ -147,16 +147,12 @@