diff --git a/allocations/migrations/0009_chargebudget.py b/allocations/migrations/0009_chargebudget.py new file mode 100644 index 00000000..fd37c23e --- /dev/null +++ b/allocations/migrations/0009_chargebudget.py @@ -0,0 +1,30 @@ +# 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_project_default_su_budget'), + 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, 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)), + ], + options={ + 'unique_together': {('user', 'project')}, + }, + ), + ] diff --git a/allocations/models.py b/allocations/models.py index 13873221..b8caa9fb 100644 --- a/allocations/models.py +++ b/allocations/models.py @@ -1,6 +1,8 @@ -from django.db import models import logging + from django.conf import settings +from django.db import models +from django.core.validators import MinValueValidator from projects.models import Project from util.consts import allocation @@ -100,3 +102,18 @@ 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, validators=[MinValueValidator(0)]) + + class Meta: + unique_together = ('user', 'project',) diff --git a/balance_service/enforcement/usage_enforcement.py b/balance_service/enforcement/usage_enforcement.py index a6685af9..18f7d0c3 100755 --- a/balance_service/enforcement/usage_enforcement.py +++ b/balance_service/enforcement/usage_enforcement.py @@ -21,10 +21,11 @@ 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 +from util.keycloak_client import KeycloakClient LOG = logging.getLogger(__name__) @@ -132,6 +133,34 @@ def get_balance_service_version(self, data): else: return 2 + 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_budget - su_calculators.calculate_user_total_su_usage(user, project) + if left < new_charge: + 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 @@ -156,6 +185,14 @@ def check_usage_against_allocation(self, data): ) alloc = su_calculators.get_active_allocation(lease_eval.project) + 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/balance_service/utils/su_calculators.py b/balance_service/utils/su_calculators.py index 03b4f4f9..c6345e8a 100644 --- a/balance_service/utils/su_calculators.py +++ b/balance_service/utils/su_calculators.py @@ -1,6 +1,9 @@ from django.utils import timezone +from django.db import models +from django.db.models import ExpressionWrapper, F, Sum, functions from projects.models import Project +from allocations.models import Charge def get_used_sus(charge): @@ -76,3 +79,37 @@ def project_balances(project_ids) -> "list[dict]": } ) return project_balances + + +def calculate_user_total_su_usage(user, project): + """ + Calculate the current SU usage for the args:user in args:project + """ + allocation = get_active_allocation(project) + if not allocation: + return 0 + + charges = Charge.objects.filter(allocation=allocation, user=user) + + # Avoid doing the calculation if there are no charges + if not charges.exists(): + return 0 + microseconds_per_hour = 1_000_000 * 3600 + # 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() + ) + ) + # 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') + ) + # 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/projects/membership.py b/projects/membership.py index 1bb7dd4e..1772813f 100644 --- a/projects/membership.py +++ b/projects/membership.py @@ -1,5 +1,7 @@ from datetime import datetime from django.contrib.auth import get_user_model +from allocations.models import ChargeBudget +from projects.models import Project from util.keycloak_client import KeycloakClient, UserAttributes @@ -27,6 +29,8 @@ def add_user_to_project(tas_project, user_ref): If this is the first time the user has been added to any project, also set an attribute with the current date indicating when the user joined their first project. + Also apply the project's default budget to the user + Args: tas_project (pytas.Project): the TAS project representation. user_ref (str): the username or email address of the user. @@ -41,6 +45,13 @@ def add_user_to_project(tas_project, user_ref): keycloak_client.update_user( user.username, lifecycle_allocation_joined=datetime.now() ) + # add the project's default budget to the user + project = Project.objects.get(charge_code=tas_project.chargeCode) + if project.default_su_budget != 0: + user_budget = ChargeBudget( + user=user, project=project, su_budget=project.default_su_budget + ) + user_budget.save() return True 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 4bdfb4c0..124b4679 100644 --- a/projects/templates/projects/view_project.html +++ b/projects/templates/projects/view_project.html @@ -146,92 +146,189 @@

Project Members

{% include "projects/bulk_invite_modal.html" %} {% include "projects/bulk_remove_modal.html" %} +
+
+ {% csrf_token %} + + + +
{% endif %}

- - - - - - - - - - - {% for join_request in join_requests %} - - - - - - - - - {% endfor %} - {% for u in users %} - - - - - - {% if can_manage_project_membership %} + + +{% endfor %} +{% if join_requests or invitations %} +
UsernameFirst NameLast NameEmailRoleEdit
{{ join_request.user.username }}{{ join_request.user.first_name }}{{ join_request.user.last_name }}{{ join_request.user.email }} - Requested to join - - {% if can_manage_project_membership %} -
{{ u.username }}{{ u.first_name }}{{ u.last_name }}{{ u.email }}
+ + + + + + + + + +{% endif %} + {% for join_request in join_requests %} + + + + + - {% else %} - - {% endif %} - - - + {% endif %} + + {% endfor %} {% for i in invitations %} @@ -266,7 +363,10 @@

