diff --git a/bootstrap/ansible/main.copyme b/bootstrap/ansible/main.copyme index d30e65214..a8236dc02 100644 --- a/bootstrap/ansible/main.copyme +++ b/bootstrap/ansible/main.copyme @@ -5,6 +5,7 @@ # Types of Ansible tasks to run by default. provisioning_tasks: true common_tasks: true +chmod_tasks: true # Can be false when a Windows FS is mounted ############################################################################### # General Settings @@ -36,7 +37,7 @@ db_host: localhost # The credentials for the database admin user. # TODO: Replace the username and password. db_admin_user: admin -db_admin_passwd: '' +db_admin_passwd: root #------------------------------------------------------------------------------ # Redis settings @@ -44,8 +45,7 @@ db_admin_passwd: '' # The password for Redis. # TODO: Replace the password. -redis_passwd: '' - +redis_passwd: root redis_host: localhost #------------------------------------------------------------------------------ @@ -123,7 +123,7 @@ cilogon_app_secret: "" # Django Flags settings #------------------------------------------------------------------------------ -# # Note: Use uppercase True/False so that Python interprets these as booleans. +# Note: Use uppercase True/False so that Python interprets these as booleans. # TODO: For LRC, disable link login. flag_basic_auth_enabled: False @@ -149,6 +149,15 @@ flag_mou_generation_enabled: False # Whether to include a survey as part of the allowance renewal request process. flag_renewal_survey_enabled: True +#------------------------------------------------------------------------------ +# Plugin: departments +#------------------------------------------------------------------------------ + +# TODO: Enable for BRC, disable for LRC. +plugin_departments_enabled: true +plugin_departments_department_display_name: "Department" +plugin_departments_department_data_source: "coldfront.plugins.departments.utils.data_sources.backends.calnet_ldap.CalNetLdapDataSourceBackend" + #------------------------------------------------------------------------------ # User-facing strings #------------------------------------------------------------------------------ @@ -167,6 +176,8 @@ center_user_guide: "https://docs-research-it.berkeley.edu/services/high-performa center_login_guide: "https://docs-research-it.berkeley.edu/services/high-performance-computing/user-guide/logging-brc-clusters/#Logging-in" # TODO: For MyLRC, use "hpcshelp@lbl.gov". center_help_email: "brc-hpc-help@berkeley.edu" +# TODO: For MyLRC, use "Division". +department_display_name: "Department" #------------------------------------------------------------------------------ # BRC Vector settings @@ -421,16 +432,13 @@ sentry_dsn: "" # # Email settings. # email_host: localhost # email_port: 1025 -# # TODO: Set these addresses to yours. -# from_email: you@email.com -# admin_email: you@email.com +# from_email: placeholder@dev.dev +# admin_email: placeholder@dev.dev # # TODO: For LRC, use the substring 'MyLRC'. # email_subject_prefix: '[MyBRC-User-Portal]' # # A list of admin email addresses to be notified about new requests and other # # events. -# # TODO: Set these addresses to yours. -# email_admin_list: ['you@email.com'] +# email_admin_list: ['placeholder@dev.dev'] # # A list of email addresses to CC when certain requests are processed. -# # TODO: Set these addresses to yours. -# request_approval_cc_list: ['you@email.com'] +# request_approval_cc_list: ['placeholder@dev.dev'] diff --git a/bootstrap/ansible/playbook.yml b/bootstrap/ansible/playbook.yml index d10c23368..c693c9583 100644 --- a/bootstrap/ansible/playbook.yml +++ b/bootstrap/ansible/playbook.yml @@ -13,7 +13,6 @@ cwd: "{{ lookup('env', 'PWD') }} " provisioning_tasks: true common_tasks: true - python_version: 3.6 postgres_version: 15 python_version_dotless: "{{ python_version | regex_replace('\\.','') }}" @@ -61,6 +60,9 @@ name: https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox-0.12.6-1.centos7.x86_64.rpm state: present + # The SCL role runs many tasks when it's included, so having it do + # everything in one run would be ideal to not have redundant tasks. + - name: Install SCL include_role: role: smbambling.scl @@ -648,6 +650,8 @@ requirements: "{{ git_prefix }}/{{ reponame }}/requirements.txt" executable: "{{ git_prefix }}/venv/bin/pip3" become_user: "{{ djangooperator }}" + environment: # needed as pg_config isn't in PATH by default + PATH: "{{ ansible_env.PATH }}:/usr/pgsql-{{ postgres_version }}/bin" # Run Django management commands. @@ -731,6 +735,7 @@ state: directory mode: 0755 group: apache + when: chmod_tasks - name: Grant Apache recursive read access to the application directory file: @@ -739,6 +744,7 @@ recurse: true mode: "u=rwX,g=rX,o=rX" group: apache + when: chmod_tasks # Gracefully restart Apache so that processes handle current requests # before being replaced by a new process. diff --git a/bootstrap/ansible/settings_template.tmpl b/bootstrap/ansible/settings_template.tmpl index 4fc068fba..c1bf6cffa 100644 --- a/bootstrap/ansible/settings_template.tmpl +++ b/bootstrap/ansible/settings_template.tmpl @@ -21,7 +21,7 @@ CENTER_BASE_URL = '{{ full_host_path }}' CENTER_HELP_URL = CENTER_BASE_URL + '/help' CENTER_PROJECT_RENEWAL_HELP_URL = CENTER_BASE_URL + '/help' -EMAIL_HOST = '{{ email_host}}' +EMAIL_HOST = '{{ email_host }}' EMAIL_PORT = {{ email_port }} EMAIL_SUBJECT_PREFIX = '{{ email_subject_prefix }}' # A list of admin email addresses to be notified about new requests and other @@ -298,6 +298,7 @@ FLAGS = { 'SECURE_DIRS_REQUESTABLE': [{'condition': 'boolean', 'value': {{ flag_brc_enabled }}}], 'SERVICE_UNITS_PURCHASABLE': [{'condition': 'boolean', 'value': {{ flag_brc_enabled }}}], 'SSO_ENABLED': [{'condition': 'boolean', 'value': {{ flag_sso_enabled }}}], + 'USER_DEPARTMENTS_ENABLED': [{'condition': 'app installed', 'value': 'coldfront.plugins.departments'}], 'MOU_GENERATION_ENABLED': [{'condition': 'boolean', 'value': {{ flag_mou_generation_enabled }}}], 'RENEWAL_SURVEY_ENABLED': [{'condition': 'boolean', 'value': {{ flag_renewal_survey_enabled }}}], } @@ -318,6 +319,19 @@ if (not FLAGS['SSO_ENABLED'][0]['value'] and 'LINK_LOGIN_ENABLED should only be enabled when SSO_ENABLED is ' 'enabled.') +{% if plugin_departments_enabled is defined and plugin_departments_enabled | bool %} +#------------------------------------------------------------------------------ +# Plugin: departments +#------------------------------------------------------------------------------ + +EXTRA_EXTRA_APPS += [ + 'coldfront.plugins.departments' +] + +DEPARTMENTS_DEPARTMENT_DISPLAY_NAME = '{{ plugin_departments_department_display_name }}' +DEPARTMENTS_DEPARTMENT_DATA_SOURCE = '{{ plugin_departments_department_data_source }}' +{% endif %} + {% if file_storage_backend == 'google_drive' %} #------------------------------------------------------------------------------ # django-googledrive-storage settings diff --git a/bootstrap/development/docker-legacy/docker-compose.yml b/bootstrap/development/docker-legacy/docker-compose.yml index 1b7306963..a72fbd888 100644 --- a/bootstrap/development/docker-legacy/docker-compose.yml +++ b/bootstrap/development/docker-legacy/docker-compose.yml @@ -26,7 +26,7 @@ services: - 6379:6379 volumes: - redis_data:/data - command: --requirepass ${REDIS_PASSWORD} + command: --requirepass "${REDIS_PASSWORD}" healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s diff --git a/bootstrap/development/docker-legacy/docker_load_database_backup.sh b/bootstrap/development/docker-legacy/docker_load_database_backup.sh index e27a08d42..f89ff7c2a 100644 --- a/bootstrap/development/docker-legacy/docker_load_database_backup.sh +++ b/bootstrap/development/docker-legacy/docker_load_database_backup.sh @@ -32,4 +32,4 @@ echo MODIFYING PERMISSIONS... docker exec -i coldfront-db-1 \ psql -U $DB_OWNER -c "ALTER SCHEMA public OWNER TO $DB_OWNER;" $DB_NAME -echo DONE. \ No newline at end of file +echo DONE. diff --git a/bootstrap/development/docker/config/brc_defaults.yml b/bootstrap/development/docker/config/brc_defaults.yml index 5bf4473cb..933ed1248 100644 --- a/bootstrap/development/docker/config/brc_defaults.yml +++ b/bootstrap/development/docker/config/brc_defaults.yml @@ -16,6 +16,10 @@ flag_lrc_enabled: False flag_next_period_renewal_requestable_month: 5 flag_multiple_email_addresses_allowed: False +plugin_departments_enabled: true +plugin_departments_department_display_name: "Department" +plugin_departments_department_data_source: "coldfront.plugins.departments.utils.data_sources.backends.calnet_ldap.CalNetLdapDataSourceBackend" + portal_name: MyBRC program_name_long: Berkeley Research Computing program_name_short: BRC diff --git a/bootstrap/development/docker/config/lrc_defaults.yml b/bootstrap/development/docker/config/lrc_defaults.yml index 969e27acb..657eae09c 100644 --- a/bootstrap/development/docker/config/lrc_defaults.yml +++ b/bootstrap/development/docker/config/lrc_defaults.yml @@ -16,6 +16,10 @@ flag_lrc_enabled: True flag_next_period_renewal_requestable_month: 9 flag_multiple_email_addresses_allowed: False +plugin_departments_enabled: false +plugin_departments_department_display_name: "Division" +plugin_departments_department_data_source: "coldfront.plugins.departments.utils.data_sources.backends.dummy.DummyDataSourceBackend" + portal_name: MyLRC program_name_long: Laboratory Research Computing program_name_short: LRC diff --git a/bootstrap/development/docker/docker-compose.yml b/bootstrap/development/docker/docker-compose.yml index 05ac369bc..60db810ad 100644 --- a/bootstrap/development/docker/docker-compose.yml +++ b/bootstrap/development/docker/docker-compose.yml @@ -53,6 +53,11 @@ services: volumes: - ../../..:/var/www/coldfront_app/coldfront + qcluster: + image: coldfront-qcluster + volumes: + - ../../..:/var/www/coldfront_app/coldfront + volumes: db-postgres: external: false diff --git a/bootstrap/development/docker/images/qcluster.Dockerfile b/bootstrap/development/docker/images/qcluster.Dockerfile new file mode 100644 index 000000000..980d9cf5e --- /dev/null +++ b/bootstrap/development/docker/images/qcluster.Dockerfile @@ -0,0 +1,3 @@ +FROM coldfront-app-base + +CMD ["python3", "manage.py", "qcluster"] diff --git a/bootstrap/development/docker/scripts/build_images.sh b/bootstrap/development/docker/scripts/build_images.sh index 6aca0e44a..2dbbce365 100755 --- a/bootstrap/development/docker/scripts/build_images.sh +++ b/bootstrap/development/docker/scripts/build_images.sh @@ -7,3 +7,4 @@ docker build -f bootstrap/development/docker/images/app-shell.Dockerfile -t cold docker build -f bootstrap/development/docker/images/web.Dockerfile -t coldfront-web . docker build -f bootstrap/development/docker/images/email-server.Dockerfile -t coldfront-email-server . docker build -f bootstrap/development/docker/images/db-postgres-shell.Dockerfile -t coldfront-db-postgres-shell . +docker build -f bootstrap/development/docker/images/qcluster.Dockerfile -t coldfront-qcluster . diff --git a/bootstrap/development/docs/vagrant-vm-README.md b/bootstrap/development/docs/vagrant-vm-README.md index c41d51bf3..22d9ba1bb 100644 --- a/bootstrap/development/docs/vagrant-vm-README.md +++ b/bootstrap/development/docs/vagrant-vm-README.md @@ -30,16 +30,14 @@ The application may be installed within a Vagrant VM that is running on Scientif # This produces two lines: condense them into one. openssl rand -base64 64 ``` -8. Customize `main.yml`. In particular, uncomment everything under the `dev_settings` section, and fill in the below variables. Note that quotes need not be provided, except in the list variable. - ``` - django_secret_key: secret_key_from_previous_step - db_admin_passwd: password_here - redis_passwd: password_here - from_email: you@email.com - admin_email: you@email.com - email_admin_list: ["you@email.com"] - request_approval_cc_list: ["you@email.com"] - ``` +8. In `main.yml`, uncomment everything under the dev_settings section, +and customize the following variables with your own values. + ``` + django_secret_key: secret_key_from_previous_step + chmod_tasks: true # Can be false when a Windows FS is mounted + db_admin_passwd: root + redis_passwd: root + ``` 9. Provision the VM. This should run the Ansible playbook. Expect this to take a few minutes on the first run. ``` vagrant up diff --git a/coldfront/config/settings.py b/coldfront/config/settings.py index 5b871b1ea..2b5afd517 100644 --- a/coldfront/config/settings.py +++ b/coldfront/config/settings.py @@ -53,6 +53,7 @@ INSTALLED_APPS += [ 'coldfront.core.user', 'coldfront.core.field_of_science', + # 'coldfront.core.department', 'coldfront.core.utils', 'coldfront.core.portal', 'coldfront.core.project', diff --git a/coldfront/config/test_settings.py.sample b/coldfront/config/test_settings.py.sample index 44f99a412..741c11914 100644 --- a/coldfront/config/test_settings.py.sample +++ b/coldfront/config/test_settings.py.sample @@ -157,6 +157,26 @@ FLAGS = { 'SECURE_DIRS_REQUESTABLE': [{'condition': 'boolean', 'value': True}], 'SERVICE_UNITS_PURCHASABLE': [{'condition': 'boolean', 'value': True}], 'SSO_ENABLED': [{'condition': 'boolean', 'value': False}], + 'USER_DEPARTMENTS_ENABLED': [{'condition': 'app installed', 'value': 'coldfront.plugins.departments'}], 'MOU_GENERATION_ENABLED': [{'condition': 'boolean', 'value': False}], 'RENEWAL_SURVEY_ENABLED': [{'condition': 'boolean', 'value': True}], } + +#------------------------------------------------------------------------------ +# Plugin: departments +#------------------------------------------------------------------------------ + +EXTRA_EXTRA_APPS += [ + 'coldfront.plugins.departments' +] + +DEPARTMENTS_DEPARTMENT_DISPLAY_NAME = 'Department' +DEPARTMENTS_DEPARTMENT_DATA_SOURCE = 'coldfront.plugins.departments.utils.data_sources.backends.dummy.DummyDataSourceBackend' + +#------------------------------------------------------------------------------ +# django-q settings +#------------------------------------------------------------------------------ + +Q_CLUSTER = { + 'sync': True, +} 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 ddccb697b..d3fa1d3e2 100644 --- a/coldfront/core/project/forms_/new_project_forms/request_forms.py +++ b/coldfront/core/project/forms_/new_project_forms/request_forms.py @@ -18,7 +18,6 @@ from django import forms from django.conf import settings from django.contrib.auth.models import User -from django.core.exceptions import ImproperlyConfigured from django.core.validators import MaxValueValidator from django.core.validators import MinLengthValidator from django.core.validators import MinValueValidator diff --git a/coldfront/core/project/templates/project/project_request/savio/project_pi_department.html b/coldfront/core/project/templates/project/project_request/savio/project_pi_department.html new file mode 100644 index 000000000..b72656b7d --- /dev/null +++ b/coldfront/core/project/templates/project/project_request/savio/project_pi_department.html @@ -0,0 +1,84 @@ +{% extends "common/base.html" %} +{% load feature_flags %} +{% load crispy_forms_tags %} +{% load common_tags %} +{% load static %} + + +{% block title %} +{{ PRIMARY_CLUSTER_NAME }} Project Request - PI {% settings_value 'DEPARTMENTS_DEPARTMENT_DISPLAY_NAME' %} +{% endblock %} + + +{% block head %} +{{ wizard.form.media }} +{% endblock %} + + +{% block content %} + + + + +
Select one or more {{ department_display_name | lower }}s for the Principal Investigator of the project. You may search for the {{ department_display_name | lower }}(s) in the selection field.
++ {% flag_enabled 'BRC_ONLY' as brc_only %} + {% if brc_only %} Non-Berkeley {% else %} Non-Lab {% endif %} + users should select "Other".
+ + +Step {{ wizard.steps.step1 }} of {{ wizard.steps.count }}
+ + + +{% endblock %} 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 5b2667d05..2e9c6cf10 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 @@ -1,14 +1,27 @@ +from copy import deepcopy +from http import HTTPStatus + +from django.conf import settings +from django.test import override_settings +from django.urls import reverse + from coldfront.core.project.models import Project from coldfront.core.project.models import SavioProjectAllocationRequest from coldfront.core.project.utils_.renewal_utils import get_current_allowance_year_period from coldfront.core.resource.utils_.allowance_utils.interface import ComputingAllowanceInterface from coldfront.core.utils.tests.test_base import enable_deployment -from coldfront.core.utils.tests.test_base import TestBase -from django.urls import reverse -from http import HTTPStatus +from coldfront.core.utils.tests.test_base import TransactionTestBase + +from coldfront.plugins.departments.models import Department +from coldfront.plugins.departments.models import UserDepartment + +Q_CLUSTER_COPY = deepcopy(settings.Q_CLUSTER) +Q_CLUSTER_COPY['sync'] = True -class TestSavioProjectRequestWizard(TestBase): + +@override_settings(Q_CLUSTER=Q_CLUSTER_COPY) +class TestSavioProjectRequestWizard(TransactionTestBase): """A class for testing SavioProjectRequestWizard.""" @enable_deployment('BRC') @@ -21,61 +34,80 @@ def setUp(self): self.interface = ComputingAllowanceInterface() + self._department_1 = Department.objects.create( + name='Department 1', code='DEPT1') + self._department_2 = Department.objects.create( + name='Department 2', code='DEPT2') + @staticmethod - def request_url(): + def _request_url(): """Return the URL for requesting to create a new Savio 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) + def _send_post_data(self, computing_allowance, allocation_period, + details_data, survey_data, new_pi_details=None, + existing_pi=None, pi_departments=None): + """Send a POST request to the view with the given parameters. - computing_allowance = self.get_predominant_computing_allowance() - allocation_period = get_current_allowance_year_period() + TODO: Some POST data should be made configurable (e.g., new PI + details, pooling, etc.). + """ + form_data = [] 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', } + form_data.append(computing_allowance_form_data) + allocation_period_form_data = { '1-allocation_period': allocation_period.pk, current_step_key: '1', } - existing_pi_form_data = { - '2-PI': self.user.pk, - current_step_key: '2', - } + form_data.append(allocation_period_form_data) + + if existing_pi is not None: + existing_pi_form_data = { + '2-PI': existing_pi.pk, + current_step_key: '2', + } + form_data.append(existing_pi_form_data) + + # TODO: Account for new_pi_details. + + if pi_departments is not None: + pi_department_form_data = { + '4-departments': [ + department.pk for department in pi_departments], + current_step_key: '4' + } + form_data.append(pi_department_form_data) + pool_allocations_data = { - '6-pool': False, - current_step_key: '6', + '7-pool': False, + current_step_key: '7', } - details_data = { - '8-name': 'name', - '8-title': 'title', - '8-description': 'a' * 20, - current_step_key: '8', + form_data.append(pool_allocations_data) + + details_data_copy = { + current_step_key: '9', } - survey_data = { - '10-scope_and_intent': 'b' * 20, - '10-computational_aspects': 'c' * 20, - current_step_key: '10', + for key in details_data: + details_data_copy[f'9-{key}'] = details_data[key] + form_data.append(details_data_copy) + + survey_data_copy = { + current_step_key: '11', } - form_data = [ - computing_allowance_form_data, - allocation_period_form_data, - existing_pi_form_data, - pool_allocations_data, - details_data, - survey_data, - ] - - url = self.request_url() + for key in survey_data: + survey_data_copy[f'11-{key}'] = survey_data[key] + form_data.append(survey_data_copy) + + url = self._request_url() for i, data in enumerate(form_data): response = self.client.post(url, data) if i == len(form_data) - 1: @@ -83,11 +115,33 @@ def test_post_creates_request(self): else: self.assertEqual(response.status_code, HTTPStatus.OK) + @enable_deployment('BRC') + def test_post_creates_request_and_project(self): + """Test that a POST request creates a + SavioProjectAllocationRequest and a Project.""" + 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() + details_data = { + 'name': 'name', + 'title': 'title', + 'description': 'a' * 20, + } + survey_data = { + 'scope_and_intent': 'b' * 20, + 'computational_aspects': 'c' * 20, + } + + self._send_post_data( + computing_allowance, allocation_period, details_data, survey_data, + existing_pi=self.user, pi_departments=[self._department_1]) + requests = SavioProjectAllocationRequest.objects.all() self.assertEqual(requests.count(), 1) projects = Project.objects.all() self.assertEqual(projects.count(), 1) - request = requests.first() project = projects.first() self.assertEqual(request.requester, self.user) @@ -98,16 +152,61 @@ def test_post_creates_request(self): self.assertEqual(request.allocation_period, allocation_period) self.assertEqual(request.pi, self.user) self.assertEqual(request.project, project) - self.assertEqual(project.name, f'fc_{details_data["8-name"]}') - self.assertEqual(project.title, details_data['8-title']) - self.assertEqual(project.description, details_data['8-description']) + + details_data['name'] = f'fc_{details_data["name"]}' + for key in details_data: + self.assertEqual(getattr(project, key), details_data[key]) + self.assertFalse(request.pool) - self.assertEqual( - request.survey_answers['scope_and_intent'], - survey_data['10-scope_and_intent']) - self.assertEqual( - request.survey_answers['computational_aspects'], - survey_data['10-computational_aspects']) + + for key in survey_data: + self.assertEqual(request.survey_answers[key], survey_data[key]) + self.assertEqual(request.status.name, 'Under Review') + @override_settings( + DEPARTMENT_DATA_SOURCE=( + 'coldfront.plugins.departments.utils.data_sources.backends.dummy.' + 'DummyDataSourceBackend')) + def test_post_sets_user_departments(self): + """Test that a POST request sets authoritative and + non-authoritative UserDepartments for the PI.""" + self.user.first_name = 'First' + self.user.last_name = 'Last' + self.user.save() + + self.assertFalse(UserDepartment.objects.filter(user=self.user).exists()) + + computing_allowance = self.get_predominant_computing_allowance() + allocation_period = get_current_allowance_year_period() + details_data = { + 'name': 'name', + 'title': 'title', + 'description': 'a' * 20, + } + survey_data = { + 'scope_and_intent': 'b' * 20, + 'computational_aspects': 'c' * 20, + } + self._send_post_data( + computing_allowance, allocation_period, details_data, survey_data, + existing_pi=self.user, pi_departments=[self._department_1]) + + self.assertEqual(UserDepartment.objects.count(), 3) + self.assertTrue( + UserDepartment.objects.filter( + user=self.user, + department=self._department_1, + is_authoritative=False).exists()) + self.assertTrue( + UserDepartment.objects.filter( + user=self.user, + department=Department.objects.get(name='Department F'), + is_authoritative=True).exists()) + self.assertTrue( + UserDepartment.objects.filter( + user=self.user, + department=Department.objects.get(name='Department L'), + is_authoritative=True).exists()) + # TODO 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 2a8d41d12..2c94bcc9c 100644 --- a/coldfront/core/project/views_/new_project_views/request_views.py +++ b/coldfront/core/project/views_/new_project_views/request_views.py @@ -4,6 +4,7 @@ from coldfront.core.allocation.models import AllocationStatusChoice from coldfront.core.billing.forms import BillingIDValidationForm from coldfront.core.billing.utils.queries import get_or_create_billing_activity_from_full_id + from coldfront.core.project.forms_.new_project_forms.request_forms import ComputingAllowanceForm from coldfront.core.project.forms_.new_project_forms.request_forms import SavioProjectAllocationPeriodForm from coldfront.core.project.forms_.new_project_forms.request_forms import SavioProjectDetailsForm @@ -32,9 +33,14 @@ from coldfront.core.resource.utils_.allowance_utils.interface import ComputingAllowanceInterface from coldfront.core.user.models import UserProfile from coldfront.core.user.utils import access_agreement_signed +from coldfront.core.utils.common import import_from_settings from coldfront.core.utils.common import session_wizard_all_form_data from coldfront.core.utils.common import utc_now_offset_aware +from coldfront.plugins.departments.forms import NonAuthoritativeDepartmentSelectionForm +from coldfront.plugins.departments.utils.queries import get_departments_for_user +from coldfront.plugins.departments.utils.queries import UserDepartmentUpdater + from django.conf import settings from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin @@ -48,9 +54,11 @@ from django.views.generic.base import TemplateView from django.views.generic.edit import FormView +from django_q.tasks import async_task from flags.state import flag_enabled from formtools.wizard.views import SessionWizardView +import hashlib import logging @@ -125,6 +133,7 @@ class SavioProjectRequestWizard(LoginRequiredMixin, UserPassesTestMixin, ('allocation_period', SavioProjectAllocationPeriodForm), ('existing_pi', SavioProjectExistingPIForm), ('new_pi', SavioProjectNewPIForm), + ('pi_department', NonAuthoritativeDepartmentSelectionForm), ('ica_extra_fields', SavioProjectICAExtraFieldsForm), ('recharge_extra_fields', SavioProjectRechargeExtraFieldsForm), ('pool_allocations', SavioProjectPoolAllocationsForm), @@ -143,6 +152,8 @@ class SavioProjectRequestWizard(LoginRequiredMixin, UserPassesTestMixin, 'project/project_request/savio/project_existing_pi.html', 'new_pi': 'project/project_request/savio/project_new_pi.html', + 'pi_department': + 'project/project_request/savio/project_pi_department.html', 'ica_extra_fields': 'project/project_request/savio/project_ica_extra_fields.html', 'recharge_extra_fields': @@ -162,6 +173,7 @@ class SavioProjectRequestWizard(LoginRequiredMixin, UserPassesTestMixin, SavioProjectAllocationPeriodForm, SavioProjectExistingPIForm, SavioProjectNewPIForm, + NonAuthoritativeDepartmentSelectionForm, SavioProjectICAExtraFieldsForm, SavioProjectRechargeExtraFieldsForm, SavioProjectPoolAllocationsForm, @@ -247,6 +259,9 @@ def done(self, form_list, **kwargs): allocation_period = self.__get_allocation_period(form_data) pi = self.__handle_pi_data(form_data) + if self.__departments_enabled(): + self.__handle_pi_departments(form_data, pi) + if computing_allowance_wrapper.is_instructional(): self.__handle_ica_allowance( form_data, computing_allowance_wrapper, request_kwargs) @@ -285,6 +300,20 @@ def done(self, form_list, **kwargs): request = SavioProjectAllocationRequest.objects.create( **request_kwargs) + try: + if self.__departments_enabled(): + # Enqueue a task to update the PI's authoritative + # departments. This is purposefully placed outside the + # transaction to prevent it from closing during processing + # (when processing is synchronous). + func = ( + 'coldfront.plugins.departments.tasks.' + 'fetch_and_set_user_authoritative_departments') + async_task( + func, pi.pk, sync=settings.Q_CLUSTER.get('sync', False)) + except Exception as e: + self.logger.exception(e) + # Send a notification email to admins. try: send_new_project_request_admin_notification_email(request) @@ -321,12 +350,13 @@ def condition_dict(): return { '1': view.show_allocation_period_form_condition, '3': view.show_new_pi_form_condition, - '4': view.show_ica_extra_fields_form_condition, - '5': view.show_recharge_extra_fields_form_condition, - '6': view.show_pool_allocations_form_condition, - '7': view.show_pooled_project_selection_form_condition, - '8': view.show_details_form_condition, - '9': view.show_billing_id_form_condition, + '4': view.show_pi_department_form_condition, + '5': view.show_ica_extra_fields_form_condition, + '6': view.show_recharge_extra_fields_form_condition, + '7': view.show_pool_allocations_form_condition, + '8': view.show_pooled_project_selection_form_condition, + '9': view.show_details_form_condition, + '10': view.show_billing_id_form_condition, } def show_allocation_period_form_condition(self): @@ -369,11 +399,34 @@ def show_ica_extra_fields_form_condition(self): computing_allowance.requires_extra_information()) def show_new_pi_form_condition(self): + """Only show the form for providing details about a new PI if + the user did not select an existing PI.""" step_name = 'existing_pi' step = str(self.step_numbers_by_form_name[step_name]) cleaned_data = self.get_cleaned_data_for_step(step) or {} return cleaned_data.get('PI', None) is None + def show_pi_department_form_condition(self): + """Only show the form for providing department(s) for the PI + when the following are true: + - Department information is required. + - The PI does not already have any departments set. + """ + if not self.__departments_enabled(): + return False + + existing_pi_step = str(self.step_numbers_by_form_name['existing_pi']) + existing_pi_cleaned_data = self.get_cleaned_data_for_step( + existing_pi_step) or {} + if existing_pi_cleaned_data: + pi = existing_pi_cleaned_data['PI'] + authoritative_departments, non_authoritative_departments = \ + get_departments_for_user(pi) + if authoritative_departments or non_authoritative_departments: + return False + + return True + def show_pool_allocations_form_condition(self): step_name = 'computing_allowance' step = str(self.step_numbers_by_form_name[step_name]) @@ -404,10 +457,15 @@ def show_recharge_extra_fields_form_condition(self): @staticmethod def __billing_id_required(): """Return whether a billing ID should be requested from the - user. Ultimately, the form will only be included if pooling is - not requested.""" + user. The form for requesting it will be included based on + additional factors.""" return flag_enabled('LRC_ONLY') + @staticmethod + def __departments_enabled(): + """Return whether department functionality is enabled.""" + return flag_enabled('USER_DEPARTMENTS_ENABLED') + def __get_allocation_period(self, form_data): """Return the AllocationPeriod the user selected.""" step_number = self.step_numbers_by_form_name['allocation_period'] @@ -465,48 +523,66 @@ def __handle_pi_data(self, form_data): step_number = self.step_numbers_by_form_name['existing_pi'] data = form_data[step_number] if data['PI']: - return data['PI'] - - # Create a new User object intended to be a new PI. - step_number = self.step_numbers_by_form_name['new_pi'] - data = form_data[step_number] - email = data['email'] - try: - pi = User.objects.create( - username=email, - first_name=data['first_name'], - last_name=data['last_name'], - email=email, - is_active=True) - except IntegrityError as e: - self.logger.error(f'User {email} unexpectedly exists.') - raise e - - # Set the user's middle name in the UserProfile; generate a PI request. - try: + pi = data['PI'] pi_profile = pi.userprofile - except UserProfile.DoesNotExist as e: - self.logger.error( - f'User {email} unexpectedly has no UserProfile.') - raise e - pi_profile.middle_name = data['middle_name'] - pi_profile.upgrade_request = utc_now_offset_aware() - pi_profile.save() + else: + # Create a new User object intended to be a new PI. + step_number = self.step_numbers_by_form_name['new_pi'] + data = form_data[step_number] - # Create an unverified, primary EmailAddress for the new User object. - try: - EmailAddress.objects.create( - user=pi, - email=email, - verified=False, - primary=True) - except IntegrityError as e: - self.logger.error( - f'EmailAddress {email} unexpectedly already exists.') - raise e + try: + email = data['email'] + pi = User.objects.create( + username=email, + first_name=data['first_name'], + last_name=data['last_name'], + email=email, + is_active=False) + except IntegrityError as e: + self.logger.error(f'User {email} unexpectedly exists.') + raise e + + # Set user's middle name in the UserProfile; generate a PI request. + try: + pi_profile = pi.userprofile + except UserProfile.DoesNotExist as e: + self.logger.error( + f'User {email} unexpectedly has no UserProfile.') + raise e + pi_profile.middle_name = data['middle_name'] + pi_profile.upgrade_request = utc_now_offset_aware() + pi_profile.save() + + # Create an unverified, primary EmailAddress for the new User object. + try: + EmailAddress.objects.create( + user=pi, + email=email, + verified=False, + primary=True) + except IntegrityError as e: + self.logger.error( + f'EmailAddress {email} unexpectedly already exists.') + raise e return pi + def __handle_pi_departments(self, form_data, pi): + """Update the PI's non-authoritative departments to the + specified ones, if the requester was prompted to provide them. + Do not update the PI's authoritative departments.""" + step_number = self.step_numbers_by_form_name['pi_department'] + data = form_data[step_number] + non_authoritative_departments = data.get('departments', None) + + if non_authoritative_departments is None: + # The requester was not prompted to provide departments. + return + + user_department_updater = UserDepartmentUpdater( + pi, non_authoritative_departments) + user_department_updater.run(authoritative=False, non_authoritative=True) + def __handle_recharge_allowance(self, form_data, computing_allowance_wrapper, request_kwargs): @@ -611,7 +687,7 @@ def __set_data_from_previous_steps(self, step, dictionary): dictionary.update({ 'breadcrumb_pi': ( f'Existing PI: {pi.first_name} {pi.last_name} ' - f'({pi.email})') + f'({pi.email})'), }) else: first_name = new_pi_form_data['first_name'] diff --git a/coldfront/core/user/models.py b/coldfront/core/user/models.py index 6810cb02b..4f24f4a4c 100644 --- a/coldfront/core/user/models.py +++ b/coldfront/core/user/models.py @@ -12,7 +12,6 @@ from rest_framework.authtoken.models import Token from simple_history.models import HistoricalRecords - class UserProfile(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE) is_pi = models.BooleanField(default=False) @@ -43,7 +42,6 @@ class UserProfile(models.Model): history = HistoricalRecords() - class EmailAddress(models.Model): user = models.ForeignKey( User, on_delete=models.CASCADE, related_name='emailaddress_user') diff --git a/coldfront/core/user/templates/user/user_profile.html b/coldfront/core/user/templates/user/user_profile.html index 6904e6961..855130505 100644 --- a/coldfront/core/user/templates/user/user_profile.html +++ b/coldfront/core/user/templates/user/user_profile.html @@ -76,6 +76,43 @@Select the {{ department_display_name | lower }}s you are associated with. +Deselect a {{ department_display_name | lower }} to remove it from your associations.
+ +External collaborators should select "Other".
+ +{% if auth_department_list %} +The following {{ department_display_name | lower }}s are verified by the institution, and may not be updated.
+