diff --git a/.gitignore b/.gitignore index 32d292253b..edd0de3f96 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,4 @@ google-drive-key.json overrides.yml New Project Request MOUs Secure Directory Request MOUs -Service Units Purchase Request MOUs \ No newline at end of file +Service Units Purchase Request MOUs diff --git a/bootstrap/development/docker/README.md b/bootstrap/development/docker/README.md index eaf6ee622c..a812fe0608 100644 --- a/bootstrap/development/docker/README.md +++ b/bootstrap/development/docker/README.md @@ -50,11 +50,11 @@ Note that these steps must be run from the root directory of the repo. 6. Start the application stack. Specify a unique Docker [project name](https://docs.docker.com/compose/project-name/) so that resources are placed within a Docker namespace. Examples: "brc-dev", "lrc-dev". ```bash - export DOCKER_PROJECT_NAME=brc-dev +` export DOCKER_PROJECT_NAME=brc-dev docker-compose \ -f bootstrap/development/docker/docker-compose.yml \ -p $DOCKER_PROJECT_NAME \ - up + up` ``` Notes: diff --git a/coldfront/core/allocation/forms.py b/coldfront/core/allocation/forms.py index 4247e2ecb8..f5f4b65d22 100644 --- a/coldfront/core/allocation/forms.py +++ b/coldfront/core/allocation/forms.py @@ -281,13 +281,16 @@ class AllocationPeriodChoiceField(forms.ModelChoiceField): def __init__(self, *args, **kwargs): self.computing_allowance = kwargs.pop('computing_allowance', None) + if (self.computing_allowance is not None and + not isinstance(self.computing_allowance, ComputingAllowance)): + self.computing_allowance = ComputingAllowance( + self.computing_allowance) self.interface = ComputingAllowanceInterface() super().__init__(*args, **kwargs) def label_from_instance(self, obj): - computing_allowance = ComputingAllowance(self.computing_allowance) num_service_units = self.allocation_value(obj) - if computing_allowance.are_service_units_prorated(): + if self.computing_allowance.are_service_units_prorated(): num_service_units = prorated_allocation_amount( num_service_units, utc_now_offset_aware(), obj) return ( @@ -297,7 +300,7 @@ def label_from_instance(self, obj): def allocation_value(self, obj): """Return the allocation value (Decimal) to use based on the allocation type and the AllocationPeriod.""" - allowance_name = self.computing_allowance.name + allowance_name = self.computing_allowance.get_name() if flag_enabled('BRC_ONLY'): assert allowance_name in self._allowances_with_periods_brc() return Decimal( diff --git a/coldfront/core/allocation/utils.py b/coldfront/core/allocation/utils.py index ca6e44ee25..4566ea2535 100644 --- a/coldfront/core/allocation/utils.py +++ b/coldfront/core/allocation/utils.py @@ -237,6 +237,30 @@ def prorated_allocation_amount(amount, dt, allocation_period): return Decimal(f'{math.floor(amount):.2f}') +def calculate_service_units_to_allocate(computing_allowance, + request_time, allocation_period=None): + """Return the number of service units to allocate to a new project + request or allowance renewal request with the given + ComputingAllowance, if it were to be made at the given datetime. If + the request is associated with an AllocationPeriod, use it to + determine the number. Prorate as needed.""" + kwargs = {} + if allocation_period is not None: + kwargs['is_timed'] = True + kwargs['allocation_period'] = allocation_period + + computing_allowance_interface = ComputingAllowanceInterface() + num_service_units = Decimal( + computing_allowance_interface.service_units_from_name( + computing_allowance.get_name(), **kwargs)) + + if computing_allowance.are_service_units_prorated(): + num_service_units = prorated_allocation_amount( + num_service_units, request_time, allocation_period) + + return num_service_units + + def review_cluster_access_requests_url(): domain = settings.CENTER_BASE_URL view = reverse('allocation-cluster-account-request-list') 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 7029299ddf..7cd4aae7a0 100644 --- a/coldfront/core/project/forms_/new_project_forms/request_forms.py +++ b/coldfront/core/project/forms_/new_project_forms/request_forms.py @@ -51,9 +51,13 @@ class SavioProjectAllocationPeriodForm(forms.Form): def __init__(self, *args, **kwargs): computing_allowance = kwargs.pop('computing_allowance', None) super().__init__(*args, **kwargs) - display_timezone = pytz.timezone(settings.DISPLAY_TIME_ZONE) - queryset = self.allocation_period_choices( - computing_allowance, utc_now_offset_aware(), display_timezone) + if computing_allowance is not None: + computing_allowance = ComputingAllowance(computing_allowance) + display_timezone = pytz.timezone(settings.DISPLAY_TIME_ZONE) + queryset = self.allocation_period_choices( + computing_allowance, utc_now_offset_aware(), display_timezone) + else: + queryset = AllocationPeriod.objects.none() self.fields['allocation_period'] = AllocationPeriodChoiceField( computing_allowance=computing_allowance, label='Allocation Period', @@ -87,32 +91,29 @@ def allocation_period_choices(self, computing_allowance, utc_dt, def _allocation_period_choices_brc(self, computing_allowance, date, f, order_by): - """TODO""" - allowance_name = computing_allowance.name + allowance_name = computing_allowance.get_name() if allowance_name in (BRCAllowances.FCA, BRCAllowances.PCA): return self._allocation_period_choices_allowance_year( - date, f, order_by) + computing_allowance, date, f, order_by) elif allowance_name == BRCAllowances.ICA: num_days = self.NUM_DAYS_BEFORE_ICA f = f & Q(start_date__lte=date + timedelta(days=num_days)) - f = f & ( - Q(name__startswith='Fall Semester') | - Q(name__startswith='Spring Semester') | - Q(name__startswith='Summer Sessions')) + allowance_periods_q = computing_allowance.get_period_filters() + if allowance_periods_q is not None: + f = f & allowance_periods_q return AllocationPeriod.objects.filter(f).order_by(*order_by) return AllocationPeriod.objects.none() def _allocation_period_choices_lrc(self, computing_allowance, date, f, order_by): - """TODO""" allowance_name = computing_allowance.name if allowance_name == LRCAllowances.PCA: return self._allocation_period_choices_allowance_year( - date, f, order_by) + computing_allowance, date, f, order_by) return AllocationPeriod.objects.none() - def _allocation_period_choices_allowance_year(self, date, f, order_by): - """TODO""" + def _allocation_period_choices_allowance_year(self, computing_allowance, + date, f, order_by): if flag_enabled('ALLOCATION_RENEWAL_FOR_NEXT_PERIOD_REQUESTABLE'): # If projects for the next period may be requested, include it. started_before_date = ( @@ -126,7 +127,9 @@ def _allocation_period_choices_allowance_year(self, date, f, order_by): # Otherwise, include only the current period. started_before_date = date f = f & Q(start_date__lte=started_before_date) - f = f & Q(name__startswith='Allowance Year') + allowance_periods_q = computing_allowance.get_period_filters() + if allowance_periods_q is not None: + f = f & allowance_periods_q return AllocationPeriod.objects.filter(f).order_by(*order_by) diff --git a/coldfront/core/project/management/commands/projects.py b/coldfront/core/project/management/commands/projects.py index dd441f9824..d8c9eac7b1 100644 --- a/coldfront/core/project/management/commands/projects.py +++ b/coldfront/core/project/management/commands/projects.py @@ -7,9 +7,14 @@ from flags.state import flag_enabled from coldfront.core.allocation.models import Allocation +from coldfront.core.allocation.models import AllocationPeriod from coldfront.core.allocation.models import AllocationAttribute from coldfront.core.allocation.models import AllocationAttributeType from coldfront.core.allocation.models import AllocationStatusChoice +from coldfront.core.allocation.models import AllocationRenewalRequest +from coldfront.core.allocation.models import AllocationRenewalRequestStatusChoice +from coldfront.core.allocation.utils import calculate_service_units_to_allocate +from coldfront.core.resource.utils_.allowance_utils.interface import ComputingAllowanceInterface from coldfront.core.project.models import Project from coldfront.core.project.models import ProjectStatusChoice from coldfront.core.project.models import ProjectUser @@ -18,8 +23,12 @@ from coldfront.core.project.utils import is_primary_cluster_project from coldfront.core.project.utils_.new_project_user_utils import NewProjectUserRunnerFactory from coldfront.core.project.utils_.new_project_user_utils import NewProjectUserSource +from coldfront.core.project.utils_.renewal_utils import AllocationRenewalApprovalRunner +from coldfront.core.project.utils_.renewal_utils import AllocationRenewalProcessingRunner +from coldfront.core.project.utils_.renewal_utils import set_allocation_renewal_request_eligibility from coldfront.core.resource.models import Resource from coldfront.core.resource.utils import get_primary_compute_resource_name +from coldfront.core.resource.utils_.allowance_utils.computing_allowance import ComputingAllowance from coldfront.core.statistics.models import ProjectTransaction from coldfront.core.utils.common import add_argparse_dry_run_argument from coldfront.core.utils.common import display_time_zone_current_date @@ -46,11 +55,15 @@ def add_arguments(self, parser): subparsers.required = True self._add_create_subparser(subparsers) + self._add_renew_subparser(subparsers) + def handle(self, *args, **options): """Call the handler for the provided subcommand.""" subcommand = options['subcommand'] if subcommand == 'create': self._handle_create(*args, **options) + elif subcommand == 'renew': + self._handle_renew(*args, **options) @staticmethod def _add_create_subparser(parsers): @@ -82,6 +95,32 @@ def _add_create_subparser(parsers): type=str) add_argparse_dry_run_argument(parser) + @staticmethod + def _add_renew_subparser(parsers): + """Add a subparser for the 'renew' subcommand.""" + parser = parsers.add_parser( + 'renew', + help='Renew a PI\'s allowance under a project.') + parser.add_argument( + 'name', help='The name of the project to renew under.', type=str) + parser.add_argument( + 'allocation_period', + help='The name of the AllocationPeriod to renew under.', + type=str) + parser.add_argument( + 'pi_username', + help=( + 'The username of the user whose allowance should be renewed. ' + 'The PI must be an active PI of the project.'), + type=str) + parser.add_argument( + 'requester_username', + help=( + 'The username of the user making the request. The requester ' + 'must be an active manager or PI of the project.'), + type=str) + add_argparse_dry_run_argument(parser) + @staticmethod def _create_project_with_compute_allocation_and_pis(project_name, compute_resource, @@ -180,6 +219,103 @@ def _handle_create(self, *args, **options): self.stdout.write(self.style.SUCCESS(message)) self.logger.info(message) + @staticmethod + def _renew_project(project, allocation_period, requester, pi, + computing_allowance, num_service_units): + """Renew the computing allowance of the given PI under the given + project for the given AllocationPeriod, as requested by the + given User and granting the given number of service units. + + 1. Create an AllocationRenewalRequest. + 2. Update the state of the request to prepare it for + approval. + 3. Approve the request. + 4. Process the request. + + Assumptions: + - The ComputingAllowance is renewable. + - (TODO: Temporary) The ComputingAllowance is not one per + PI. + - The PI is an active PI of the project. + - The requester is an active Manager or PI of the project. + - The allowance is being renewed under the same project + (i.e., there is no change in pooling preferences). + - The AllocationPeriod is current: it has started, and it + has not ended. + """ + pre_project = project + post_project = project + request_time = utc_now_offset_aware() + status = AllocationRenewalRequestStatusChoice.objects.get( + name='Under Review') + + with transaction.atomic(): + request = AllocationRenewalRequest.objects.create( + requester=requester, + pi=pi, + computing_allowance=computing_allowance.get_resource(), + allocation_period=allocation_period, + status=status, + pre_project=pre_project, + post_project=post_project, + request_time=request_time) + + eligibility_status = 'Approved' + eligibility_justification = '' + set_allocation_renewal_request_eligibility( + request, eligibility_status, eligibility_justification) + + # TODO: The command currently assumes that the period has already + # started. If allowing renewals for future periods: + # - Refactor and reuse existing logic for determining whether to + # run the processing runner. + # - Refactor ane reuse existing logic to filter out periods that + # are too far in the future. + + approval_runner = AllocationRenewalApprovalRunner( + request, num_service_units, email_strategy=DropEmailStrategy()) + approval_runner.run() + + request.refresh_from_db() + processing_runner = AllocationRenewalProcessingRunner( + request, num_service_units) + processing_runner.run() + + def _handle_renew(self, *args, **options): + """Handle the 'renew' subcommand.""" + cleaned_options = self._validate_renew_options(options) + + project_name = options['name'] + alloc_period_name = options['allocation_period'] + requester_str = options['requester_username'] + pi_str = options['pi_username'] + + message_template = ( + f'{{0}} the allocation for PI "{pi_str}" under Project ' + f'"{project_name}" for {alloc_period_name}, ' + f'requested by {requester_str}.') + if options['dry_run']: + message = message_template.format('Would renew') + self.stdout.write(self.style.WARNING(message)) + return + + try: + self._renew_project( + cleaned_options['project'], + cleaned_options['allocation_period'], + cleaned_options['requester'], + cleaned_options['pi'], + cleaned_options['computing_allowance'], + cleaned_options['num_service_units']) + except Exception as e: + message = message_template.format('Failed to renew') + self.stderr.write(self.style.ERROR(message)) + self.logger.exception(f'{message}\n{e}') + else: + message = message_template.format('Renewed') + self.stdout.write(self.style.SUCCESS(message)) + self.logger.info(message) + @staticmethod def _validate_create_options(options): """Validate the options provided to the 'create' subcommand. @@ -254,3 +390,124 @@ def _validate_create_options(options): 'compute_resource': compute_resource, 'pi_users': pi_users, } + + @staticmethod + def _validate_renew_options(options): + """Validate the options provided to the 'renew' subcommand. + Raise a subcommand if any are invalid or if they violate + business logic, else return a dict of the form: + { + 'project': Project, + 'requester': User, + 'pi': User, + 'allocation_period': AllocationPeriod, + 'computing_allowance': ComputingAllowance, + 'num_service_units': Decimal, + } + """ + project_name = options['name'].lower() + try: + project = Project.objects.get(name=project_name) + except Project.DoesNotExist: + raise CommandError( + f'A Project with name "{project_name}" does not exist.') + + computing_allowance_interface = ComputingAllowanceInterface() + computing_allowance = ComputingAllowance( + computing_allowance_interface.allowance_from_project(project)) + if not computing_allowance.is_renewable(): + raise CommandError( + f'Computing allowance "{computing_allowance.get_name()}" is ' + f'not renewable.') + + # TODO: There are some allowances which a PI may only have one of. + # Disallow renewals of these until business logic (in progress) is in + # place to enforce this constraint. + if computing_allowance.is_one_per_pi(): + raise CommandError( + 'Renewals of computing allowances that are limited per PI are ' + 'not currently supported by this command.') + + active_project_user_status = ProjectUserStatusChoice.objects.get( + name='Active') + manager_project_user_role = ProjectUserRoleChoice.objects.get( + name='Manager') + pi_project_user_role = ProjectUserRoleChoice.objects.get( + name='Principal Investigator') + + # The requester must be an "Active" "Manager" or "Principal + # Investigator" of the project. + requester_username = options['requester_username'] + try: + requester = User.objects.get(username=requester_username) + except User.DoesNotExist: + raise CommandError( + f'User with username "{requester_username}" does not exist.') + is_requester_valid = ProjectUser.objects.filter( + project=project, user=requester, + role__in=[manager_project_user_role, pi_project_user_role], + status=active_project_user_status).exists() + if not is_requester_valid: + raise CommandError( + f'Requester {requester.username} is not an active member of ' + f'the project "{project_name}".') + + # The PI must be an "Active" "Principal Investigator" of the project. + pi_username = options['pi_username'] + try: + pi = User.objects.get(username=pi_username) + except User.DoesNotExist: + raise CommandError( + f'User with username "{pi_username}" does not exist.') + is_pi_valid = ProjectUser.objects.filter( + project=project, user=pi, role=pi_project_user_role, + status=active_project_user_status).exists() + if not is_pi_valid: + raise CommandError( + f'{pi} is not an active PI of the project "{project_name}".') + + # The AllocationPeriod must be: + # (a) valid for the project's computing allowance, and + # (b) current. + allocation_period_name = options['allocation_period'] + try: + allocation_period = AllocationPeriod.objects.get( + name=allocation_period_name) + except AllocationPeriod.DoesNotExist: + raise CommandError( + f'AllocationPeriod "{allocation_period_name}" does not exist.') + + allowance_periods_q = computing_allowance.get_period_filters() + if not allowance_periods_q: + raise CommandError( + f'Unexpectedly found no AllocationPeriod filters for ' + f'"{computing_allowance.get_name()}".') + allocation_periods_for_allowance = AllocationPeriod.objects.filter( + allowance_periods_q) + try: + error = ( + f'"{allocation_period_name}" is not a valid AllocationPeriod ' + f'for computing allowance "{computing_allowance.get_name()}".') + assert allocation_period in allocation_periods_for_allowance, error + except AssertionError as e: + raise CommandError(e) + + try: + allocation_period.assert_started() + allocation_period.assert_not_ended() + except AssertionError as e: + raise CommandError(e) + + request_time = utc_now_offset_aware() + num_service_units = calculate_service_units_to_allocate( + computing_allowance, request_time, + allocation_period=allocation_period) + + return { + 'project': project, + 'requester': requester, + 'pi': pi, + 'allocation_period': allocation_period, + 'computing_allowance': computing_allowance, + 'num_service_units': num_service_units, + } diff --git a/coldfront/core/project/tests/test_commands/test_deactivate_ica_projects.py b/coldfront/core/project/tests/test_commands/test_deactivate_ica_projects.py index 936d3ed955..56e9511777 100644 --- a/coldfront/core/project/tests/test_commands/test_deactivate_ica_projects.py +++ b/coldfront/core/project/tests/test_commands/test_deactivate_ica_projects.py @@ -10,7 +10,7 @@ AllocationUserAttributeUsage from coldfront.core.project.models import Project, ProjectStatusChoice, \ ProjectUserStatusChoice, ProjectUserRoleChoice, ProjectUser -from coldfront.core.project.utils import get_project_compute_allocation +from coldfront.core.allocation.utils import get_project_compute_allocation from coldfront.core.utils.common import utc_now_offset_aware from coldfront.core.project.tests.test_commands.test_service_units_base import TestSUBase diff --git a/coldfront/core/project/tests/test_commands/test_projects.py b/coldfront/core/project/tests/test_commands/test_projects.py new file mode 100644 index 0000000000..9ee8162205 --- /dev/null +++ b/coldfront/core/project/tests/test_commands/test_projects.py @@ -0,0 +1,471 @@ +from datetime import timedelta +from decimal import Decimal +from io import StringIO + +from django.conf import settings +from django.core.management import call_command +from django.core.management import CommandError +from django.contrib.auth.models import User + +from coldfront.api.statistics.utils import create_project_allocation + +from coldfront.core.allocation.models import AllocationPeriod +from coldfront.core.allocation.models import AllocationRenewalRequest +from coldfront.core.allocation.models import AllocationRenewalRequestStatusChoice +from coldfront.core.project.tests.test_commands.test_service_units_base import TestSUBase +from coldfront.core.project.models import Project +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 ProjectUserStatusChoice +from coldfront.core.project.utils_.renewal_utils import get_current_allowance_year_period +from coldfront.core.resource.models import Resource +from coldfront.core.resource.models import ResourceAttributeType +from coldfront.core.resource.models import TimedResourceAttribute +from coldfront.core.resource.utils_.allowance_utils.constants import BRCAllowances +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 display_time_zone_current_date +from coldfront.core.utils.tests.test_base import enable_deployment + + +class TestProjectsBase(TestSUBase): + """A base class for tests of the projects management command.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._command = ProjectsCommand() + + +class TestProjectsCreate(TestProjectsBase): + """A class for testing the 'create' subcommand of the 'projects' + management command.""" + + # TODO + pass + + +class TestProjectsRenew(TestProjectsBase): + """A class for testing the 'renew' subcommand of the 'projects' + management command.""" + + @enable_deployment('BRC') + def setUp(self): + super().setUp() + + self.create_test_user() + self.sign_user_access_agreement(self.user) + self.client.login(username=self.user.username, password=self.password) + + self._ica_computing_allowance = ComputingAllowance( + Resource.objects.get(name=BRCAllowances.ICA)) + computing_allowance_interface = ComputingAllowanceInterface() + project_name_prefix = computing_allowance_interface.code_from_name( + self._ica_computing_allowance.get_name()) + + # An arbitrary number of service units to grant to ICAs in these tests. + self._ica_num_service_units = Decimal('1000000.00') + + self._set_up_allocation_periods() + + # Create an inactive project and make self.user the PI + self.project_name = f'{project_name_prefix}project' + inactive_project_status = ProjectStatusChoice.objects.get( + name='Inactive') + inactive_project = Project.objects.create( + name=self.project_name, + title=self.project_name, + status=inactive_project_status) + + pi_role = ProjectUserRoleChoice.objects.get( + name='Principal Investigator') + active_project_user_status = ProjectUserStatusChoice.objects.get( + name='Active') + ProjectUser.objects.create( + project=inactive_project, + role=pi_role, + status=active_project_user_status, + user=self.user) + + accounting_allocation_objects = create_project_allocation( + inactive_project, settings.ALLOCATION_MIN) + self.service_units_attribute = \ + accounting_allocation_objects.allocation_attribute + + def _assert_project_inactive(self, project_name): + """Assert that a project has the Inactive status.""" + still_inactive_proj = Project.objects.get(name=project_name) + self.assertEqual( + still_inactive_proj.status, + ProjectStatusChoice.objects.get(name='Inactive')) + + def _set_up_allocation_periods(self): + """Create AllocationPeriods to potentially renew under.""" + # Delete existing ICA AllocationPeriods. + AllocationPeriod.objects.filter( + self._ica_computing_allowance.get_period_filters()).delete() + + today = display_time_zone_current_date() + year = today.year + + self.past_ica_period = AllocationPeriod.objects.create( + name=f'Spring Semester {year}', + start_date=today - timedelta(days=100), + end_date=today - timedelta(days=1)) + self.current_ica_period = AllocationPeriod.objects.create( + name=f'Summer Sessions {year}', + start_date=today - timedelta(days=50), + end_date=today + timedelta(days=50)) + self.future_ica_period = AllocationPeriod.objects.create( + name=f'Fall Semester {year + 1}', + start_date=today + timedelta(days=1), + end_date=today + timedelta(days=100)) + + ica_periods = ( + self.past_ica_period, + self.current_ica_period, + self.future_ica_period, + ) + for period in ica_periods: + self._set_service_units_to_be_allocated_for_period( + self._ica_computing_allowance, period, + self._ica_num_service_units) + + def _set_service_units_to_be_allocated_for_period(self, computing_allowance, + allocation_period, + num_service_units): + """Define the number of service units that should be granted to + as part of the given ComputingAllowance under the given + AllocationPeriod.""" + assert isinstance(computing_allowance, ComputingAllowance) + assert isinstance(allocation_period, AllocationPeriod) + assert isinstance(num_service_units, Decimal) + resource_attribute_type = ResourceAttributeType.objects.get( + name='Service Units') + TimedResourceAttribute.objects.update_or_create( + resource_attribute_type=resource_attribute_type, + resource=computing_allowance.get_resource(), + start_date=allocation_period.start_date, + end_date=allocation_period.end_date, + defaults={ + 'value': str(num_service_units), + }) + + @enable_deployment('BRC') + def test_dry_run(self): + """Test that the request would be successful but the dry run + ensures that the project is not updated.""" + project = Project.objects.get(name=self.project_name) + self.assertEqual(project.status, + ProjectStatusChoice.objects.get(name='Inactive')) + output, error = self._command.renew( + self.project_name, self.current_ica_period, self.user.username, + self.user.username, dry_run=True) + + self.assertFalse(error) + + self.assertIn('Would renew', output) + self.service_units_attribute.refresh_from_db() + self.assertEqual( + Decimal(self.service_units_attribute.value), + settings.ALLOCATION_MIN) + + @enable_deployment('BRC') + def test_success(self): + """Test that a successful request updates a project's status + to 'Active' and the service units to the correct amount.""" + project = Project.objects.get(name=self.project_name) + self.assertEqual( + project.status, + ProjectStatusChoice.objects.get(name='Inactive')) + + output, error = self._command.renew( + self.project_name, self.current_ica_period, self.user.username, + self.user.username) + + self.assertFalse(error) + now_active_proj = Project.objects.get(name=self.project_name) + self.assertEqual( + now_active_proj.status, + ProjectStatusChoice.objects.get(name='Active')) + self.service_units_attribute.refresh_from_db() + self.assertEqual( + Decimal(self.service_units_attribute.value), + self._ica_num_service_units) + + request = AllocationRenewalRequest.objects.get( + requester=self.user, + pi=self.user, + pre_project=project) + self.assertEqual( + AllocationRenewalRequestStatusChoice.objects.get(name='Complete'), + request.status) + # TODO: Also test: + # That only a processing email is sent + + @enable_deployment('BRC') + def test_validate_project(self): + """Test that, if the project is invalid, the command raises an + error, and does not proceed.""" + project = Project.objects.get(name=self.project_name) + self.assertEqual( + project.status, ProjectStatusChoice.objects.get(name='Inactive')) + + with self.assertRaises(CommandError) as cm: + self._command.renew( + 'invalid project name', self.current_ica_period, + self.user.username, self.user.username) + self.assertIn('A Project with name', str(cm.exception)) + self._assert_project_inactive(self.project_name) + self.service_units_attribute.refresh_from_db() + self.assertEqual( + Decimal(self.service_units_attribute.value), + settings.ALLOCATION_MIN) + + @enable_deployment('BRC') + def test_validate_computing_allowance_non_renewable(self): + """Test that computing allowances that cannot be renewed fail + correctly (e.g., Recharge, Condo).""" + computing_allowance_interface = ComputingAllowanceInterface() + non_renewable_resources = [BRCAllowances.CO, BRCAllowances.RECHARGE] + for resource_name in non_renewable_resources: + computing_allowance = ComputingAllowance( + Resource.objects.get(name=resource_name)) + assert not computing_allowance.is_renewable() + project_name_prefix = computing_allowance_interface.code_from_name( + computing_allowance.get_name()) + project_name = project_name_prefix + 'testproject' + Project.objects.create( + name=project_name, + title=project_name, + status=ProjectStatusChoice.objects.get(name='Inactive')) + + with self.assertRaises(CommandError) as cm: + self._command.renew( + project_name, self.current_ica_period, + self.user.username, self.user.username) + self.assertIn('is not renewable', str(cm.exception)) + self._assert_project_inactive(project_name) + + # TODO: Retire this test case once support for these allowances has been + # added. + @enable_deployment('BRC') + def test_validate_computing_allowance_one_per_pi(self): + """Test that computing allowances which a PI may only have one + of fail correctly.""" + computing_allowance_interface = ComputingAllowanceInterface() + one_per_pi_resources = [BRCAllowances.FCA, BRCAllowances.PCA] + for resource_name in one_per_pi_resources: + computing_allowance = ComputingAllowance( + Resource.objects.get(name=resource_name)) + assert computing_allowance.is_one_per_pi() + project_name_prefix = computing_allowance_interface.code_from_name( + computing_allowance.get_name()) + project_name = project_name_prefix + 'testproject' + Project.objects.create( + name=project_name, + title=project_name, + status=ProjectStatusChoice.objects.get(name='Inactive')) + + with self.assertRaises(CommandError) as cm: + self._command.renew( + project_name, self.current_ica_period, + self.user.username, self.user.username) + self.assertIn('not currently supported', str(cm.exception)) + self._assert_project_inactive(project_name) + + @enable_deployment('BRC') + def test_validate_requester(self): + """Test that requesters who do not exist, or are not an active + manager/PI fail correctly.""" + project = Project.objects.get(name=self.project_name) + self.assertEqual( + project.status, ProjectStatusChoice.objects.get(name='Inactive')) + + # Requester who is not manager/PI is invalid. + invalid_requester = User.objects.create( + email='invalid@gmail.com', + first_name='invalid', + last_name='invalid', + username='invalid_requester' + ) + ProjectUser.objects.create( + project=project, + role=ProjectUserRoleChoice.objects.get(name='User'), + status=ProjectUserStatusChoice.objects.get(name='Active'), + user=invalid_requester) + + # Requester who is removed from project is invalid, even if Manager/PI + removed_requester = User.objects.create( + email='removed@gmail.com', + first_name='removed', + last_name='removed', + username='removed_requester' + ) + ProjectUser.objects.create( + project=project, + role=ProjectUserRoleChoice.objects.get(name='Manager'), + status=ProjectUserStatusChoice.objects.get(name='Removed'), + user=removed_requester) + + invalid_usernames = [invalid_requester.username, + removed_requester.username] + for invalid_username in invalid_usernames: + with self.assertRaises(CommandError) as cm: + self._command.renew( + project.name, self.current_ica_period, + self.user.username, invalid_username) + self.assertIn( + f'Requester {invalid_username} is not an active member', + str(cm.exception)) + self._assert_project_inactive(project.name) + self.service_units_attribute.refresh_from_db() + self.assertEqual( + Decimal(self.service_units_attribute.value), + settings.ALLOCATION_MIN) + + @enable_deployment('BRC') + def test_validate_pi(self): + """Test that PIs who do not exist or are not active fail + correctly.""" + project = Project.objects.get(name=self.project_name) + self.assertEqual( + project.status, ProjectStatusChoice.objects.get(name='Inactive')) + + nonexistent_pi_username = 'nonexistent_pi_username' + with self.assertRaises(CommandError) as cm: + self._command.renew( + project.name, self.current_ica_period, + nonexistent_pi_username, self.user.username) + self.assertIn( + f'User with username "{nonexistent_pi_username}" does not exist', + str(cm.exception)) + self._assert_project_inactive(project.name) + + # Active User (manager) who is on project but not PI + invalid_pi = User.objects.create( + email='manager@gmail.com', + first_name='manager', + last_name='manager', + username='invalid_pi' + ) + ProjectUser.objects.create( + project=project, + role=ProjectUserRoleChoice.objects.get(name='Manager'), + status=ProjectUserStatusChoice.objects.get(name='Active'), + user=invalid_pi) + + # Removed PI + removed_pi = User.objects.create( + email='removedPI@gmail.com', + first_name='removed', + last_name='removed', + username='removed_pi' + ) + ProjectUser.objects.create( + project=project, + role=ProjectUserRoleChoice.objects.get( + name='Principal Investigator'), + status=ProjectUserStatusChoice.objects.get(name='Removed'), + user=removed_pi) + invalid_pis = [invalid_pi.username, removed_pi.username] + for invalid_pi in invalid_pis: + with self.assertRaises(CommandError) as cm: + self._command.renew( + project.name, self.current_ica_period, + invalid_pi, self.user.username) + self.assertIn( + f'{invalid_pi} is not an active PI', + str(cm.exception)) + self._assert_project_inactive(project.name) + self.service_units_attribute.refresh_from_db() + self.assertEqual( + Decimal(self.service_units_attribute.value), + settings.ALLOCATION_MIN) + + @enable_deployment('BRC') + def test_validate_allocation_period(self): + """Test that AllocationPeriods which do not exist, are not valid + for the given computing allowance, or are not current fail + correctly.""" + project = Project.objects.get(name=self.project_name) + self.assertEqual( + project.status, ProjectStatusChoice.objects.get(name='Inactive')) + + nonexistent_alloc_period = 'I don\'t exist!' + with self.assertRaises(CommandError) as cm: + self._command.renew( + project.name, nonexistent_alloc_period, + self.user.username, self.user.username) + self.assertIn( + f'AllocationPeriod "{nonexistent_alloc_period}" does not exist.', + str(cm.exception)) + self._assert_project_inactive(project.name) + + # "Allowance Year" allocation periods are not for ICA projects + cur_allowance_year = get_current_allowance_year_period() + + with self.assertRaises(CommandError) as cm: + self._command.renew( + project.name, cur_allowance_year.name, + self.user.username, self.user.username) + self.assertIn( + f'"{cur_allowance_year.name}" is not a valid AllocationPeriod', + str(cm.exception)) + self._assert_project_inactive(project.name) + + # Ended allocation period + with self.assertRaises(CommandError) as cm: + self._command.renew( + project.name, self.past_ica_period, + self.user.username, self.user.username) + self.assertIn( + 'AllocationPeriod already ended', + str(cm.exception)) + self._assert_project_inactive(project.name) + + # Not started allocation period + with self.assertRaises(CommandError) as cm: + self._command.renew( + project.name, self.future_ica_period, + self.user.username, self.user.username) + self.assertIn( + 'AllocationPeriod does not start until', + str(cm.exception)) + self._assert_project_inactive(project.name) + + self.service_units_attribute.refresh_from_db() + self.assertEqual( + Decimal(self.service_units_attribute.value), + settings.ALLOCATION_MIN) + + +class ProjectsCommand(object): + """A wrapper class over the 'projects' management command.""" + + command_name = 'projects' + + def call_subcommand(self, name, *args): + """Call the subcommand with the given name and arguments. Return + output written to stdout and stderr.""" + out, err = StringIO(), StringIO() + args = [self.command_name, name, *args] + kwargs = {'stdout': out, 'stderr': err} + call_command(*args, **kwargs) + return out.getvalue(), err.getvalue() + + def renew(self, name, allocation_period, pi_username, requester_username, + **flags): + """Call the 'renew' subcommand with the given positional arguments.""" + args = [ + 'renew', name, allocation_period, pi_username, requester_username] + self._add_flags_to_args(args, **flags) + return self.call_subcommand(*args) + + @staticmethod + def _add_flags_to_args(args, **flags): + """Given a list of arguments to the command and a dict of flag + values, add the latter to the former.""" + for key in ('dry_run', 'ignore_invalid'): + if flags.get(key, False): + args.append(f'--{key}') diff --git a/coldfront/core/project/tests/test_views/test_new_project_views/test_savio_project_request_wizard.py b/coldfront/core/project/tests/test_views/test_new_project_views/test_savio_project_request_wizard.py index 5b2667d057..09e7d3b709 100644 --- a/coldfront/core/project/tests/test_views/test_new_project_views/test_savio_project_request_wizard.py +++ b/coldfront/core/project/tests/test_views/test_new_project_views/test_savio_project_request_wizard.py @@ -11,6 +11,7 @@ class TestSavioProjectRequestWizard(TestBase): """A class for testing SavioProjectRequestWizard.""" + @enable_deployment('BRC') def setUp(self): """Set up test data.""" @@ -27,18 +28,20 @@ def request_url(): project.""" return reverse('new-project-request') - @enable_deployment('BRC') - def test_post_creates_request(self): - """Test that a POST request creates a - SavioProjectAllocationRequest.""" - self.assertEqual(SavioProjectAllocationRequest.objects.count(), 0) - self.assertEqual(Project.objects.count(), 0) - - computing_allowance = self.get_predominant_computing_allowance() - allocation_period = get_current_allowance_year_period() + def get_form_preparation_data(self, computing_allowance=None, allocation_period=None, pi=None, name='name', + title='title', description='a' * 20, scope_and_intent='b' * 20, + computational_aspects='c' * 20): + """Prepare the form data for the wizard.""" + if computing_allowance is None: + computing_allowance = self.get_predominant_computing_allowance() + if allocation_period is None: + allocation_period = get_current_allowance_year_period() + if pi is None: + pi = self.user view_name = 'savio_project_request_wizard' current_step_key = f'{view_name}-current_step' + computing_allowance_form_data = { '0-computing_allowance': computing_allowance.pk, current_step_key: '0', @@ -48,7 +51,7 @@ def test_post_creates_request(self): current_step_key: '1', } existing_pi_form_data = { - '2-PI': self.user.pk, + '2-PI': pi.pk, current_step_key: '2', } pool_allocations_data = { @@ -56,14 +59,14 @@ def test_post_creates_request(self): current_step_key: '6', } details_data = { - '8-name': 'name', - '8-title': 'title', - '8-description': 'a' * 20, + '8-name': name, + '8-title': title, + '8-description': description, current_step_key: '8', } survey_data = { - '10-scope_and_intent': 'b' * 20, - '10-computational_aspects': 'c' * 20, + '10-scope_and_intent': scope_and_intent, + '10-computational_aspects': computational_aspects, current_step_key: '10', } form_data = [ @@ -74,6 +77,19 @@ def test_post_creates_request(self): details_data, survey_data, ] + return form_data, details_data, survey_data + + @enable_deployment('BRC') + def test_post_creates_request(self): + """Test that a POST request creates a + SavioProjectAllocationRequest.""" + self.assertEqual(SavioProjectAllocationRequest.objects.count(), 0) + self.assertEqual(Project.objects.count(), 0) + + computing_allowance = self.get_predominant_computing_allowance() + allocation_period = get_current_allowance_year_period() + + form_data, details_data, survey_data = self.get_form_preparation_data() url = self.request_url() for i, data in enumerate(form_data): @@ -110,4 +126,29 @@ def test_post_creates_request(self): survey_data['10-computational_aspects']) self.assertEqual(request.status.name, 'Under Review') - # TODO + @enable_deployment('BRC') + def test_post_fails_without_user_last_name(self): + """Test that a user without a last name cannot create a request.""" + + self.user.last_name = '' + self.user.save() + + self.assertEqual(SavioProjectAllocationRequest.objects.count(), 0) + self.assertEqual(Project.objects.count(), 0) + + form_data, _, _ = self.get_form_preparation_data() + + url = self.request_url() + for i, data in enumerate(form_data): + response = self.client.post(url, data, follow=True) + print(response.content) + self.assertEqual(response.status_code, HTTPStatus.OK) + # Once the error message is displayed, we break to check if the request was created + if b'You must set your first and last name on your account' in response.content: + break + + # Assert that the request was not created (1 from the test above) + requests = SavioProjectAllocationRequest.objects.all() + self.assertEqual(requests.count(), 1) + projects = Project.objects.all() + self.assertEqual(projects.count(), 1) \ No newline at end of file diff --git a/coldfront/core/project/utils_/renewal_utils.py b/coldfront/core/project/utils_/renewal_utils.py index e8ffded203..2a4a044ef7 100644 --- a/coldfront/core/project/utils_/renewal_utils.py +++ b/coldfront/core/project/utils_/renewal_utils.py @@ -6,6 +6,7 @@ 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 @@ -579,6 +580,33 @@ def allocation_renewal_request_state_status(request): name='Under Review') +def set_allocation_renewal_request_eligibility(request, status, justification, + timestamp=None): + """Update the given AllocationRenewalRequest to note whether the PI + of the request is eligible for renewal, with the following: + - A str 'status' denoting eligibility (one of 'Pending', + 'Approved', 'Denied'), + - A str 'justification' with admin comments, and + - An optional str ISO 8601 'timestamp'. If one is not given, the + current time is used. + + Based on 'status', also update the status of the request (e.g., if + the PI is ineligible, the request's status should have status + 'Denied'. + """ + if timestamp is not None: + assert isinstance(timestamp, str) + else: + timestamp = utc_now_offset_aware().isoformat() + request.state['eligibility'] = { + 'status': status, + 'justification': justification, + 'timestamp': timestamp, + } + request.status = allocation_renewal_request_state_status(request) + request.save() + + class AllocationRenewalRunnerBase(object): """A base class that Runners for handling AllocationRenewalsRequests should inherit from.""" diff --git a/coldfront/core/project/views.py b/coldfront/core/project/views.py index 17fdb75338..d2eb490327 100644 --- a/coldfront/core/project/views.py +++ b/coldfront/core/project/views.py @@ -56,6 +56,7 @@ from coldfront.core.utils.common import (get_domain_url, import_from_settings) from coldfront.core.utils.email.email_strategy import EnqueueEmailStrategy from coldfront.core.utils.mail import send_email, send_email_template +from coldfront.core.utils.permissions import permissions_required, check_first_last_name from flags.state import flag_enabled @@ -630,6 +631,7 @@ class ProjectCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): template_name_suffix = '_create_form' fields = ['title', 'description', 'field_of_science', ] + @permissions_required(check_first_last_name) def test_func(self): """ UserPassesTestMixin Tests""" if self.request.user.is_superuser: diff --git a/coldfront/core/project/views_/join_views/request_views.py b/coldfront/core/project/views_/join_views/request_views.py index c99c2f71ce..0a41271862 100644 --- a/coldfront/core/project/views_/join_views/request_views.py +++ b/coldfront/core/project/views_/join_views/request_views.py @@ -29,6 +29,7 @@ from coldfront.core.project.views import ProjectListView from coldfront.core.user.utils_.host_user_utils import is_lbl_employee from coldfront.core.user.utils_.host_user_utils import needs_host +from coldfront.core.utils.permissions import permissions_required, check_first_last_name logger = logging.getLogger(__name__) @@ -38,6 +39,7 @@ class ProjectJoinView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): logger = logging.getLogger(__name__) + @permissions_required(check_first_last_name) def test_func(self): project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) user_obj = self.request.user 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 d4dcb0899f..2332e11e74 100644 --- a/coldfront/core/project/views_/new_project_views/approval_views.py +++ b/coldfront/core/project/views_/new_project_views/approval_views.py @@ -1,11 +1,10 @@ from coldfront.core.allocation.models import AllocationRenewalRequest from coldfront.core.allocation.utils import annotate_queryset_with_allocation_period_not_started_bool -from coldfront.core.allocation.utils import prorated_allocation_amount +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_.new_project_forms.request_forms import NewProjectExtraFieldsFormFactory -from coldfront.core.project.forms_.new_project_forms.request_forms import SavioProjectExtraFieldsForm from coldfront.core.project.forms_.new_project_forms.request_forms import SavioProjectSurveyForm from coldfront.core.project.forms_.new_project_forms.approval_forms import SavioProjectReviewSetupForm from coldfront.core.project.forms_.new_project_forms.approval_forms import VectorProjectReviewSetupForm @@ -143,21 +142,13 @@ def get_service_units_to_allocate(self): 'num_service_units'] num_service_units = Decimal(f'{num_service_units_int:.2f}') else: - allowance_name = self.request_obj.computing_allowance.name - - allocation_period = self.request_obj.allocation_period kwargs = {} + allocation_period = self.request_obj.allocation_period if allocation_period: - kwargs['is_timed'] = True kwargs['allocation_period'] = allocation_period - num_service_units = Decimal( - self.interface.service_units_from_name( - allowance_name, **kwargs)) - - if self.computing_allowance_obj.are_service_units_prorated(): - num_service_units = prorated_allocation_amount( - num_service_units, self.request_obj.request_time, - self.request_obj.allocation_period) + num_service_units = calculate_service_units_to_allocate( + self.computing_allowance_obj, self.request_obj.request_time, + **kwargs) return num_service_units def get_survey_form(self): diff --git a/coldfront/core/project/views_/new_project_views/request_views.py b/coldfront/core/project/views_/new_project_views/request_views.py index e4bfa52d85..7fbf778837 100644 --- a/coldfront/core/project/views_/new_project_views/request_views.py +++ b/coldfront/core/project/views_/new_project_views/request_views.py @@ -34,6 +34,8 @@ from coldfront.core.user.utils import access_agreement_signed from coldfront.core.utils.common import session_wizard_all_form_data from coldfront.core.utils.common import utc_now_offset_aware +from coldfront.core.utils.permissions import permissions_required, check_first_last_name + from django.conf import settings from django.contrib import messages @@ -58,6 +60,7 @@ class ProjectRequestView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): template_name = 'project/project_request/project_request.html' + @permissions_required(check_first_last_name) def test_func(self): if self.request.user.is_superuser: return True diff --git a/coldfront/core/project/views_/renewal_views/approval_views.py b/coldfront/core/project/views_/renewal_views/approval_views.py index 796e3af8cd..20f5dadda6 100644 --- a/coldfront/core/project/views_/renewal_views/approval_views.py +++ b/coldfront/core/project/views_/renewal_views/approval_views.py @@ -1,6 +1,6 @@ from coldfront.core.allocation.models import AllocationRenewalRequest from coldfront.core.allocation.utils import annotate_queryset_with_allocation_period_not_started_bool -from coldfront.core.allocation.utils import prorated_allocation_amount +from coldfront.core.allocation.utils import calculate_service_units_to_allocate from coldfront.core.project.forms import ReviewDenyForm from coldfront.core.project.forms import ReviewStatusForm from coldfront.core.project.models import ProjectAllocationRequestStatusChoice @@ -11,16 +11,14 @@ from coldfront.core.project.utils_.renewal_utils import allocation_renewal_request_denial_reason from coldfront.core.project.utils_.renewal_utils import allocation_renewal_request_latest_update_timestamp from coldfront.core.project.utils_.renewal_utils import allocation_renewal_request_state_status +from coldfront.core.project.utils_.renewal_utils import set_allocation_renewal_request_eligibility 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 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 from coldfront.core.utils.email.email_strategy import DropEmailStrategy from coldfront.core.utils.email.email_strategy import EnqueueEmailStrategy -from decimal import Decimal - from django.conf import settings from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin @@ -109,17 +107,11 @@ def get_redirect_url(pk): 'pi-allocation-renewal-request-detail', kwargs={'pk': pk}) def get_service_units_to_allocate(self): - """Return the number of service units to allocate to the project - if it were to be approved now.""" - num_service_units = Decimal( - ComputingAllowanceInterface().service_units_from_name( - self.computing_allowance_obj.get_name(), - is_timed=True, allocation_period=self.allocation_period_obj)) - if self.computing_allowance_obj.are_service_units_prorated(): - num_service_units = prorated_allocation_amount( - num_service_units, self.request_obj.request_time, - self.allocation_period_obj) - return num_service_units + """Return the number of service units to allocate to the + project.""" + return calculate_service_units_to_allocate( + self.computing_allowance_obj, self.request_obj.request_time, + allocation_period=self.allocation_period_obj) def set_common_context_data(self, context): """Given a dictionary of context variables to include in the @@ -375,15 +367,9 @@ def form_valid(self, form): form_data = form.cleaned_data status = form_data['status'] justification = form_data['justification'] - timestamp = utc_now_offset_aware().isoformat() - self.request_obj.state['eligibility'] = { - 'status': status, - 'justification': justification, - 'timestamp': timestamp, - } - self.request_obj.status = allocation_renewal_request_state_status( - self.request_obj) - self.request_obj.save() + + set_allocation_renewal_request_eligibility( + self.request_obj, status, justification) if status == 'Denied': runner = AllocationRenewalDenialRunner(self.request_obj) diff --git a/coldfront/core/project/views_/renewal_views/request_views.py b/coldfront/core/project/views_/renewal_views/request_views.py index ba09438a7b..830a69b6e5 100644 --- a/coldfront/core/project/views_/renewal_views/request_views.py +++ b/coldfront/core/project/views_/renewal_views/request_views.py @@ -34,6 +34,8 @@ from coldfront.core.user.utils import access_agreement_signed from coldfront.core.utils.common import session_wizard_all_form_data from coldfront.core.utils.common import utc_now_offset_aware +from coldfront.core.utils.permissions import permissions_required, check_first_last_name + from decimal import Decimal @@ -60,6 +62,7 @@ class AllocationRenewalLandingView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): template_name = 'project/project_renewal/request_landing.html' + @permissions_required(check_first_last_name) def test_func(self): if self.request.user.is_superuser: return True diff --git a/coldfront/core/resource/utils_/allowance_utils/computing_allowance.py b/coldfront/core/resource/utils_/allowance_utils/computing_allowance.py index 08868dc83a..a152e21607 100644 --- a/coldfront/core/resource/utils_/allowance_utils/computing_allowance.py +++ b/coldfront/core/resource/utils_/allowance_utils/computing_allowance.py @@ -1,3 +1,5 @@ +from django.db.models import Q + from flags.state import flag_enabled from coldfront.core.resource.models import Resource @@ -107,7 +109,7 @@ def is_renewable(self): return self.is_periodic() def is_renewal_supported(self): - """Return whether there is support for renewing the + """Return whether there is UI support for renewing the allowance.""" allowance_names = [] if flag_enabled('BRC_ONLY'): @@ -131,6 +133,27 @@ def get_name(self): """Return the name of the underlying Resource.""" return self._name + def get_period_filters(self): + """Return a Django Q object that can be used to filter for + AllocationPeriods associated with the allowance. + + If none are applicable, return None. + """ + yearly_q = Q(name__startswith='Allowance Year') + if flag_enabled('BRC_ONLY'): + if self.is_yearly(): + return yearly_q + elif self.is_instructional(): + instructional_q = ( + Q(name__startswith='Fall Semester') | + Q(name__startswith='Spring Semester') | + Q(name__startswith='Summer Sessions')) + return instructional_q + if flag_enabled('LRC_ONLY'): + if self.is_yearly(): + return yearly_q + return None + def get_resource(self): """Return the underlying Resource.""" return self._resource diff --git a/coldfront/core/utils/permissions.py b/coldfront/core/utils/permissions.py new file mode 100644 index 0000000000..d07d87a65c --- /dev/null +++ b/coldfront/core/utils/permissions.py @@ -0,0 +1,38 @@ +from functools import wraps +from django.contrib import messages +from django.urls import reverse +from django.utils.html import format_html + + + +def permissions_required(*permissions): + """ + Decorator to check if a user has all specified permissions before allowing them to access a view. + The decorator is used to wrap a test_func from UserPassesTestMixin, which allows granular refactoring of + the permission check logic on test_func without having to change the permission logic. + + :param permissions: variable number of functions where each returns True if the user has the permission, False otherwise + :return: + """ + def decorator(test_func): + @wraps(test_func) + def wrapper(view_instance, *args, **kwargs): + # Check if all provided permissions return True + if not all(permission(view_instance.request) for permission in permissions): + return False + return test_func(view_instance, *args, **kwargs) + return wrapper + return decorator + + +def check_first_last_name(request): + """ + Check if the user has set their first and last name on their account before allowing them to make requests. + :param request: + :return: + """ + if request.user.first_name == '' or request.user.last_name == '': + profile_url = request.build_absolute_uri(reverse('user-profile')) + messages.error(request, format_html(f'You must set your first and last name on your account before you can make requests. Update your profile here.')) + return False + return True \ No newline at end of file diff --git a/coldfront/templates/common/messages.html b/coldfront/templates/common/messages.html index e18356e9e6..552c36d831 100644 --- a/coldfront/templates/common/messages.html +++ b/coldfront/templates/common/messages.html @@ -3,7 +3,7 @@ - {{ message }} + {{ message|safe }} {% endfor %} diff --git a/coldfront/templates/error_with_message.html b/coldfront/templates/error_with_message.html index 1b7240c009..da77ea9554 100644 --- a/coldfront/templates/error_with_message.html +++ b/coldfront/templates/error_with_message.html @@ -11,7 +11,7 @@