Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow setting default and individual SU budgets for project members #437

Merged
merged 3 commits into from
Mar 18, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions allocations/migrations/0009_chargebudget.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Generated by Django 3.2 on 2024-03-08 08:19

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('projects', '0021_delete_status_and_approvedwith_blank'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('allocations', '0008_allocation_balance_service_version'),
]

operations = [
migrations.CreateModel(
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)),
('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)),
],
options={
'unique_together': {('user', 'project')},
},
),
]
52 changes: 51 additions & 1 deletion allocations/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
from django.db import models
import logging

from django.conf import settings
from django.db import models
from django.db.models import ExpressionWrapper, F, Sum, functions

from balance_service.utils import su_calculators
from projects.models import Project
from util.consts import allocation

Expand Down Expand Up @@ -100,3 +104,49 @@ class Charge(models.Model):

def __str__(self):
return f"{self.allocation.project}: {self.start_time}-{self.end_time}"


class ChargeBudget(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
related_name="projectbudgets",
on_delete=models.CASCADE,
)
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
)

class Meta:
unique_together = ('user', 'project',)

def current_usage(self):
"""
Calculate the current charge usage for the user on this project
"""
allocation = su_calculators.get_active_allocation(self.project)
if not allocation:
return 0

charges = Charge.objects.filter(allocation=allocation, user=self.user)

# Avoid doing the calculation if there are no charges
if not charges.exists():
return 0
microseconds_per_hour = 1_000_000 * 3600
return charges.annotate(
charge_duration=ExpressionWrapper(
F('end_time') - F('start_time'), output_field=models.FloatField()
)
).annotate(
charge_cost=F('charge_duration') / microseconds_per_hour * F('hourly_cost')
).aggregate(
total_cost=functions.Coalesce(Sum('charge_cost'), 0.0, output_field=models.IntegerField())
)['total_cost']

def su_left(self):
return self.su_budget - self.current_usage()
18 changes: 17 additions & 1 deletion balance_service/enforcement/usage_enforcement.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from django.contrib.auth import get_user_model
from django.utils import timezone

from allocations.models import Charge
from allocations.models import Charge, ChargeBudget
from balance_service.enforcement import exceptions
from balance_service.utils import su_calculators
from projects.models import Project
Expand Down Expand Up @@ -132,6 +132,19 @@ 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)
left = user_budget.su_left()
if left - new_charge < 0:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if left < new_charge would be more clear.

raise exceptions.BillingError(
message=(
"Reservation for user {} would spend {:.2f} SUs, "
"only {:.2f} left in user budget".format(
user.username, new_charge, left
)
)
)

