diff --git a/allocations/migrations/0010_allocation_low_allocation_warning_issued.py b/allocations/migrations/0010_allocation_low_allocation_warning_issued.py new file mode 100644 index 00000000..765e63ca --- /dev/null +++ b/allocations/migrations/0010_allocation_low_allocation_warning_issued.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-11-20 17:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('allocations', '0009_chargebudget'), + ] + + operations = [ + migrations.AddField( + model_name='allocation', + name='low_allocation_warning_issued', + field=models.DateTimeField(null=True), + ), + ] diff --git a/allocations/models.py b/allocations/models.py index b1323766..af2b57e7 100644 --- a/allocations/models.py +++ b/allocations/models.py @@ -49,6 +49,7 @@ class Allocation(models.Model): su_allocated = models.FloatField(null=True) su_used = models.FloatField(null=True) balance_service_version = models.IntegerField(default=2, null=False) + low_allocation_warning_issued = models.DateTimeField(null=True) def as_dict(self): return Allocation.to_dict(self) diff --git a/allocations/tasks.py b/allocations/tasks.py index 86894fa3..d21fa402 100644 --- a/allocations/tasks.py +++ b/allocations/tasks.py @@ -13,6 +13,7 @@ from django.forms.models import model_to_dict from django.utils import timezone from django.utils.html import strip_tags +from django.urls import reverse from keycloak.exceptions import KeycloakClientError from allocations.models import Allocation, Charge @@ -60,13 +61,14 @@ def _send_expiration_warning_mail(alloc, today): f"day{'s' if time_until_expiration.days != 1 else ''}" ) + project_url = f'https://chameleoncloud.org{reverse("projects:view_project", args=[alloc.project.id])}' docs_url = ( "https://chameleoncloud.readthedocs.io/en/latest/user/project.html" "#recharge-or-extend-your-allocation " ) email_body = f"""

- The allocation for project {charge_code} + The allocation for project {charge_code} will expire {time_description}. See our Documentation on how to recharge or extend your allocation. @@ -140,6 +142,95 @@ def warn_user_for_expiring_allocation(): ) +def _send_low_allocation_warning(alloc, percent_used): + charge_code = alloc.project.charge_code + project_url = f'https://chameleoncloud.org{reverse("projects:view_project", args=[alloc.project.id])}' + docs_url = ( + "https://chameleoncloud.readthedocs.io/en/latest/user/project.html" + "#recharge-or-extend-your-allocation " + ) + email_body = f""" +

+ The allocation for project {charge_code} + has used {percent_used}% of allocated SUs. See our + Documentation + on how to recharge or extend your allocation. +

+ """ + + mail_sent = None + email = alloc.project.pi.email + if not email: + LOG.warning( + f"PI for project {charge_code} has no email; " + "cannot send low usage warning" + ) + return None + try: + mail_sent = send_mail( + subject=f"NOTICE: Your allocation for project {charge_code} has used " + f"{percent_used}% of allocated SUs!", + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[email], + message=strip_tags(" ".join(email_body.split()).strip()), + html_message=email_body, + ) + except Exception: + pass + + return mail_sent + + +@task +def warn_user_for_low_allocations(): + """ + Sends an email to users when their allocation is 90% utilized + """ + # NOTE this avoids sending a low allocation email if we've already + # sent an expiration warning email, as if the allocation is soon expiring + # low remaining SUs does not matter. + active_allocations = Allocation.objects.filter( + status="active", + low_allocation_warning_issued__isnull=True, + expiration_warning_issued__isnull=True, + ) + + emails_sent = 0 + # Iterate over all active allocations that haven't been issued warnings + for alloc in active_allocations: + # Calculate the percentage of used SUs + balances = project_balances([alloc.project.id])[0] + # Skip edge cases + if balances["total"] is None: + continue + percentage_used = round(100.0 * balances["total"] / balances["allocated"], 2) + + # Ignore if allocation hasn't used 90% of SUs. + if percentage_used < 90: + continue + # Otherwise send warning mail + mail_sent = _send_low_allocation_warning(alloc, percentage_used) + charge_code = alloc.project.charge_code + + # If we successfully sent mail, log it in the database + if mail_sent: + emails_sent += 1 + LOG.info(f"Warned PI about low allocation {alloc.id}") + try: + with transaction.atomic(): + alloc.low_allocation_warning_issued = datetime.now(timezone.utc) + alloc.save() + except Exception: + LOG.error( + f"Failed to update ORM with low warning timestamp " + f"for project {charge_code}." + ) + else: + LOG.error( + f"Failed to send expiration warning email for project {charge_code}" + ) + + def _deactivate_allocation(alloc): balance = project_balances([alloc.project.id]) if not balance: diff --git a/chameleon/settings.py b/chameleon/settings.py index 1c8fd469..38bdad2c 100644 --- a/chameleon/settings.py +++ b/chameleon/settings.py @@ -834,6 +834,10 @@ "task": "allocations.tasks.warn_user_for_expiring_allocation", "schedule": crontab(minute=0, hour=7), }, + "warn-user-for-low-allocation": { + "task": "allocations.tasks.warn_user_for_low_allocations", + "schedule": crontab(minute=30, hour=7), + }, "check_charge": { "task": "allocations.tasks.check_charge", "schedule": crontab(