Skip to content

Commit

Permalink
Default SU budget for project
Browse files Browse the repository at this point in the history
  • Loading branch information
AnishReddyRavula committed Mar 14, 2024
1 parent 4e3a93d commit 0099c9a
Show file tree
Hide file tree
Showing 7 changed files with 118 additions and 53 deletions.
8 changes: 4 additions & 4 deletions allocations/migrations/0009_chargebudget.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
# 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


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'),
]
Expand All @@ -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)),
],
Expand Down
23 changes: 14 additions & 9 deletions allocations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',)
Expand All @@ -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']

Expand Down
31 changes: 26 additions & 5 deletions balance_service/enforcement/usage_enforcement.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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, "
Expand Down Expand Up @@ -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
)
Expand Down
19 changes: 19 additions & 0 deletions projects/migrations/0021_project_default_su_budget.py
Original file line number Diff line number Diff line change
@@ -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)]),
),
]
2 changes: 2 additions & 0 deletions projects/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
59 changes: 37 additions & 22 deletions projects/templates/projects/view_project.html
Original file line number Diff line number Diff line change
Expand Up @@ -147,16 +147,12 @@ <h3>Project Members</h3>
{% include "projects/bulk_invite_modal.html" %}
{% include "projects/bulk_remove_modal.html" %}
<br>
<div class="budget-setting">
<form class="form-inline" role="form" method="post" >
{% csrf_token %}
<div class="form-group">
<label for="defaultSUbudget">Set Default SU Budget for All Members:</label>
<input type="number" class="form-control" id="defaultSUbudget" name="default_su_budget" placeholder="">
<button type="submit" class="btn btn-default">Set Default Budget</button>
</div>
</form>
</div>
<form class="form-inline" role="form" method="post" >
{% csrf_token %}
<label for="defaultSUbudget">Set Default SU Budget for All Members:</label>
<input style="width: 15%;"type="number" class="form-control" id="defaultSUbudget" name="default_su_budget" placeholder="{{ project_default_su }}" min="0" max="{{ su_allocated }}">
<button type="submit" class="btn btn-default">Set Default Budget</button>
</form>
{% endif %}

<br>
Expand Down Expand Up @@ -230,36 +226,55 @@ <h3>Project Members</h3>
{% endif %}
</div>
</div>
{% if can_manage_project_membership %}
{% if can_manage_project_membership and u.role == 'Member' %}
<form role="form" method="post">
{% csrf_token %}
<div class="col-xs-4 col-sm-4 col-lg-4">
<div class="row">
<div class="col-xs-5 col-sm-5 col-lg-5">
<b>Usage</b>: <p style="display: inline-block">{{u.su_used}}/</p>
<p class="su-budget-display" style="display: inline-block">{{ u.su_budget }}</p>
<div class="col-xs-4 col-sm-4 col-lg-4">
<b>Used SU</b>: <p style="display: inline-block;">{{u.su_used}}</p>
</div>
<div class="col-xs-5 col-sm-5 col-lg-5">
<div class="col-xs-8 col-sm-8 col-lg-8">
<b>SU Budget</b>:
<input
style="width:100%; display: inline-block"
type="number"
class="su-budget-display"
style="display: inline-block; height:100%"
placeholder=""
min="{{u.su_used}}"
max="{{ su_allocated }}"
step="100"
value="{{ u.su_budget }}"
oninput="this.parentNode.parentNode.nextElementSibling.querySelector('.su-budget-slider').value = this.value;">
</div>
</div>
<div class="row">
<div class="col-xs-9 col-sm-9 col-lg-9" style="padding-top: 5px">
<input
class="su-budget-slider"
type="range"
value="{{ u.su_budget }}"
min="{{u.su_used}}"
max="{{ su_allocated }}"
step="1000"
step="100"
name="su_budget_user"
oninput="this.parentNode.previousElementSibling.querySelector('.su-budget-display').innerHTML = this.value;">
oninput="this.parentNode.parentNode.previousElementSibling.querySelector('.su-budget-display').value = this.value;">
</div>
<div class="col-xs-2 col-sm-2 col-lg-2">
<div class="col-xs-1 col-sm-1 col-lg-1">
<input type="hidden" name="user_ref" value="{{ u.username }}">
<button class="btn btn-xs btn-primary" type="submit">Set</button>
</div>
<div class="col-xs-2 col-sm-2 col-lg-2">
</div>
</div>
</div>
</form>
{% else %}
<div class="col-xs-4 col-sm-4 col-lg-4">
<b>Budget</b>: <p class="su-budget-display" style="display: inline-block">{{ u.su_budget }}</p>
{% elif can_manage_project_membership or u.username == request.user.username%}
<div class="col-xs-2 col-sm-2 col-lg-2">
<b>Used SU</b>: {{u.su_used}}
</div>
<div class="col-xs-2 col-sm-2 col-lg-2">
<b>SU Budget</b>: <p class="su-budget-display" style="display: inline-block">{{ u.su_budget }}</p>
</div>
{% endif %}
</div>
Expand Down
29 changes: 16 additions & 13 deletions projects/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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!")
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
},
)

Expand Down

0 comments on commit 0099c9a

Please sign in to comment.