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',