diff --git a/coldfront/core/allocation/management/commands/load_allocation_renewal_requests.py b/coldfront/core/allocation/management/commands/load_allocation_renewal_requests.py
index ecc7598d8f..33d020ff49 100644
--- a/coldfront/core/allocation/management/commands/load_allocation_renewal_requests.py
+++ b/coldfront/core/allocation/management/commands/load_allocation_renewal_requests.py
@@ -17,8 +17,9 @@
from coldfront.core.allocation.models import AllocationRenewalRequestStatusChoice
from coldfront.core.project.models import Project
from coldfront.core.project.models import ProjectUser
+from coldfront.core.project.utils_.computing_allowance_eligibility_manager import ComputingAllowanceEligibilityManager
from coldfront.core.project.utils_.renewal_utils import AllocationRenewalProcessingRunner
-from coldfront.core.project.utils_.renewal_utils import has_non_denied_renewal_request
+from coldfront.core.resource.utils_.allowance_utils.computing_allowance import ComputingAllowance
from coldfront.core.resource.utils_.allowance_utils.interface import ComputingAllowanceInterface
from coldfront.core.utils.common import add_argparse_dry_run_argument
from coldfront.core.utils.common import utc_now_offset_aware
@@ -110,6 +111,8 @@ def parse_input_file(self, file_path, allocation_period):
seen_pi_emails = set()
+ interface = ComputingAllowanceInterface()
+
for input_dict in input_dicts:
for key in input_dict:
input_dict[key] = input_dict[key].strip()
@@ -128,13 +131,21 @@ def parse_input_file(self, file_path, allocation_period):
continue
seen_pi_emails.add(pi_email)
- # Validate that the PI does not already have a non-'Denied'
+ # Validate that the PI is eligible to make a
# AllocationRenewalRequest for this period.
pi = self._get_user_with_email(pi_email)
- if (isinstance(pi, User) and
- has_non_denied_renewal_request(pi, allocation_period)):
- already_renewed.append(pi)
- continue
+ if isinstance(pi, User):
+ computing_allowance = ComputingAllowance(
+ interface.allowance_from_code(post_project_name[:3]))
+ computing_allowance_eligibility_manager = \
+ ComputingAllowanceEligibilityManager(
+ computing_allowance,
+ allocation_period=allocation_period)
+ is_user_eligible = \
+ computing_allowance_eligibility_manager.is_user_eligible(pi)
+ if not is_user_eligible:
+ already_renewed.append(pi)
+ continue
# Validate that the pre-Project exists, if given. The PI may have
# previously not had a Project.
diff --git a/coldfront/core/project/forms.py b/coldfront/core/project/forms.py
index 0d4afb0f50..c3279a1ced 100644
--- a/coldfront/core/project/forms.py
+++ b/coldfront/core/project/forms.py
@@ -1,5 +1,3 @@
-import datetime
-
from django import forms
from django.core.validators import MinLengthValidator
from django.shortcuts import get_object_or_404
@@ -7,10 +5,15 @@
from coldfront.core.project.models import (Project, ProjectReview,
ProjectUserRoleChoice)
+from coldfront.core.project.utils_.computing_allowance_eligibility_manager import ComputingAllowanceEligibilityManager
+from coldfront.core.resource.utils_.allowance_utils.computing_allowance import ComputingAllowance
+from django.contrib.auth.models import User
from coldfront.core.user.utils_.host_user_utils import eligible_host_project_users
from coldfront.core.utils.common import import_from_settings
from coldfront.core.resource.utils import get_compute_resource_names
+import datetime
+
EMAIL_DIRECTOR_PENDING_PROJECT_REVIEW_EMAIL = import_from_settings(
'EMAIL_DIRECTOR_PENDING_PROJECT_REVIEW_EMAIL')
@@ -243,6 +246,86 @@ def clean(self):
return cleaned_data
+# note: from coldfront\core\project\forms_\new_project_forms\request_forms.py
+class PIChoiceField(forms.ModelChoiceField):
+
+ def label_from_instance(self, obj):
+ return f'{obj.first_name} {obj.last_name} ({obj.email})'
+
+
+class ReviewEligibilityForm(ReviewStatusForm):
+
+ PI = PIChoiceField(
+ label='Principal Investigator',
+ queryset=User.objects.none(),
+ required=False,
+ widget=DisabledChoicesSelectWidget(),
+ help_text= 'Please confirm the PI for this project. If the PI is ' \
+ 'listed, select them from the dropdown and do not fill ' \
+ 'out the PI information fields. If the PI is not ' \
+ 'listed, empty the field and provide the PI\'s ' \
+ 'information below.')
+ first_name = forms.CharField(max_length=30, required=False)
+ middle_name = forms.CharField(max_length=30, required=False)
+ last_name = forms.CharField(max_length=150, required=False)
+ email = forms.EmailField(max_length=100, required=False)
+
+ field_order=['PI', 'first_name', 'middle_name', 'last_name', 'email',
+ 'status', 'justification']
+
+ def __init__(self, *args, **kwargs):
+ self.computing_allowance = kwargs.pop('computing_allowance', None)
+ self.allocation_period = kwargs.pop('allocation_period', None)
+ super().__init__(*args, **kwargs)
+ if self.computing_allowance is not None:
+ self.computing_allowance = ComputingAllowance(
+ self.computing_allowance)
+ self.disable_pi_choices()
+ self.exclude_pi_choices()
+
+ def clean(self):
+ cleaned_data = super().clean()
+ pi = self.cleaned_data['PI']
+ if pi is not None and pi not in self.fields['PI'].queryset:
+ raise forms.ValidationError(f'Invalid selection {pi.username}.')
+
+ # TODO: This doesn't include the clean_email method from
+ # SavioProjectNewPIForm.
+
+ return cleaned_data
+
+ def disable_pi_choices(self):
+ """Prevent certain Users, who should be displayed, from being
+ selected as PIs."""
+ computing_allowance_eligibility_manager = \
+ ComputingAllowanceEligibilityManager(
+ self.computing_allowance,
+ allocation_period=self.allocation_period)
+
+ disable_user_pks = \
+ computing_allowance_eligibility_manager.get_ineligible_users(
+ pks_only=True)
+
+ self.fields['PI'].widget.disabled_choices = disable_user_pks
+
+ def exclude_pi_choices(self):
+ """Exclude certain Users from being displayed as PI options."""
+ # Exclude any user that does not have an email address or is inactive.
+ self.fields['PI'].queryset = User.objects.exclude(
+ Q(email__isnull=True) | Q(email__exact='') | Q(is_active=False))
+
+ def clean(self):
+ cleaned_data = super().clean()
+ status = cleaned_data.get('status', 'Pending')
+ # Require justification for denials.
+ if status == 'Denied':
+ justification = cleaned_data.get('justification', '')
+ if not justification.strip():
+ raise forms.ValidationError(
+ 'Please provide a justification for your decision.')
+ return cleaned_data
+
+
class JoinRequestSearchForm(forms.Form):
project_name = forms.CharField(label='Project Name',
max_length=100, required=False)
diff --git a/coldfront/core/project/forms_/new_project_forms/request_forms.py b/coldfront/core/project/forms_/new_project_forms/request_forms.py
index d3fa1d3e27..0ae240cc7e 100644
--- a/coldfront/core/project/forms_/new_project_forms/request_forms.py
+++ b/coldfront/core/project/forms_/new_project_forms/request_forms.py
@@ -2,17 +2,12 @@
from coldfront.core.allocation.models import AllocationPeriod
from coldfront.core.project.forms import DisabledChoicesSelectWidget
from coldfront.core.project.models import Project
-from coldfront.core.project.utils_.new_project_utils import non_denied_new_project_request_statuses
-from coldfront.core.project.utils_.new_project_utils import pis_with_new_project_requests_pks
-from coldfront.core.project.utils_.new_project_utils import project_pi_pks
-from coldfront.core.project.utils_.renewal_utils import non_denied_renewal_request_statuses
-from coldfront.core.project.utils_.renewal_utils import pis_with_renewal_requests_pks
+from coldfront.core.project.utils_.computing_allowance_eligibility_manager import ComputingAllowanceEligibilityManager
from coldfront.core.resource.models import Resource
from coldfront.core.resource.utils_.allowance_utils.computing_allowance import ComputingAllowance
from coldfront.core.resource.utils_.allowance_utils.constants import BRCAllowances
from coldfront.core.resource.utils_.allowance_utils.constants import LRCAllowances
from coldfront.core.resource.utils_.allowance_utils.interface import ComputingAllowanceInterface
-from coldfront.core.user.utils_.host_user_utils import is_lbl_employee
from coldfront.core.utils.common import utc_now_offset_aware
from django import forms
@@ -151,7 +146,6 @@ class PIChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj):
return f'{obj.first_name} {obj.last_name} ({obj.email})'
-
class SavioProjectExistingPIForm(forms.Form):
PI = PIChoiceField(
@@ -180,45 +174,14 @@ def clean(self):
def disable_pi_choices(self):
"""Prevent certain Users, who should be displayed, from being
selected as PIs."""
- disable_user_pks = set()
-
- if self.computing_allowance.is_one_per_pi() and self.allocation_period:
- # Disable any PI who has:
- # (a) an existing Project with the allowance*,
- # (b) a new project request for a Project with the allowance
- # during the AllocationPeriod*, or
- # (c) an allowance renewal request for a Project with the
- # allowance during the AllocationPeriod*.
- # * Projects/requests must have ineligible statuses.
- resource = self.computing_allowance.get_resource()
- project_status_names = ['New', 'Active', 'Inactive']
- disable_user_pks.update(
- project_pi_pks(
- computing_allowance=resource,
- project_status_names=project_status_names))
- new_project_request_status_names = list(
- non_denied_new_project_request_statuses().values_list(
- 'name', flat=True))
- disable_user_pks.update(
- pis_with_new_project_requests_pks(
- self.allocation_period,
- computing_allowance=resource,
- request_status_names=new_project_request_status_names))
- renewal_request_status_names = list(
- non_denied_renewal_request_statuses().values_list(
- 'name', flat=True))
- disable_user_pks.update(
- pis_with_renewal_requests_pks(
- self.allocation_period,
- computing_allowance=resource,
- request_status_names=renewal_request_status_names))
+ computing_allowance_eligibility_manager = \
+ ComputingAllowanceEligibilityManager(
+ self.computing_allowance,
+ allocation_period=self.allocation_period)
- if flag_enabled('LRC_ONLY'):
- # On LRC, PIs must be LBL employees.
- non_lbl_employees = set(
- [user.pk for user in User.objects.all()
- if not is_lbl_employee(user)])
- disable_user_pks.update(non_lbl_employees)
+ disable_user_pks = \
+ computing_allowance_eligibility_manager.get_ineligible_users(
+ pks_only=True)
self.fields['PI'].widget.disabled_choices = disable_user_pks
@@ -239,8 +202,8 @@ class SavioProjectNewPIForm(forms.Form):
def clean_email(self):
cleaned_data = super().clean()
email = cleaned_data['email'].lower()
- if (User.objects.filter(username=email).exists() or
- User.objects.filter(email=email).exists()):
+ if User.objects.filter(username=email).exists() or \
+ User.objects.filter(email=email).exists():
raise forms.ValidationError(
'A user with that email address already exists.')
diff --git a/coldfront/core/project/forms_/renewal_forms/request_forms.py b/coldfront/core/project/forms_/renewal_forms/request_forms.py
index 300348e5a3..e0ffcde60b 100644
--- a/coldfront/core/project/forms_/renewal_forms/request_forms.py
+++ b/coldfront/core/project/forms_/renewal_forms/request_forms.py
@@ -8,10 +8,7 @@
from coldfront.core.project.models import ProjectUser
from coldfront.core.project.models import ProjectUserRoleChoice
from coldfront.core.project.models import ProjectUserStatusChoice
-from coldfront.core.project.utils_.new_project_utils import non_denied_new_project_request_statuses
-from coldfront.core.project.utils_.new_project_utils import pis_with_new_project_requests_pks
-from coldfront.core.project.utils_.renewal_utils import non_denied_renewal_request_statuses
-from coldfront.core.project.utils_.renewal_utils import pis_with_renewal_requests_pks
+from coldfront.core.project.utils_.computing_allowance_eligibility_manager import ComputingAllowanceEligibilityManager
from coldfront.core.resource.utils_.allowance_utils.computing_allowance import ComputingAllowance
from coldfront.core.resource.utils_.allowance_utils.interface import ComputingAllowanceInterface
from coldfront.core.project.utils_.renewal_survey import is_renewal_survey_completed
@@ -19,7 +16,6 @@
from flags.state import flag_enabled
from django import forms
from django.utils.safestring import mark_safe
-from django.core.validators import MinLengthValidator
from django.core.exceptions import ValidationError
@@ -68,34 +64,20 @@ def __init__(self, *args, **kwargs):
def disable_pi_choices(self, pi_project_users):
"""Prevent certain of the given ProjectUsers, who should be
displayed, from being selected for renewal."""
+ computing_allowance_eligibility_manager = \
+ ComputingAllowanceEligibilityManager(
+ self.computing_allowance,
+ allocation_period=self.allocation_period)
+
+ disable_user_pks = \
+ computing_allowance_eligibility_manager.get_ineligible_users(
+ is_renewal=True, pks_only=True)
+
disable_project_user_pks = set()
- if self.computing_allowance.is_one_per_pi():
- # Disable any PI who has:
- # (a) a new project request for a Project during the
- # AllocationPeriod*, or
- # (b) an AllocationRenewalRequest during the AllocationPeriod*.
- # * Requests must have ineligible statuses.
- resource = self.computing_allowance.get_resource()
- disable_user_pks = set()
- new_project_request_status_names = list(
- non_denied_new_project_request_statuses().values_list(
- 'name', flat=True))
- disable_user_pks.update(
- pis_with_new_project_requests_pks(
- self.allocation_period,
- computing_allowance=resource,
- request_status_names=new_project_request_status_names))
- renewal_request_status_names = list(
- non_denied_renewal_request_statuses().values_list(
- 'name', flat=True))
- disable_user_pks.update(
- pis_with_renewal_requests_pks(
- self.allocation_period,
- computing_allowance=resource,
- request_status_names=renewal_request_status_names))
- for project_user in pi_project_users:
- if project_user.user.pk in disable_user_pks:
- disable_project_user_pks.add(project_user.pk)
+ for project_user in pi_project_users:
+ if project_user.user.pk in disable_user_pks:
+ disable_project_user_pks.add(project_user.pk)
+
self.fields['PI'].widget.disabled_choices = disable_project_user_pks
diff --git a/coldfront/core/project/templates/project/project_request/savio/project_review_eligibility.html b/coldfront/core/project/templates/project/project_request/savio/project_review_eligibility.html
index d76e7e6159..0bae8bbaf0 100644
--- a/coldfront/core/project/templates/project/project_request/savio/project_review_eligibility.html
+++ b/coldfront/core/project/templates/project/project_request/savio/project_review_eligibility.html
@@ -9,6 +9,9 @@
{% block content %}
+
+
+
Review Eligibility
@@ -39,11 +42,17 @@
Cancel
+
{% include 'project/project_request/savio/project_request_extra_fields_modal.html' with extra_fields_form=extra_fields_form %}
{% include 'project/project_request/savio/project_request_survey_modal.html' with survey_form=survey_form %}
-
{% endblock %}
diff --git a/coldfront/core/project/utils_/computing_allowance_eligibility_manager.py b/coldfront/core/project/utils_/computing_allowance_eligibility_manager.py
new file mode 100644
index 0000000000..37a37faf6e
--- /dev/null
+++ b/coldfront/core/project/utils_/computing_allowance_eligibility_manager.py
@@ -0,0 +1,204 @@
+from django.contrib.auth.models import User
+
+from flags.state import flag_enabled
+
+from coldfront.core.allocation.models import AllocationPeriod
+from coldfront.core.allocation.models import AllocationRenewalRequest
+from coldfront.core.project.models import ProjectUser
+from coldfront.core.allocation.models import AllocationRenewalRequestStatusChoice
+from coldfront.core.project.models import ProjectAllocationRequestStatusChoice
+from coldfront.core.project.models import ProjectStatusChoice
+from coldfront.core.project.models import SavioProjectAllocationRequest
+from coldfront.core.resource.utils_.allowance_utils.computing_allowance import ComputingAllowance
+from coldfront.core.resource.utils_.allowance_utils.interface import ComputingAllowanceInterface
+from coldfront.core.user.utils_.host_user_utils import is_lbl_employee
+
+
+class ComputingAllowanceEligibilityManager(object):
+ """A class for managing whether a User is eligible for a computing
+ allowance of a given type, optionally under a particular
+ AllocationPeriod. The following constraints apply:
+ - On LRC, computing allowances may only be allocated to PIs who
+ are LBL employees.
+ - If the computing allowance is limited to one per PI and is
+ periodic:
+ - A PI may not have an existing renewal request with the
+ same computing allowance type in the period.
+ - A PI may not have an existing new project request with
+ the same computing allowance type in the period.
+ - A PI may not already have an active Project with the
+ same computing allowance type, except in the case of
+ renewal.
+ """
+
+ def __init__(self, computing_allowance, allocation_period=None):
+ """Take a ComputingAllowance and an optional AllocationPeriod.
+ The period is required if the allowance is periodic."""
+ assert isinstance(computing_allowance, ComputingAllowance)
+ self._computing_allowance = computing_allowance
+
+ if allocation_period is not None:
+ assert isinstance(allocation_period, AllocationPeriod)
+ self._allocation_period = allocation_period
+
+ if self._computing_allowance.is_periodic():
+ assert self._allocation_period is not None
+
+ def get_ineligible_users(self, is_renewal=False, pks_only=False):
+ """Return a QuerySet of Users who are ineligible for the
+ computing allowance. Optionally return a set of User primary
+ keys instead."""
+ ineligible_user_pks = set()
+
+ if flag_enabled('LRC_ONLY'):
+ non_lbl_employee_user_pks = {
+ user.pk
+ for user in User.objects.all()
+ if not is_lbl_employee(user)}
+ ineligible_user_pks.update(non_lbl_employee_user_pks)
+
+ if self._computing_allowance.is_one_per_pi():
+
+ if self._computing_allowance.is_periodic():
+
+ existing_renewal_requests = self._existing_renewal_requests(
+ self._allocation_period)
+ renewal_request_pi_pks = existing_renewal_requests.values_list(
+ 'pi', flat=True)
+ ineligible_user_pks.update(set(renewal_request_pi_pks))
+
+ existing_new_project_requests = \
+ self._existing_new_project_requests(self._allocation_period)
+ new_project_request_pi_pks = \
+ existing_new_project_requests.values_list('pi', flat=True)
+ ineligible_user_pks.update(set(new_project_request_pi_pks))
+
+ if not is_renewal:
+ existing_pi_project_users = \
+ self._existing_pi_project_users()
+ existing_pk_pks = existing_pi_project_users.values_list(
+ 'user', flat=True)
+ ineligible_user_pks.update(set(existing_pk_pks))
+
+ if pks_only:
+ return ineligible_user_pks
+ return User.objects.filter(pk__in=ineligible_user_pks)
+
+ def is_user_eligible(self, user, is_renewal=False):
+ """Return whether the given User is eligible for the computing
+ allowance."""
+ if flag_enabled('LRC_ONLY'):
+ if not is_lbl_employee(user):
+ return False
+
+ if self._computing_allowance.is_one_per_pi():
+
+ if self._computing_allowance.is_periodic():
+
+ existing_renewal_requests = self._existing_renewal_requests(
+ self._allocation_period, user)
+ if existing_renewal_requests.exists():
+ return False
+
+ existing_new_project_requests = \
+ self._existing_new_project_requests(
+ self._allocation_period, user)
+ if existing_new_project_requests.exists():
+ return False
+
+ if not is_renewal:
+ existing_pi_project_users = \
+ self._existing_pi_project_users(user)
+ if existing_pi_project_users.exists():
+ return False
+
+ return True
+
+ def _existing_new_project_requests(self, allocation_period, pi=None):
+ """Return a QuerySet of new project request objects:
+ - Under the given AllocationPeriod
+ - With the given computing allowance
+ - With a status that would render the associated PI
+ ineligible to make another one
+ - Optionally belonging to the given PI
+ """
+ ineligible_statuses = self._ineligible_new_project_request_statuses()
+ kwargs = {
+ 'allocation_period': allocation_period,
+ 'computing_allowance': self._computing_allowance.get_resource(),
+ 'status__in': ineligible_statuses,
+ }
+ if pi is not None:
+ kwargs['pi'] = pi
+ return SavioProjectAllocationRequest.objects.filter(**kwargs)
+
+ def _existing_pi_project_users(self, pi=None):
+ """Return a QuerySet of ProjectUser objects:
+ - With the given computing allowance
+ - With the "Principal Investigator" ProjectUser role
+ - With the "Active" ProjectUser status
+ - With a project status that would render the associated PIs
+ ineligible to be the PI of another project with the same
+ computing allowance
+ - Optionally belonging to the given PI
+ """
+ computing_allowance_interface = ComputingAllowanceInterface()
+ project_prefix = computing_allowance_interface.code_from_name(
+ self._computing_allowance.get_name())
+
+ ineligible_project_statuses = self._ineligible_project_statuses()
+ kwargs = {
+ 'project__name__startswith': project_prefix,
+ 'role__name': 'Principal Investigator',
+ 'status__name': 'Active',
+ 'project__status__in': ineligible_project_statuses,
+ }
+ if pi is not None:
+ kwargs['user'] = pi
+ return ProjectUser.objects.filter(**kwargs)
+
+ def _existing_renewal_requests(self, allocation_period, pi=None):
+ """Return a QuerySet of AllocationRenewalRequest objects:
+ - Under the given AllocationPeriod
+ - With the given computing allowance
+ - With a status that would render the associated PI
+ ineligible to make another one
+ - Optionally belonging to the given PI
+ """
+ ineligible_statuses = self._ineligible_renewal_request_statuses()
+ kwargs = {
+ 'allocation_period': allocation_period,
+ 'computing_allowance': self._computing_allowance.get_resource(),
+ 'status__in': ineligible_statuses,
+ }
+ if pi is not None:
+ kwargs['pi'] = pi
+ return AllocationRenewalRequest.objects.filter(**kwargs)
+
+ @staticmethod
+ def _ineligible_new_project_request_statuses():
+ """Return a QuerySet of ProjectAllocationRequestStatusChoice
+ objects. If a PI has a relevant request with one of these
+ statuses, they are considered ineligible for a computing
+ allowance."""
+ return ProjectAllocationRequestStatusChoice.objects.exclude(
+ name='Denied')
+
+ @staticmethod
+ def _ineligible_project_statuses():
+ """Return a QuerySet of ProjectStatusChoice objects. If the
+ computing allowance is one-per-PI, and a user is the PI of a
+ project with one of these statuses, they are considered
+ ineligible to receive another computing allowance of the same
+ type."""
+ return ProjectStatusChoice.objects.exclude(
+ name__in=['Archived', 'Denied'])
+
+ @staticmethod
+ def _ineligible_renewal_request_statuses():
+ """Return a QuerySet of AllocationRenewalRequestStatusChoice
+ objects. If a PI has a relevant request with one of these
+ statuses, they are considered ineligible for a computing
+ allowance."""
+ return AllocationRenewalRequestStatusChoice.objects.exclude(
+ name='Denied')
diff --git a/coldfront/core/project/utils_/new_project_utils.py b/coldfront/core/project/utils_/new_project_utils.py
index ffcf8fdd14..c4be12210a 100644
--- a/coldfront/core/project/utils_/new_project_utils.py
+++ b/coldfront/core/project/utils_/new_project_utils.py
@@ -1,19 +1,15 @@
from coldfront.api.statistics.utils import set_project_user_allocation_value
from coldfront.core.allocation.models import AllocationAttribute
from coldfront.core.allocation.models import AllocationAttributeType
-from coldfront.core.allocation.models import AllocationPeriod
from coldfront.core.allocation.models import AllocationStatusChoice
from coldfront.core.allocation.utils import get_project_compute_allocation
from coldfront.core.project.models import ProjectAllocationRequestStatusChoice
-from coldfront.core.project.models import ProjectUser
from coldfront.core.project.models import ProjectStatusChoice
from coldfront.core.project.models import SavioProjectAllocationRequest
from coldfront.core.project.models import VectorProjectAllocationRequest
from coldfront.core.project.signals import new_project_request_denied
from coldfront.core.project.utils_.request_processing_utils import create_project_users
-from coldfront.core.resource.models import Resource
from coldfront.core.resource.utils_.allowance_utils.computing_allowance import ComputingAllowance
-from coldfront.core.resource.utils_.allowance_utils.interface import ComputingAllowanceInterface
from coldfront.core.statistics.models import ProjectTransaction
from coldfront.core.statistics.models import ProjectUserTransaction
from coldfront.core.user.utils import account_activation_url
@@ -30,7 +26,6 @@
from django.conf import settings
from django.db import transaction
-from django.db.models import Q
from django.urls import reverse
from flags.state import flag_enabled
@@ -43,76 +38,6 @@
logger = logging.getLogger(__name__)
-def non_denied_new_project_request_statuses():
- """Return a queryset of ProjectAllocationRequestStatusChoices that
- do not have the name 'Denied'."""
- return ProjectAllocationRequestStatusChoice.objects.filter(
- ~Q(name='Denied'))
-
-
-def pis_with_new_project_requests_pks(allocation_period,
- computing_allowance=None,
- request_status_names=[]):
- """Return a list of primary keys of PIs of new project requests for
- the given AllocationPeriod that match the given filters.
-
- Parameters:
- - allocation_period (AllocationPeriod): The AllocationPeriod to
- filter with
- - computing_allowance (Resource): An optional computing
- allowance to filter with
- - request_status_names (list[str]): A list of names of request
- statuses to filter with
-
- Returns:
- - A list of integers representing primary keys of matching PIs.
-
- Raises:
- - AssertionError, if an input has an unexpected type.
- """
- assert isinstance(allocation_period, AllocationPeriod)
- f = Q(allocation_period=allocation_period)
- if computing_allowance is not None:
- assert isinstance(computing_allowance, Resource)
- f = f & Q(computing_allowance=computing_allowance)
- if request_status_names:
- f = f & Q(status__name__in=request_status_names)
- return set(
- SavioProjectAllocationRequest.objects.filter(
- f).values_list('pi__pk', flat=True))
-
-
-def project_pi_pks(computing_allowance=None, project_status_names=[]):
- """Return a list of primary keys of PI Users of Projects that match
- the given filters.
-
- Parameters:
- - computing_allowance (Resource): An optional computing
- allowance to filter with
- - project_status_names (list[str]): A list of names of Project
- statuses to filter with
-
- Returns:
- - A list of integers representing primary keys of matching PIs.
-
- Raises:
- - AssertionError, if an input has an unexpected type.
- - ComputingAllowanceInterfaceError, if allowance-related values
- cannot be retrieved.
- """
- project_prefix = ''
- if computing_allowance is not None:
- assert isinstance(computing_allowance, Resource)
- interface = ComputingAllowanceInterface()
- project_prefix = interface.code_from_name(computing_allowance.name)
- return set(
- ProjectUser.objects.filter(
- role__name='Principal Investigator',
- project__name__startswith=project_prefix,
- project__status__name__in=project_status_names
- ).values_list('user__pk', flat=True))
-
-
class ProjectDenialRunner(object):
"""An object that performs necessary database changes when a new
project request is denied."""
diff --git a/coldfront/core/project/utils_/renewal_utils.py b/coldfront/core/project/utils_/renewal_utils.py
index 2a4a044ef7..a180b21f0f 100644
--- a/coldfront/core/project/utils_/renewal_utils.py
+++ b/coldfront/core/project/utils_/renewal_utils.py
@@ -6,15 +6,14 @@
from coldfront.core.allocation.models import AllocationRenewalRequestStatusChoice
from coldfront.core.allocation.models import AllocationStatusChoice
from coldfront.core.allocation.utils import get_project_compute_allocation
-from coldfront.core.allocation.utils import prorated_allocation_amount
from coldfront.core.project.models import Project
from coldfront.core.project.models import ProjectAllocationRequestStatusChoice
from coldfront.core.project.models import ProjectStatusChoice
from coldfront.core.project.models import ProjectUser
from coldfront.core.project.models import ProjectUserRoleChoice
from coldfront.core.project.models import SavioProjectAllocationRequest
+from coldfront.core.project.utils_.computing_allowance_eligibility_manager import ComputingAllowanceEligibilityManager
from coldfront.core.project.utils_.request_processing_utils import create_project_users
-from coldfront.core.resource.models import Resource
from coldfront.core.resource.utils_.allowance_utils.computing_allowance import ComputingAllowance
from coldfront.core.resource.utils_.allowance_utils.interface import ComputingAllowanceInterface
from coldfront.core.statistics.models import ProjectTransaction
@@ -199,73 +198,23 @@ def get_pi_active_unique_project(pi_user, computing_allowance,
return project
-def has_non_denied_renewal_request(pi, allocation_period):
- """Return whether the given PI User has a non-"Denied"
- AllocationRenewalRequest for the given AllocationPeriod."""
- if not isinstance(pi, User):
- raise TypeError(f'{pi} is not a User object.')
- if not isinstance(allocation_period, AllocationPeriod):
- raise TypeError(
- f'{allocation_period} is not an AllocationPeriod object.')
- status_names = ['Under Review', 'Approved', 'Complete']
- return AllocationRenewalRequest.objects.filter(
- pi=pi,
- allocation_period=allocation_period,
- status__name__in=status_names).exists()
-
-
def is_any_project_pi_renewable(project, allocation_period):
"""Return whether the Project has at least one PI who is eligible to
make an AllocationRenewalRequest during the given
AllocationPeriod."""
+ interface = ComputingAllowanceInterface()
+ computing_allowance = ComputingAllowance(
+ interface.allowance_from_project(project))
+ computing_allowance_eligibility_manager = \
+ ComputingAllowanceEligibilityManager(
+ computing_allowance, allocation_period=allocation_period)
for pi in project.pis():
- if not has_non_denied_renewal_request(pi, allocation_period):
+ if computing_allowance_eligibility_manager.is_user_eligible(
+ pi, is_renewal=True):
return True
return False
-def non_denied_renewal_request_statuses():
- """Return a queryset of AllocationRenewalRequestStatusChoices that
- do not have the name 'Denied'."""
- return AllocationRenewalRequestStatusChoice.objects.filter(
- ~Q(name='Denied'))
-
-
-def pis_with_renewal_requests_pks(allocation_period, computing_allowance=None,
- request_status_names=[]):
- """Return a list of primary keys of PIs of allocation renewal
- requests for the given AllocationPeriod that match the given filters.
-
- Parameters:
- - allocation_period (AllocationPeriod): The AllocationPeriod to
- filter with
- - computing_allowance (Resource): An optional computing
- allowance to filter with
- - request_status_names (list[str]): A list of names of request
- statuses to filter with
-
- Returns:
- - A list of integers representing primary keys of matching PIs.
-
- Raises:
- - AssertionError, if an input has an unexpected type.
- - ComputingAllowanceInterfaceError, if allowance-related values
- cannot be retrieved.
- """
- assert isinstance(allocation_period, AllocationPeriod)
- f = Q(allocation_period=allocation_period)
- if computing_allowance is not None:
- assert isinstance(computing_allowance, Resource)
- interface = ComputingAllowanceInterface()
- project_prefix = interface.code_from_name(computing_allowance.name)
- f = f & Q(post_project__name__startswith=project_prefix)
- if request_status_names:
- f = f & Q(status__name__in=request_status_names)
- return set(
- AllocationRenewalRequest.objects.filter(
- f).values_list('pi__pk', flat=True))
-
-
def send_allocation_renewal_request_approval_email(request, num_service_units):
"""Send a notification email to the requester and PI associated with
the given AllocationRenewalRequest stating that the request has been
diff --git a/coldfront/core/project/views_/new_project_views/approval_views.py b/coldfront/core/project/views_/new_project_views/approval_views.py
index 2332e11e74..f4df2918ae 100644
--- a/coldfront/core/project/views_/new_project_views/approval_views.py
+++ b/coldfront/core/project/views_/new_project_views/approval_views.py
@@ -3,7 +3,7 @@
from coldfront.core.allocation.utils import calculate_service_units_to_allocate
from coldfront.core.project.forms import MemorandumSignedForm
from coldfront.core.project.forms import ReviewDenyForm
-from coldfront.core.project.forms import ReviewStatusForm
+from coldfront.core.project.forms import ReviewStatusForm, ReviewEligibilityForm
from coldfront.core.project.forms_.new_project_forms.request_forms import NewProjectExtraFieldsFormFactory
from coldfront.core.project.forms_.new_project_forms.request_forms import SavioProjectSurveyForm
from coldfront.core.project.forms_.new_project_forms.approval_forms import SavioProjectReviewSetupForm
@@ -18,8 +18,10 @@
from coldfront.core.project.utils_.new_project_utils import send_project_request_pooling_email
from coldfront.core.project.utils_.new_project_utils import VectorProjectProcessingRunner
from coldfront.core.project.utils_.new_project_utils import vector_request_state_status
+from django.contrib.auth.models import User
from coldfront.core.resource.utils_.allowance_utils.computing_allowance import ComputingAllowance
from coldfront.core.resource.utils_.allowance_utils.interface import ComputingAllowanceInterface
+from coldfront.core.user.models import UserProfile
from coldfront.core.utils.common import display_time_zone_current_date
from coldfront.core.utils.common import format_date_month_name_day_year
from coldfront.core.utils.common import utc_now_offset_aware
@@ -32,7 +34,7 @@
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.mixins import UserPassesTestMixin
-from django.db import transaction
+from django.db import IntegrityError, transaction
from django.db.models import Q
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
@@ -587,10 +589,12 @@ def is_checklist_complete(self):
class SavioProjectReviewEligibilityView(LoginRequiredMixin,
UserPassesTestMixin,
SavioProjectRequestMixin, FormView):
- form_class = ReviewStatusForm
+ form_class = ReviewEligibilityForm
template_name = (
'project/project_request/savio/project_review_eligibility.html')
+ logger = logging.getLogger(__name__)
+
def test_func(self):
"""UserPassesTestMixin tests."""
if self.request.user.is_superuser:
@@ -606,7 +610,7 @@ def dispatch(self, request, *args, **kwargs):
if redirect is not None:
return redirect
return super().dispatch(request, *args, **kwargs)
-
+
def form_valid(self, form):
form_data = form.cleaned_data
status = form_data['status']
@@ -622,9 +626,48 @@ def form_valid(self, form):
if status == 'Denied':
runner = ProjectDenialRunner(self.request_obj)
runner.run()
+
+ if form_data['PI'] != self.request_obj.pi:
+ if form_data['PI'] is not None:
+ self.request_obj.pi = form_data['PI']
+ self.request_obj.save()
+ elif all([form_data['first_name'],
+ form_data['last_name'],
+ form_data['email']]):
+ try:
+ self.request_obj.pi = User.objects.create(
+ username=form_data['email'],
+ first_name=form_data['first_name'],
+ last_name=form_data['last_name'],
+ email=form_data['email'],
+ is_active=True)
+ self.request_obj.pi.save()
+ self.request_obj.save()
+ except IntegrityError as e:
+ self.logger.error(f'User {form_data["email"]} '
+ 'unexpectedly exists.')
+ raise e
+ try:
+ pi_profile = self.request_obj.pi.userprofile
+ except UserProfile.DoesNotExist as e:
+ self.logger.error(
+ f'User {form_data["email"]} unexpectedly has no '
+ 'UserProfile.')
+ raise e
+ pi_profile.middle_name = form_data['middle_name'] or None
+ pi_profile.upgrade_request = utc_now_offset_aware()
+ pi_profile.save()
+ else:
+ incomplete_fields = [field for field in ['email', 'first_name',
+ 'last_name'] if not form_data[field]]
+ message = \
+ f'Incomplete field(s): {", ".join(incomplete_fields)}. ' \
+ 'Please specify a PI or provide all required fields ' \
+ 'for a new PI.'
+ messages.error(self.request, message)
+ return self.form_invalid(form)
self.request_obj.save()
-
message = (
f'Eligibility status for request {self.request_obj.pk} has been '
f'set to {status}.')
@@ -642,6 +685,7 @@ def get_context_data(self, **kwargs):
def get_initial(self):
initial = super().get_initial()
eligibility = self.request_obj.state['eligibility']
+ initial['PI'] = self.request_obj.pi
initial['status'] = eligibility['status']
initial['justification'] = eligibility['justification']
return initial
@@ -1220,7 +1264,7 @@ def is_checklist_complete(self):
class VectorProjectReviewEligibilityView(LoginRequiredMixin,
UserPassesTestMixin,
VectorProjectRequestMixin, FormView):
- form_class = ReviewStatusForm
+ form_class = ReviewEligibilityForm
template_name = (
'project/project_request/vector/project_review_eligibility.html')
diff --git a/coldfront/core/project/views_/renewal_views/request_views.py b/coldfront/core/project/views_/renewal_views/request_views.py
index 48cb8017d5..d11d44c523 100644
--- a/coldfront/core/project/views_/renewal_views/request_views.py
+++ b/coldfront/core/project/views_/renewal_views/request_views.py
@@ -17,10 +17,10 @@
from coldfront.core.project.models import ProjectUser
from coldfront.core.project.models import ProjectUserStatusChoice
from coldfront.core.project.models import SavioProjectAllocationRequest
+from coldfront.core.project.utils_.computing_allowance_eligibility_manager import ComputingAllowanceEligibilityManager
from coldfront.core.project.utils_.permissions_utils import is_user_manager_or_pi_of_project
from coldfront.core.project.utils_.renewal_utils import get_current_allowance_year_period
from coldfront.core.project.utils_.renewal_utils import get_pi_active_unique_project
-from coldfront.core.project.utils_.renewal_utils import has_non_denied_renewal_request
from coldfront.core.project.utils_.renewal_utils import send_new_allocation_renewal_request_admin_notification_email
from coldfront.core.project.utils_.renewal_utils import send_new_allocation_renewal_request_pi_notification_email
from coldfront.core.project.utils_.renewal_utils import send_new_allocation_renewal_request_pooling_notification_email
@@ -352,13 +352,20 @@ def done(self, form_list, **kwargs):
pi = tmp['PI'].user
allocation_period = tmp['allocation_period']
- # If the PI already has a non-denied request for the period, raise
- # an exception. Such PIs are not selectable in the 'pi_selection'
- # step, but a request could have been created between selection and
- # final submission.
- if has_non_denied_renewal_request(pi, allocation_period):
+ # If the PI is ineligible for a renewal request for the computing
+ # allowance for the period, raise an exception. Such PIs are not
+ # selectable in the 'pi_selection' step, but a request could have
+ # been created beteween selection and final submission.
+ computing_allowance_eligibility_manager = \
+ ComputingAllowanceEligibilityManager(
+ self.computing_allowance,
+ allocation_period=allocation_period)
+ is_pi_eligible = \
+ computing_allowance_eligibility_manager.is_user_eligible(
+ pi, is_renewal=True)
+ if not is_pi_eligible:
raise Exception(
- f'PI {pi.username} already has a non-denied '
+ f'PI {pi.username} is ineligible to make an '
f'AllocationRenewalRequest for AllocationPeriod '
f'{allocation_period.name}.')
@@ -632,13 +639,20 @@ def done(self, form_list, **kwargs):
pi = tmp['PI'].user
allocation_period = tmp['allocation_period']
- # If the PI already has a non-denied request for the period, raise
- # an exception. Such PIs are not selectable in the 'pi_selection'
- # step, but a request could have been created between selection and
- # final submission.
- if has_non_denied_renewal_request(pi, allocation_period):
+ # If the PI is ineligible for a renewal request for the computing
+ # allowance for the period, raise an exception. Such PIs are not
+ # selectable in the 'pi_selection' step, but a request could have
+ # been created beteween selection and final submission.
+ computing_allowance_eligibility_manager = \
+ ComputingAllowanceEligibilityManager(
+ self.computing_allowance,
+ allocation_period=allocation_period)
+ is_pi_eligible = \
+ computing_allowance_eligibility_manager.is_user_eligible(
+ pi, is_renewal=True)
+ if not is_pi_eligible:
raise Exception(
- f'PI {pi.username} already has a non-denied '
+ f'PI {pi.username} is ineligible to make an '
f'AllocationRenewalRequest for AllocationPeriod '
f'{allocation_period.name}.')
diff --git a/coldfront/core/user/utils_/host_user_utils.py b/coldfront/core/user/utils_/host_user_utils.py
index ba0ae51acc..e6afe42ab7 100644
--- a/coldfront/core/user/utils_/host_user_utils.py
+++ b/coldfront/core/user/utils_/host_user_utils.py
@@ -22,6 +22,14 @@ def host_user_lbl_email(user):
return lbl_email_address(host_user)
+def is_lbl_email_address(email):
+ """Return whether the given email address (str) is an LBL email
+ address."""
+ email = email.lower()
+ email_domain = lbl_email_domain()
+ return email.endswith(email_domain)
+
+
def is_lbl_employee(user):
"""Return whether the given User is an LBL employee."""
return bool(lbl_email_address(user))
@@ -31,7 +39,7 @@ def lbl_email_address(user):
"""Return the LBL email address (str) of the given User if they have
one, else None."""
assert isinstance(user, User)
- email_domain = '@lbl.gov'
+ email_domain = lbl_email_domain()
if user.email.endswith(email_domain):
return user.email
email_addresses = EmailAddress.objects.filter(
@@ -42,6 +50,11 @@ def lbl_email_address(user):
return email_addresses.first().email
+def lbl_email_domain():
+ """Return the LBL email domain, including the "@" symbol."""
+ return '@lbl.gov'
+
+
def needs_host(user):
"""Return whether the given User needs a host user."""
assert isinstance(user, User)
diff --git a/coldfront/core/utils/tests/test_mou_notify_upload_download.py b/coldfront/core/utils/tests/test_mou_notify_upload_download.py
index 8b84bc1ec1..740b2b17c9 100644
--- a/coldfront/core/utils/tests/test_mou_notify_upload_download.py
+++ b/coldfront/core/utils/tests/test_mou_notify_upload_download.py
@@ -171,7 +171,7 @@ def edit_extra_fields_url(pk):
def test_new_project(self):
"""Test that the MOU notification task, MOU upload, and MOU download
features work as expected."""
- eligibility = { 'status': 'Approved' }
+ eligibility = { 'PI': self.request.pi.pk, 'status': 'Approved' }
readiness = { 'status': 'Approved' }
extra_fields = {
'course_name': 'TEST 101',