def check_usage_against_allocation(self, data):
"""Check if we have enough available SUs for this reservation

Expand All @@ -156,6 +169,9 @@ 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
)
approved_alloc = su_calculators.get_consecutive_approved_allocation(
lease_eval.project, alloc
)
Expand Down
237 changes: 161 additions & 76 deletions projects/templates/projects/view_project.html
Original file line number Diff line number Diff line change
Expand Up @@ -146,92 +146,174 @@ <h3>Project Members</h3>
</form>
{% 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>
{% endif %}

<br>
<br>
<table width=100%>
<tr>
<th>Username</th>
<th>First Name</th>
<th>Last Name</th>
<th>Email</th>
<th>Role</th>
<th>Edit</th>
<th></th>
</tr>
{% for join_request in join_requests %}
<tr>
<td>{{ join_request.user.username }}</td>
<td>{{ join_request.user.first_name }}</td>
<td>{{ join_request.user.last_name }}</td>
<td>{{ join_request.user.email }}</td>
<td>
<i>Requested to join</i>
</td>
<td>
{% if can_manage_project_membership %}
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" id="joinRequestDropdown" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fa fa-gear text-info dropbtn"></i><span class="sr-only">Respond to Request</span>
</button>
<div class="dropdown-menu dropdown-content" aria-labelledby="joinRequestDropdown">
<form method="post" style="display:inline">

{% for u in users %}
<div class="panel panel-default" style="background-color: transparent">
<div class="panel-heading" style="background-color: #d9edf7;">
<div class="row" align="center">
<div class="col-xs-4 col-sm-4">
{{ u.username }}
</div>
<div class="col-xs-4 col-sm-4">
{{ u.first_name }}, {{ u.last_name }}
</div>
<div class="col-xs-2 col-sm-2">
</div>
<div class="col-xs-2 col-sm-2">
{% if u.username == request.user.username or can_manage_project_membership %}
<form class="pull-right" role="form" method="post" style="display:inline">
{% csrf_token %}
<input type="hidden" name="user_ref" value="{{ u.username }}">
<button
type="submit"
class="btn btn-danger btn-xs pull-right"
onclick="return confirm('Removes user from project, are you sure?');"
name="del_user">Remove user</button>
</form>
{% endif %}
</div>
</div>
</div>
<div class="panel-body">
<div class="row" align="center">
<div class="col-xs-4 col-sm-4">
<b>Email</b>: {{ u.email }}
</div>
<div class="col-xs-4 col-sm-4">
<div class="row" align="center">
<div
class="col-xs-{% if can_manage_project_membership %}2{% else %}6{% endif %} col-sm-{% if can_manage_project_membership %}2{% else %}6{% endif %} col-lg-{% if can_manage_project_membership %}2{% else %}6{% endif %}"
align="right">
<b>Role: </b>
</div>
<div
class="col-xs-{% if can_manage_project_membership %}3{% else %}6{% endif %} col-sm-{% if can_manage_project_membership %}3{% else %}6{% endif %} col-lg-{% if can_manage_project_membership %}3{% else %}6{% endif %}"
align="left">
{% if can_manage_project_membership %}
<form role="form" method="post" style="display:inline">
{% csrf_token %}
<input type="hidden" name="user_ref" value="{{ u.username }}">
<select id="role_{{ u.id }}" name="user_role" class="form-select role_selector" data-initial="{{ u.role }}">
{% for role in roles %}
<option value="{{ role }}" {% if role == u.role %}selected disabled{% endif %}>{{ role }}</option>
{% endfor %}
</select>
</form>
{% else %}
{{u.role}}
{% endif %}
</div>
{% if can_manage_project_membership %}
<div class="col-xs-2 col-sm-2 col-lg-2">
<button type="submit" id="submit_role_{{ u.id }}" class="btn btn-xs btn-primary" name="change_role">Submit</button>
</div>
{% endif %}
{% if can_manage_project_membership %}
<div class="col-xs-2 col-sm-2 col-lg-2">
<button type="button" onclick="resetRole('role_{{ u.id }}')" id="cancel_role_{{ u.id }}" class="btn btn-xs btn-danger" >Cancel</button>
</div>
{% endif %}
</div>
</div>
{% if can_manage_project_membership %}
<form role="form" method="post">
{% csrf_token %}
<input type="hidden" name="join_request" value="{{ join_request.id }}">
<button type="submit" class="btn btn-xs btn-success btn-block" name="accept_join_request">Accept</button>
<button type="submit" class="btn btn-xs btn-danger btn-block" name="reject_join_request">Reject</button>
<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>
<div class="col-xs-5 col-sm-5 col-lg-5">
<input
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How usable is this? It seems like it would be tricky to precisely set the slider. Also steps of 1000 is a lot, since our allocations only have 20000 SUs, and a class might have more than 20 people.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe 500 would be sufficient step.

style="width:100%; display: inline-block"
type="range"
value="{{ u.su_budget }}"
min="{{u.su_used}}"
max="{{ su_allocated }}"
step="1000"
name="su_budget_user"
oninput="this.parentNode.previousElementSibling.querySelector('.su-budget-display').innerHTML = this.value;">
</div>
<div class="col-xs-2 col-sm-2 col-lg-2">
<input type="hidden" name="user_ref" value="{{ u.username }}">
<button class="btn btn-xs btn-primary" type="submit">Set</button>
</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>
</div>
{% endif %}
</div>
<div class="row" align="center">
<div class="col-xs-4 col-sm-4 col-lg-4">
</div>
<div class="col-xs-4 col-sm-4 col-lg-4">
{% if u.daypass %} Daypass: {{ u.daypass }} remain {% endif %}
</div>
<div class="col-xs-4 col-sm-4 col-lg-4">
</div>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
{% for u in users %}
<tr>
<td>{{ u.username }}</td>
<td>{{ u.first_name }}</td>
<td>{{ u.last_name }}</td>
<td>{{ u.email }}</td>
{% if can_manage_project_membership %}
</div>
</div>
{% endfor %}
{% if join_requests or invitations %}
<table width=100%>
<tr>
<th>Username</th>
<th>First Name</th>
<th>Last Name</th>
<th>Email</th>
<th>Role</th>
<th>Edit</th>
<th></th>
</tr>
{% endif %}
{% for join_request in join_requests %}
<tr>
<td>{{ join_request.user.username }}</td>
<td>{{ join_request.user.first_name }}</td>
<td>{{ join_request.user.last_name }}</td>
<td>{{ join_request.user.email }}</td>
<td>
<form role="form" method="post" style="display:inline">
{% csrf_token %}
<input type="hidden" name="user_ref" value="{{ u.username }}">
<select id="role_{{ u.id }}" name="user_role" class="form-select role_selector" data-initial="{{ u.role }}" disabled>
{% for role in roles %}
<option value="{{ role }}" {% if role == u.role %}selected disabled{% endif %}>{{ role }}</option>
{% endfor %}
</select>
<button type="submit" id="submit_role_{{ u.id }}" class="btn btn-xs btn-primary" name="change_role" style="display:none;">Submit</button>
<button type="button" onclick="resetRole('role_{{ u.id }}')" id="cancel_role_{{ u.id }}" class="btn btn-xs btn-danger" style="display:none;">Cancel</button>
</form>
<i>Requested to join</i>
</td>
{% else %}
<td>{{u.role}}</td>
{% endif %}
<td>
{% if u.username == request.user.username or can_manage_project_membership %}
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fa fa-gear text-info dropbtn" title="Manage Member"></i>
</button>
<div class="dropdown-menu dropdown-content" aria-labelledby="dropdownMenuButton">
{% if can_manage_project %}
<button onclick="enableChangeRole('{{ u.id }}')" class="btn btn-xs btn-block">Change Role</button>
{% endif %}
<form role="form" method="post" style="display:inline">
{% csrf_token %}
<input type="hidden" name="user_ref" value="{{ u.username }}">
<button type="submit" class="btn btn-xs btn-danger btn-block" name="del_user">Remove user</button>
</form>
<td>
{% if can_manage_project_membership %}
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" id="joinRequestDropdown" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fa fa-gear text-info dropbtn"></i><span class="sr-only">Respond to Request</span>
</button>
<div class="dropdown-menu dropdown-content" aria-labelledby="joinRequestDropdown">
<form method="post" style="display:inline">
{% csrf_token %}
<input type="hidden" name="join_request" value="{{ join_request.id }}">
<button type="submit" class="btn btn-xs btn-success btn-block" name="accept_join_request">Accept</button>
<button type="submit" class="btn btn-xs btn-danger btn-block" name="reject_join_request">Reject</button>
</form>
</div>
</div>
</div>
{% endif %}
</td>
<td>{% if u.daypass %} Daypass: {{ u.daypass }} remain {% endif %}</td>
</tr>
{% endif %}
</td>
</tr>
{% endfor %}
{% for i in invitations %}
<tr>
Expand Down Expand Up @@ -266,7 +348,10 @@ <h3>Project Members</h3>
</td>
</tr>
{% endfor %}
</table>
{% if join_requests or invitations %}
</table>
{% endif %}

<br>
<br>

Expand Down
Loading
Loading