Project Members

{% endfor %} -
UsernameFirst NameLast NameEmailRoleEdit
{{ join_request.user.username }}{{ join_request.user.first_name }}{{ join_request.user.last_name }}{{ join_request.user.email }} -
- {% csrf_token %} - - - - -
+ Requested to join
{{u.role}} - {% if u.username == request.user.username or can_manage_project_membership %} - + {% if can_manage_project_membership %} + - - {% endif %} - {% if u.daypass %} Daypass: {{ u.daypass }} remain {% endif %}
+{% if join_requests or invitations %} + +{% endif %} +

diff --git a/projects/views.py b/projects/views.py index 3dd7833a..efc17bbf 100644 --- a/projects/views.py +++ b/projects/views.py @@ -25,7 +25,7 @@ from django.views.decorators.http import require_POST from keycloak.exceptions import KeycloakClientError -from allocations.models import Allocation, Charge +from allocations.models import Allocation, Charge, ChargeBudget from balance_service.utils import su_calculators from chameleon.decorators import terms_required from chameleon.keystone_auth import admin_ks_client @@ -312,6 +312,18 @@ def notify_join_request_user(django_request, join_request): ) +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 + ) + if target_budget == 0: + charge_budget.delete() + return + charge_budget.su_budget = target_budget + charge_budget.save() + + @login_required def view_project(request, project_id): mapper = ProjectAllocationMapper(request) @@ -319,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!") @@ -334,6 +347,11 @@ def view_project(request, project_id): user_roles = keycloak_client.get_roles_for_all_project_members( get_charge_code(project) ) + users = get_project_members(project) + if project.active_allocations: + current_allocation_su_allocated = project.active_allocations[0].computeAllocated + else: + current_allocation_su_allocated = 0 can_manage_project_membership, can_manage_project = get_user_permissions( keycloak_client, request.user.username, project ) @@ -394,6 +412,12 @@ def view_project(request, project_id): keycloak_client.set_user_project_role( role_username, get_charge_code(project), role_name ) + if role_name == 'manager': + # delete user budgets for the user if they are manager + user = User.objects.get(username=role_username) + user_budget = ChargeBudget.objects.get(user=user, project=portal_project) + if user_budget: + user_budget.delete() except Exception: logger.exception("Failed to change user role") messages.error( @@ -401,6 +425,30 @@ def view_project(request, project_id): "An unexpected error occurred while attempting " "to change role for this user. Please try again", ) + elif "su_budget_user" in request.POST: + budget_user = User.objects.get(username=request.POST["user_ref"]) + 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 {request.POST['su_budget_user']}" + ) + ) + elif "default_su_budget" in request.POST: + 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 + 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: try: invite_id = request.POST["invite_id"] @@ -510,11 +558,11 @@ def view_project(request, project_id): if isinstance(a.end, str): a.end = datetime.strptime(a.end, "%Y-%m-%dT%H:%M:%SZ") - users = get_project_members(project) if not project_member_or_admin_or_superuser(request.user, project, users): raise PermissionDenied users_mashup = [] + budget_project = Project.objects.get(charge_code=get_charge_code(project)) for u in users: if u.username == project.pi.username: @@ -530,6 +578,18 @@ def view_project(request, project_id): user["email"] = portal_user.email user["first_name"] = portal_user.first_name user["last_name"] = portal_user.last_name + try: + su_budget = ChargeBudget.objects.get( + user=portal_user, project=budget_project + ) + except ChargeBudget.DoesNotExist: + su_budget_value = current_allocation_su_allocated + else: + su_budget_value = su_budget.su_budget + user["su_budget"] = int(su_budget_value) + user["su_used"] = su_calculators.calculate_user_total_su_usage( + portal_user, budget_project + ) # Add if the user is on a daypass existing_daypass = get_daypass(portal_user.id, project_id) if existing_daypass: @@ -582,6 +642,8 @@ def view_project(request, project_id): "bulk_user_form": bulk_user_form, "roles": ROLES, "host": request.get_host(), + "su_allocated": current_allocation_su_allocated, + "project_default_su": portal_project.default_su_budget, }, ) @@ -635,7 +697,7 @@ def _add_users_to_project(request, project, project_id, user_refs): ) except Exception: logger.exception(f"Failed adding user '{user_ref}'") - error_messages.append("Unable to add user '{user_ref}'. Please try again.") + error_messages.append(f"Unable to add user '{user_ref}'. Please try again.") if not error_messages: # If only successes, then either show the message or merge them together