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 @@

Project Members

{% include "projects/bulk_invite_modal.html" %} {% include "projects/bulk_remove_modal.html" %}
-
-
- {% csrf_token %} -
- - - -
-
-
+
+ {% csrf_token %} + + + +
{% endif %}
@@ -230,36 +226,55 @@

Project Members

{% endif %} - {% if can_manage_project_membership %} + {% if can_manage_project_membership and u.role == 'Member' %}
{% csrf_token %}
-
- Usage:

{{u.su_used}}/

-

{{ u.su_budget }}

+
+ Used SU:

{{u.su_used}}

-
+
+ SU Budget: +
+
+
+
+ + oninput="this.parentNode.parentNode.previousElementSibling.querySelector('.su-budget-display').value = this.value;">
-
+
+
+
- {% else %} -
- Budget:

{{ u.su_budget }}

+ {% elif can_manage_project_membership or u.username == request.user.username%} +
+ Used SU: {{u.su_used}} +
+
+ SU Budget:

{{ u.su_budget }}

{% endif %}
diff --git a/projects/views.py b/projects/views.py index 30b66e2e..552bff12 100644 --- a/projects/views.py +++ b/projects/views.py @@ -312,13 +312,16 @@ def notify_join_request_user(django_request, join_request): ) -def set_budget_for_user_in_project(user, project, target_budget, enforced_by): +def set_budget_for_user_in_project(user, project, target_budget): + # Creates SU budget for (user, project), deletes budget if target_budget==0 charge_budget, created = ChargeBudget.objects.get_or_create( - user=user, project=project, enforced_by=enforced_by + user=user, project=project ) + if target_budget == 0: + charge_budget.delete() + return charge_budget.su_budget = target_budget charge_budget.save() - return charge_budget @login_required @@ -328,6 +331,7 @@ def view_project(request, project_id): try: project = mapper.get_project(project_id) + portal_project = Project.objects.get(id=project.id) except Exception as e: logger.error(e) raise Http404("The requested project does not exist!") @@ -417,28 +421,26 @@ def view_project(request, project_id): ) elif "su_budget_user" in request.POST: budget_user = User.objects.get(username=request.POST["user_ref"]) - budget_user_project = Project.objects.get(id=project.id) - enforced_by = User.objects.get(username=request.user.username) - charge_budget = set_budget_for_user_in_project( - budget_user, budget_user_project, request.POST["su_budget_user"], enforced_by + set_budget_for_user_in_project( + budget_user, portal_project, request.POST["su_budget_user"] ) messages.success( request, ( f"SU budget for user {budget_user.username} " - f"is currently set to {charge_budget.su_budget}" + f"is currently set to {request.POST['su_budget_user']}" ) ) elif "default_su_budget" in request.POST: - budget_user_project = Project.objects.get(id=project.id) + portal_project.default_su_budget = request.POST["default_su_budget"] + portal_project.save() for u in users: u_role = user_roles.get(u.username, "member") logger.warning(f"user role is {u_role}, {u}") if u_role in ["manager", "admin"]: continue - enforced_by = User.objects.get(username=request.user.username) - charge_budget = set_budget_for_user_in_project( - u, budget_user_project, request.POST["default_su_budget"], enforced_by + set_budget_for_user_in_project( + u, portal_project, portal_project.default_su_budget ) messages.success(request, "Updated SU budget for all non-manager users") elif "del_invite" in request.POST: @@ -578,7 +580,7 @@ def view_project(request, project_id): su_budget_value = current_allocation_su_allocated else: su_budget_value = su_budget.su_budget - user["su_budget"] = su_budget_value + user["su_budget"] = int(su_budget_value) user["su_used"] = ChargeBudget( user=portal_user, project=budget_project ).current_usage() @@ -635,6 +637,7 @@ def view_project(request, project_id): "roles": ROLES, "host": request.get_host(), "su_allocated": current_allocation_su_allocated, + "project_default_su": portal_project.default_su_budget, }, )