From e85cd9f204b17ff2e3db514794b44ba0558772a1 Mon Sep 17 00:00:00 2001 From: Matthew Li Date: Wed, 8 Jan 2025 10:09:16 -0800 Subject: [PATCH] Allow project managers to request secure directories and conditionally manage users on them (#653) * Allow project managers to request secure dirs; fix bug making inactive projects eligible; fix bug allowing 2nd set of dirs; fix bug allowing request under unrelated project * Add SecureDirRequest.pi field * Add dropdown to select PI in secure dir request flow * Add untested runner class for handling a request to create a new secure directory; update notification email text * Refactor secure dir request view to use runner; remove extraneous logic; clean up breadcrumbs * Rename migration file; fix failing tests given new form step * Remove unused logic for adding PIs to secure directory upon directory request approval * Refactor AllocationDetailView * Allow Project methods for getting PIs/managers to limit to 'Active' * Add/integrate method for determining whether user may manage directory * Notify PI, not requester, that RUA is ready to be signed * Set MOU/RUA filename+contents based on PI or requester based on request type * Remove redundant original copies of moved functions * Refactor/add exception handling to secure dir utils * Update email template context variables * Include new PI field when creating SecureDirRequest in MOU test * Remove unused Project model method * Break up secure_dir_views into four modules * Break up secure_dir_utils * Also email all active PIs upon new directory decisions; adjust tests * Refactor email logic in user management views * Update tests * * Add wrapper object around Allocation representing secure directory * Refactor user management + permissions logic into it * Add runners for secure-dir user management requests * Break up add/remove emails into two files for simplicity * Clean up view for managing secure-dir users * * Avoid repeat DB calls to get directory path * Add in-progress logic to auto-add dir requester to directory * Auto-add requester to dir if has active cluster access * Clean up email handling in secure dir views * Correct import, syntax bugs * Fix failing tests + bugs * Display PI in secure dir request list, detail views * Add migration to fill in SecureDirRequest.pi for existing objs * Add note about manager permissions to 'Add users' template * Correct count of users added/removed to/from dir * * Restore method for fetching PI ProjectUsers to email * Incorporate it so that only those PIs are notified re: secure dir events * Add buttons to navigate back in 'data description' step * Correct phrasing in secure dir request checklist step * Allow secure dir request requester/PI to upload RUA --- .../allocation/forms_/secure_dir_forms.py | 46 +- .../migrations/0016_securedirrequest_pi.py | 26 + .../0017_add_pis_to_secure_dir_requests.py | 31 + coldfront/core/allocation/models.py | 7 +- .../allocation/allocation_detail.html | 14 +- .../secure_dir/secure_dir_manage_users.html | 33 +- .../secure_dir_request/data_description.html | 28 +- .../secure_dir_request/directory_name.html | 10 +- .../secure_dir_request/pi_selection.html | 50 + .../secure_dir_request/rdm_consultation.html | 8 +- .../secure_dir_request_card.html | 8 +- .../secure_dir_request_landing.html | 4 +- .../secure_dir_request_list_table.html | 5 + .../tests/test_utils/test_secure_dir_utils.py | 10 +- ...=> test_secure_dir_new_directory_views.py} | 142 +- ... test_secure_dir_user_management_views.py} | 243 ++- coldfront/core/allocation/urls.py | 39 +- coldfront/core/allocation/utils.py | 96 - .../allocation/utils_/secure_dir_utils.py | 479 ----- .../utils_/secure_dir_utils/__init__.py | 152 ++ .../utils_/secure_dir_utils/new_directory.py | 665 ++++++ .../secure_dir_utils/user_management.py | 207 ++ coldfront/core/allocation/views.py | 228 ++- coldfront/core/allocation/views_/__init__.py | 0 .../allocation/views_/secure_dir_views.py | 1795 ----------------- .../views_/secure_dir_views/__init__.py | 0 .../new_directory/__init__.py | 0 .../new_directory/approval_views.py | 769 +++++++ .../new_directory/request_views.py | 269 +++ .../user_management/__init__.py | 0 .../user_management/approval_views.py | 531 +++++ .../user_management/request_views.py | 192 ++ coldfront/core/project/models.py | 30 +- .../templates/project/project_detail.html | 4 +- coldfront/core/project/urls.py | 9 +- coldfront/core/project/views.py | 16 +- coldfront/core/utils/mou.py | 11 +- .../tests/test_mou_notify_upload_download.py | 15 +- coldfront/core/utils/views/mou_views.py | 24 +- .../new_secure_dir_add_user_request.txt | 3 + .../new_secure_dir_remove_user_request.txt | 3 + ...nding_secure_dir_manage_user_requests.html | 3 - ...ending_secure_dir_manage_user_requests.txt | 3 - ...ecure_dir_manage_user_request_complete.txt | 4 +- .../secure_dir_manage_user_request_denied.txt | 4 +- .../secure_dir_new_request_admin.txt | 2 +- .../secure_dir_new_request_pi.txt | 6 +- .../secure_dir_request_approved.txt | 2 +- 48 files changed, 3480 insertions(+), 2746 deletions(-) create mode 100644 coldfront/core/allocation/migrations/0016_securedirrequest_pi.py create mode 100644 coldfront/core/allocation/migrations/0017_add_pis_to_secure_dir_requests.py create mode 100644 coldfront/core/allocation/templates/secure_dir/secure_dir_request/pi_selection.html rename coldfront/core/allocation/tests/test_views/{test_secure_dir_request_views.py => test_secure_dir_new_directory_views.py} (89%) rename coldfront/core/allocation/tests/test_views/{test_secure_dir_manage_users_views.py => test_secure_dir_user_management_views.py} (85%) delete mode 100644 coldfront/core/allocation/utils_/secure_dir_utils.py create mode 100644 coldfront/core/allocation/utils_/secure_dir_utils/__init__.py create mode 100644 coldfront/core/allocation/utils_/secure_dir_utils/new_directory.py create mode 100644 coldfront/core/allocation/utils_/secure_dir_utils/user_management.py create mode 100644 coldfront/core/allocation/views_/__init__.py delete mode 100644 coldfront/core/allocation/views_/secure_dir_views.py create mode 100644 coldfront/core/allocation/views_/secure_dir_views/__init__.py create mode 100644 coldfront/core/allocation/views_/secure_dir_views/new_directory/__init__.py create mode 100644 coldfront/core/allocation/views_/secure_dir_views/new_directory/approval_views.py create mode 100644 coldfront/core/allocation/views_/secure_dir_views/new_directory/request_views.py create mode 100644 coldfront/core/allocation/views_/secure_dir_views/user_management/__init__.py create mode 100644 coldfront/core/allocation/views_/secure_dir_views/user_management/approval_views.py create mode 100644 coldfront/core/allocation/views_/secure_dir_views/user_management/request_views.py create mode 100644 coldfront/templates/email/secure_dir_request/new_secure_dir_add_user_request.txt create mode 100644 coldfront/templates/email/secure_dir_request/new_secure_dir_remove_user_request.txt delete mode 100644 coldfront/templates/email/secure_dir_request/pending_secure_dir_manage_user_requests.html delete mode 100644 coldfront/templates/email/secure_dir_request/pending_secure_dir_manage_user_requests.txt diff --git a/coldfront/core/allocation/forms_/secure_dir_forms.py b/coldfront/core/allocation/forms_/secure_dir_forms.py index 8f553fc0e..6ecea33bb 100644 --- a/coldfront/core/allocation/forms_/secure_dir_forms.py +++ b/coldfront/core/allocation/forms_/secure_dir_forms.py @@ -1,8 +1,11 @@ from django import forms from django.core.validators import MinLengthValidator -from coldfront.core.allocation.utils_.secure_dir_utils import is_secure_directory_name_suffix_available -from coldfront.core.allocation.utils_.secure_dir_utils import SECURE_DIRECTORY_NAME_PREFIX +from coldfront.core.allocation.utils_.secure_dir_utils.new_directory import is_secure_directory_name_suffix_available +from coldfront.core.allocation.utils_.secure_dir_utils.new_directory import SECURE_DIRECTORY_NAME_PREFIX +from coldfront.core.project.models import ProjectUser +from coldfront.core.project.models import ProjectUserRoleChoice +from coldfront.core.project.models import ProjectUserStatusChoice class SecureDirNameField(forms.CharField): @@ -72,6 +75,31 @@ class SecureDirManageUsersRequestCompletionForm(forms.Form): widget=forms.Select()) +class SecureDirPISelectionForm(forms.Form): + + pi = forms.ModelChoiceField( + label='Principal Investigator', + queryset=ProjectUser.objects.none(), + required=True, + widget=forms.Select()) + + def __init__(self, *args, **kwargs): + self._project_pk = kwargs.pop('project_pk', None) + super().__init__(*args, **kwargs) + + if not self._project_pk: + return + self._set_pi_queryset() + + def _set_pi_queryset(self): + """Set the 'pi' choices to active PIs on the project.""" + pi_role = ProjectUserRoleChoice.objects.get( + name='Principal Investigator') + active_status = ProjectUserStatusChoice.objects.get(name='Active') + self.fields['pi'].queryset = ProjectUser.objects.filter( + project__pk=self._project_pk, role=pi_role, status=active_status) + + class SecureDirDataDescriptionForm(forms.Form): department = forms.CharField( label=('Specify the full name of the department that this directory ' @@ -96,10 +124,6 @@ class SecureDirDataDescriptionForm(forms.Form): 'the Information Security and Policy team) about your data?', required=False) - def __init__(self, *args, **kwargs): - kwargs.pop('breadcrumb_project', None) - super().__init__(*args, **kwargs) - class SecureDirRDMConsultationForm(forms.Form): rdm_consultants = forms.CharField( @@ -110,10 +134,6 @@ class SecureDirRDMConsultationForm(forms.Form): required=True, widget=forms.Textarea(attrs={'rows': 3})) - def __init__(self, *args, **kwargs): - kwargs.pop('breadcrumb_project', None) - super().__init__(*args, **kwargs) - class SecureDirDirectoryNamesForm(forms.Form): @@ -125,11 +145,6 @@ class SecureDirDirectoryNamesForm(forms.Form): required=True, widget=forms.Textarea(attrs={'rows': 1})) - def __init__(self, *args, **kwargs): - kwargs.pop('breadcrumb_rdm_consultation', None) - kwargs.pop('breadcrumb_project', None) - super().__init__(*args, **kwargs) - class SecureDirSetupForm(forms.Form): @@ -217,6 +232,7 @@ class SecureDirRDMConsultationReviewForm(forms.Form): required=False, widget=forms.Textarea(attrs={'rows': 3})) + class SecureDirRequestEditDepartmentForm(forms.Form): department = forms.CharField( diff --git a/coldfront/core/allocation/migrations/0016_securedirrequest_pi.py b/coldfront/core/allocation/migrations/0016_securedirrequest_pi.py new file mode 100644 index 000000000..ae4aa1a27 --- /dev/null +++ b/coldfront/core/allocation/migrations/0016_securedirrequest_pi.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.5 on 2024-04-23 15:43 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('allocation', '0015_allocationrenewalrequest_renewal_survey_answers'), + ] + + operations = [ + migrations.AddField( + model_name='securedirrequest', + name='pi', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='secure_dir_request_pi', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='securedirrequest', + name='requester', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='secure_dir_request_requester', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/coldfront/core/allocation/migrations/0017_add_pis_to_secure_dir_requests.py b/coldfront/core/allocation/migrations/0017_add_pis_to_secure_dir_requests.py new file mode 100644 index 000000000..f0835841a --- /dev/null +++ b/coldfront/core/allocation/migrations/0017_add_pis_to_secure_dir_requests.py @@ -0,0 +1,31 @@ +from django.db import migrations + + +def set_request_pis_to_requesters(apps, schema_editor): + """Prior to this migration, only PIs could request new secure + directories. Set the PI of each existing request to be equal to the + requester.""" + SecureDirRequest = apps.get_model('allocation', 'SecureDirRequest') + for secure_dir_request in SecureDirRequest.objects.all(): + secure_dir_request.pi = secure_dir_request.requester + secure_dir_request.save() + + +def unset_request_pis(apps, schema_editor): + """Unset the PI for all requests.""" + SecureDirRequest = apps.get_model('allocation', 'SecureDirRequest') + for secure_dir_request in SecureDirRequest.objects.all(): + secure_dir_request.pi = None + secure_dir_request.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('allocation', '0016_securedirrequest_pi'), + ] + + operations = [ + migrations.RunPython( + set_request_pis_to_requesters, unset_request_pis) + ] diff --git a/coldfront/core/allocation/models.py b/coldfront/core/allocation/models.py index a4c825483..704798bce 100644 --- a/coldfront/core/allocation/models.py +++ b/coldfront/core/allocation/models.py @@ -729,7 +729,12 @@ def secure_dir_request_state_schema(): class SecureDirRequest(TimeStampedModel): - requester = models.ForeignKey(User, on_delete=models.CASCADE) + requester = models.ForeignKey( + User, on_delete=models.CASCADE, + related_name='secure_dir_request_requester') + pi = models.ForeignKey( + User, null=True, on_delete=models.CASCADE, + related_name='secure_dir_request_pi') directory_name = models.TextField() department = models.TextField(null=True) data_description = models.TextField() diff --git a/coldfront/core/allocation/templates/allocation/allocation_detail.html b/coldfront/core/allocation/templates/allocation/allocation_detail.html index d068961e0..031a74987 100644 --- a/coldfront/core/allocation/templates/allocation/allocation_detail.html +++ b/coldfront/core/allocation/templates/allocation/allocation_detail.html @@ -203,15 +203,15 @@

Users in Allocation

{{allocation_users.count}} + {% if add_remove_users_buttons_visible %}
- {% if secure_dir and can_edit_users %} {% comment %} Email Project Users {% endcomment %} Add Users Remove Users - {% endif %}
+ {% endif %}
@@ -222,7 +222,9 @@

Email Cluster Username - Usage + {% if allocation_user_usages_visible %} + Usage + {% endif %} {% flag_enabled 'LRC_ONLY' as lrc_only %} {% if lrc_only %} Billing ID @@ -244,7 +246,9 @@

{% endif %} - {{ allocation_user_su_usages|get_value_from_dict:user.user.username }} + {% if allocation_user_usages_visible %} + {{ allocation_user_su_usages|get_value_from_dict:user.user.username }} + {% endif %} {% flag_enabled 'LRC_ONLY' as lrc_only %} {% if lrc_only %} {{ allocation_user_billing_ids|get_value_from_dict:user.user.username}} @@ -259,7 +263,7 @@

-{% if not secure_dir %} +{% if removed_users_visible %}

Users in Allocation and Removed from Project

{{ action|title }} users {{ preposition }}: {{ directory }} {% if formset %}
-
+ {% if action == 'add' %} +

+ Note: Any project managers who have been added to the directory will also be able to add and remove users to and from the directory. +

+ {% endif %} + {% csrf_token %}
- {% if can_manage_users %} - - {% endif %} + @@ -31,9 +34,7 @@

{{ action|title }} users {{ preposition }}: {{ directory }}

{% for form in formset %} - {% if can_manage_users %} - - {% endif %} + {% if form.selected %} @@ -55,13 +56,11 @@

{{ action|title }} users {{ preposition }}: {{ directory }}

{{ formset.management_form }}
- {% if can_manage_users %} - - {% endif %} - + + Back to Allocation @@ -71,7 +70,7 @@

{{ action|title }} users {{ preposition }}: {{ directory }}

{% else %} - + Back to Allocation diff --git a/coldfront/core/allocation/templates/secure_dir/secure_dir_request/data_description.html b/coldfront/core/allocation/templates/secure_dir/secure_dir_request/data_description.html index ab8cfbecb..477a01ad6 100644 --- a/coldfront/core/allocation/templates/secure_dir/secure_dir_request/data_description.html +++ b/coldfront/core/allocation/templates/secure_dir/secure_dir_request/data_description.html @@ -20,11 +20,11 @@

Secure Directory: Data Description


- {% if breadcrumb_project %} - - {% endif %} +

Please respond to the following questions to provide us with more information about your data.

@@ -41,6 +41,24 @@

Secure Directory: Data Description


{{ wizard.form|crispy }} {% endif %}
- - + + # Username First Name
{{ form.selected }}{{ form.selected }}{{ forloop.counter }}
+ {% if wizard.steps.prev %} + + + {% endif %}
diff --git a/coldfront/core/allocation/templates/secure_dir/secure_dir_request/directory_name.html b/coldfront/core/allocation/templates/secure_dir/secure_dir_request/directory_name.html index 8c2a0c124..80019e5e7 100644 --- a/coldfront/core/allocation/templates/secure_dir/secure_dir_request/directory_name.html +++ b/coldfront/core/allocation/templates/secure_dir/secure_dir_request/directory_name.html @@ -19,13 +19,11 @@

Secure Directory: Directory Name


Please provide the name of the secure directory you are requesting.

diff --git a/coldfront/core/allocation/templates/secure_dir/secure_dir_request/pi_selection.html b/coldfront/core/allocation/templates/secure_dir/secure_dir_request/pi_selection.html new file mode 100644 index 000000000..1f262e18f --- /dev/null +++ b/coldfront/core/allocation/templates/secure_dir/secure_dir_request/pi_selection.html @@ -0,0 +1,50 @@ +{% extends "common/base.html" %} +{% load crispy_forms_tags %} +{% load static %} + + +{% block title %} + Secure Directory Request: PI Selection +{% endblock %} + + +{% block head %} + {{ wizard.form.media }} +{% endblock %} + + +{% block content %} + + + + +

Secure Directory: PI Selection


+ + {% if breadcrumb_project %} + + {% endif %} + +

Select a PI of the project. This PI will be asked to sign a Researcher Use Agreement during the approval process.

+ +
+ {% csrf_token %} + + {{ wizard.management_form }} + {% if wizard.form.forms %} + {{ wizard.form.management_form }} + {% for form in wizard.form.forms %} + {{ form|crispy }} + {% endfor %} + {% else %} + {{ wizard.form|crispy }} + {% endif %} +
+ +
+
+ +

Step {{ wizard.steps.step1 }} of {{ wizard.steps.count }}

+ +{% endblock %} diff --git a/coldfront/core/allocation/templates/secure_dir/secure_dir_request/rdm_consultation.html b/coldfront/core/allocation/templates/secure_dir/secure_dir_request/rdm_consultation.html index b9ee787a0..8cd3c5fed 100644 --- a/coldfront/core/allocation/templates/secure_dir/secure_dir_request/rdm_consultation.html +++ b/coldfront/core/allocation/templates/secure_dir/secure_dir_request/rdm_consultation.html @@ -21,11 +21,9 @@

Secure Directory: Research Data Management Team


diff --git a/coldfront/core/allocation/templates/secure_dir/secure_dir_request/secure_dir_request_card.html b/coldfront/core/allocation/templates/secure_dir/secure_dir_request/secure_dir_request_card.html index 9fc7a3b8f..db3d0b55c 100644 --- a/coldfront/core/allocation/templates/secure_dir/secure_dir_request/secure_dir_request_card.html +++ b/coldfront/core/allocation/templates/secure_dir/secure_dir_request/secure_dir_request_card.html @@ -33,9 +33,15 @@

{% endwith %}

- Name: + Project Name: {{ secure_dir_request.project.name }}

+

+ PI: + {% with pi=secure_dir_request.pi %} + {{ pi.first_name }} {{ pi.last_name }} ({{ pi.email }}) + {% endwith %} +

Department: {{ secure_dir_request.department }} diff --git a/coldfront/core/allocation/templates/secure_dir/secure_dir_request/secure_dir_request_landing.html b/coldfront/core/allocation/templates/secure_dir/secure_dir_request/secure_dir_request_landing.html index 5137dd5af..612de6ed9 100644 --- a/coldfront/core/allocation/templates/secure_dir/secure_dir_request/secure_dir_request_landing.html +++ b/coldfront/core/allocation/templates/secure_dir/secure_dir_request/secure_dir_request_landing.html @@ -9,7 +9,7 @@ {% block content %} -

New Secure Directories


+

New Secure Directories for: {{ project.name }}


UC Berkeley's institutional Linux cluster, Savio is certified by the @@ -40,7 +40,7 @@

New Secure Directories


by can found here.

- + Continue diff --git a/coldfront/core/allocation/templates/secure_dir/secure_dir_request/secure_dir_request_list_table.html b/coldfront/core/allocation/templates/secure_dir/secure_dir_request/secure_dir_request_list_table.html index d6c694152..b3efc08b4 100644 --- a/coldfront/core/allocation/templates/secure_dir/secure_dir_request/secure_dir_request_list_table.html +++ b/coldfront/core/allocation/templates/secure_dir/secure_dir_request/secure_dir_request_list_table.html @@ -17,6 +17,10 @@ Project {% include 'common/table_sorter.html' with table_sorter_field='project' %} + + PI + {% include 'common/table_sorter.html' with table_sorter_field='pi' %} + Status @@ -32,6 +36,7 @@ {{ sec_dir_request.request_time|date:"M. d, Y" }} {{ sec_dir_request.requester.email }} {{ sec_dir_request.project.name }} + {{ sec_dir_request.pi.email }} {% with status=sec_dir_request.status.name %} {% if status == 'Under Review' %} diff --git a/coldfront/core/allocation/tests/test_utils/test_secure_dir_utils.py b/coldfront/core/allocation/tests/test_utils/test_secure_dir_utils.py index 93e8441fa..866d33adc 100644 --- a/coldfront/core/allocation/tests/test_utils/test_secure_dir_utils.py +++ b/coldfront/core/allocation/tests/test_utils/test_secure_dir_utils.py @@ -7,15 +7,15 @@ Allocation, AllocationStatusChoice, AllocationAttribute, \ SecureDirAddUserRequest, SecureDirAddUserRequestStatusChoice, \ SecureDirRemoveUserRequest, SecureDirRemoveUserRequestStatusChoice -from coldfront.core.allocation.utils_.secure_dir_utils import \ - create_secure_dirs, get_secure_dir_manage_user_request_objects +from coldfront.core.allocation.utils_.secure_dir_utils.new_directory import create_secure_directory +from coldfront.core.allocation.utils_.secure_dir_utils.user_management import get_secure_dir_manage_user_request_objects from coldfront.core.resource.models import Resource from coldfront.core.user.models import UserProfile from coldfront.core.utils.tests.test_base import TestBase class TestCreateSecureDir(TestBase): - """A class for testing create_secure_dirs.""" + """A class for testing create_secure_directory.""" def setUp(self): """Set up test data.""" @@ -32,8 +32,8 @@ def setUp(self): self.subdirectory_name = 'pl1_test_dir' call_command('add_directory_defaults') - create_secure_dirs(self.project1, self.subdirectory_name, 'groups') - create_secure_dirs(self.project1, self.subdirectory_name, 'scratch') + create_secure_directory(self.project1, self.subdirectory_name, 'groups') + create_secure_directory(self.project1, self.subdirectory_name, 'scratch') def test_allocation_objects_created(self): """Testing that allocation objects are created""" diff --git a/coldfront/core/allocation/tests/test_views/test_secure_dir_request_views.py b/coldfront/core/allocation/tests/test_views/test_secure_dir_new_directory_views.py similarity index 89% rename from coldfront/core/allocation/tests/test_views/test_secure_dir_request_views.py rename to coldfront/core/allocation/tests/test_views/test_secure_dir_new_directory_views.py index 9d76ae06f..3e6475400 100644 --- a/coldfront/core/allocation/tests/test_views/test_secure_dir_request_views.py +++ b/coldfront/core/allocation/tests/test_views/test_secure_dir_new_directory_views.py @@ -57,6 +57,7 @@ def setUp(self): project_user_status = ProjectUserStatusChoice.objects.get( name='Active') user_role = ProjectUserRoleChoice.objects.get(name='User') + manager_role = ProjectUserRoleChoice.objects.get(name='Manager') pi_role = ProjectUserRoleChoice.objects.get( name='Principal Investigator') for i in range(2): @@ -65,12 +66,14 @@ def setUp(self): name=f'fc_project{i}', status=project_status) setattr(self, f'project{i}', project) for j in range(2): + non_pi_role = manager_role if j % 2 == 0 else user_role ProjectUser.objects.create( user=getattr(self, f'user{j}'), project=project, - role=user_role, status=project_user_status) + role=non_pi_role, status=project_user_status) ProjectUser.objects.create( - user=getattr(self, f'pi{j}'), project=project, role=pi_role, - status=project_user_status) + user=getattr(self, f'pi{j}'), project=project, + role=pi_role, status=project_user_status, + enable_notifications=True) # Create a compute allocation for the Project. allocation = Decimal(f'{i + 1}000.00') @@ -105,25 +108,32 @@ def setUp(self): super().setUp() self.url = 'secure-dir-request' - def get_form_data(self): - """Generates valid form data for SecureDirRequestWizard.""" + @staticmethod + def get_form_data(pi_project_user_pk): + """Generates valid form data for SecureDirRequestWizard. Select + the PI ProjectUser with the given primary key.""" view_name = 'secure_dir_request_wizard' current_step_key = f'{view_name}-current_step' - data_description_form_data = { - '0-department': 'Dept. of Testing', - '0-data_description': 'a' * 20, - '0-rdm_consultation': True, + pi_selection_form_data = { + '0-pi': pi_project_user_pk, current_step_key: '0', } - rdm_consultation_form_data = { - '1-rdm_consultants': 'Tom and Jerry', + data_description_form_data = { + '1-department': 'Dept. of Testing', + '1-data_description': 'a' * 20, + '1-rdm_consultation': True, current_step_key: '1', } - directory_name_data = { - '2-directory_name': 'test_dir', + rdm_consultation_form_data = { + '2-rdm_consultants': 'Tom and Jerry', current_step_key: '2', } + directory_name_data = { + '3-directory_name': 'test_dir', + current_step_key: '3', + } form_data = [ + pi_selection_form_data, data_description_form_data, rdm_consultation_form_data, directory_name_data @@ -136,14 +146,20 @@ def test_access(self): self.assert_has_access(url, self.admin, True) self.assert_has_access(url, self.pi0, True) self.assert_has_access(url, self.pi1, True) - self.assert_has_access(url, self.user0, False) + # user0 is a manager of project1. + self.assert_has_access(url, self.user0, True) + # user1 is a regular user of project1. + self.assert_has_access(url, self.user1, False) def test_post_creates_request(self): """Test that a POST request creates a SecureDirRequest.""" self.assertEqual(SecureDirRequest.objects.count(), 0) pre_time = utc_now_offset_aware() - form_data = self.get_form_data() + + pi_project_user_pk = ProjectUser.objects.get( + project=self.project1, user=self.pi0).pk + form_data = self.get_form_data(pi_project_user_pk) self.client.login(username=self.pi0.username, password=self.password) url = reverse(self.url, kwargs={'pk': self.project1.pk}) @@ -161,15 +177,16 @@ def test_post_creates_request(self): request = requests.first() self.assertEqual(request.requester, self.pi0) + self.assertEqual(request.pi, self.pi0) self.assertEqual( request.data_description, - form_data[0]['0-data_description']) + form_data[1]['1-data_description']) self.assertEqual( request.rdm_consultation, - form_data[1]['1-rdm_consultants']) + form_data[2]['2-rdm_consultants']) self.assertEqual( request.directory_name, - f'pl1_{form_data[2]["2-directory_name"]}') + f'pl1_{form_data[3]["3-directory_name"]}') self.assertEqual(request.project, self.project1) self.assertTrue( pre_time < request.request_time < utc_now_offset_aware()) @@ -179,9 +196,11 @@ def test_post_creates_request(self): def test_emails_sent(self): """Test that a POST request sends the correct emails.""" - form_data = self.get_form_data() + pi_project_user_pk = ProjectUser.objects.get( + project=self.project1, user=self.pi0).pk + form_data = self.get_form_data(pi_project_user_pk) - self.client.login(username=self.pi1.username, password=self.password) + self.client.login(username=self.user0.username, password=self.password) url = reverse(self.url, kwargs={'pk': self.project1.pk}) for i, data in enumerate(form_data): response = self.client.post(url, data) @@ -193,16 +212,22 @@ def test_emails_sent(self): # Test that the correct emails are sent. admin_email = settings.EMAIL_ADMIN_LIST pi0_email = self.pi0.email - pi_email_body = [f'There is a new secure directory request for ' - f'project {self.project1.name} requested by ' - f'{self.pi1.first_name} {self.pi1.last_name}' - f' ({self.pi1.email})', - f'You may view the details of the request here:'] - admin_email_body = [f'There is a new secure directory request for ' - f'project {self.project1.name} requested by ' - f'{self.pi1.first_name} {self.pi1.last_name}' - f' ({self.pi1.email}).', - 'Please review the request here:'] + pi_email_body = [ + (f'{self.user0.first_name} {self.user0.last_name} ' + f'({self.user0.email}) has made a request to create a new secure ' + f'directory under project {self.project1.name}, with you as the ' + f'Principal Investigator (PI)'), + 'view the details of the request', + 'would like to prevent this', + ] + admin_email_body = [ + (f'There is a new secure directory request for project ' + f'{self.project1.name} under PI {self.pi0.first_name} ' + f'{self.pi0.last_name} ({self.pi0.email}) requested by ' + f'{self.user0.first_name} {self.user0.last_name} ' + f'({self.user0.email}).'), + 'Please review the request here:', + ] self.assertEqual(2, len(mail.outbox)) for email in mail.outbox: @@ -230,6 +255,7 @@ def setUp(self): self.request0 = SecureDirRequest.objects.create( directory_name='test_dir0', requester=self.pi0, + pi=self.pi0, data_description='a'*20, project=self.project0, status=SecureDirRequestStatusChoice.objects.get(name='Under Review'), @@ -239,6 +265,7 @@ def setUp(self): self.request1 = SecureDirRequest.objects.create( directory_name='test_dir1', requester=self.pi1, + pi=self.pi1, data_description='a'*20, project=self.project1, status=SecureDirRequestStatusChoice.objects.get(name='Under Review'), @@ -347,7 +374,8 @@ def setUp(self): # Create SecureDirRequest self.request0 = SecureDirRequest.objects.create( directory_name='test_dir', - requester=self.pi0, + requester=self.user0, + pi=self.pi0, data_description='a'*20, project=self.project0, status=SecureDirRequestStatusChoice.objects.get(name='Approved - Processing'), @@ -365,7 +393,8 @@ def setUp(self): def test_access(self): self.assert_has_access(self.url0, self.admin, True) self.assert_has_access(self.url0, self.staff, True) - self.assert_has_access(self.url0, self.user0, False) + self.assert_has_access(self.url0, self.user0, True) + self.assert_has_access(self.url0, self.user1, False) self.assert_has_access(self.url0, self.pi0, True) def test_content(self): @@ -434,12 +463,8 @@ def test_post_request_creates_allocations(self): def test_post_request_emails_sent(self): """Test that a POST request sends the correct emails.""" self.client.login(username=self.admin.username, password=self.password) - response = self.client.post(self.url0, {}) + self.client.post(self.url0, {}) - # Test that the correct emails are sent. - pi_emails = self.project0.projectuser_set.filter( - role__name='Principal Investigator' - ).values_list('user__email', flat=True) email_body = [f'Your request for a secure directory for project ' f'\'{self.project0.name}\' was approved. Setup ' f'on the cluster is complete.', @@ -450,12 +475,15 @@ def test_post_request_emails_sent(self): f'{self.request0.directory_name}\', ' f'respectively.'] - self.assertEqual(len(pi_emails), len(mail.outbox)) - for email in mail.outbox: - self.assertIn(email.to[0], pi_emails) - for section in email_body: - self.assertIn(section, email.body) - self.assertEqual(settings.EMAIL_SENDER, email.from_email) + # The email should be sent to the requester, with active PIs CC'ed. + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertIn('Secure Directory Request Approved', email.subject) + self.assertEqual(email.to, [self.user0.email]) + self.assertEqual(email.cc, [self.pi0.email, self.pi1.email]) + self.assertEqual(email.from_email, settings.EMAIL_SENDER) + for section in email_body: + self.assertIn(section, email.body) class TestSecureDirRequestUndenyRequestView(TestSecureDirRequestBase): @@ -468,6 +496,7 @@ def setUp(self): self.request0 = SecureDirRequest.objects.create( directory_name='test_dir', requester=self.pi0, + pi=self.pi0, data_description='a'*20, project=self.project0, status=SecureDirRequestStatusChoice.objects.get(name='Denied'), @@ -544,7 +573,8 @@ def setUp(self): # Create SecureDirRequest self.request0 = SecureDirRequest.objects.create( directory_name='test_dir', - requester=self.pi0, + requester=self.user0, + pi=self.pi0, data_description='a'*20, project=self.project0, status=SecureDirRequestStatusChoice.objects.get(name='Approved - Processing'), @@ -589,24 +619,23 @@ def test_post_request_sends_emails(self): """Test that a POST request sends the correct emails.""" self.client.login(username=self.admin.username, password=self.password) data = {'justification': 'This is a test denial justification.'} - response = self.client.post(self.url, data) + self.client.post(self.url, data) - # Test that the correct emails are sent. - pi_emails = self.project0.projectuser_set.filter( - role__name='Principal Investigator' - ).values_list('user__email', flat=True) email_body = [f'Your request for a secure directory for project ' f'\'{self.project0.name}\' was denied for the ' f'following reason:', data['justification'], 'If you have any questions, please contact us at'] - self.assertEqual(len(pi_emails), len(mail.outbox)) - for email in mail.outbox: - self.assertIn(email.to[0], pi_emails) - for section in email_body: - self.assertIn(section, email.body) - self.assertEqual(settings.EMAIL_SENDER, email.from_email) + # The email should be sent to the requester, with active PIs CC'ed. + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertIn('Secure Directory Request Denied', email.subject) + self.assertEqual(email.to, [self.user0.email]) + self.assertEqual(email.cc, [self.pi0.email, self.pi1.email]) + self.assertEqual(email.from_email, settings.EMAIL_SENDER) + for section in email_body: + self.assertIn(section, email.body) class TestSecureDirRequestReviewRDMConsultView(TestSecureDirRequestBase): @@ -619,6 +648,7 @@ def setUp(self): self.request0 = SecureDirRequest.objects.create( directory_name='test_dir', requester=self.pi0, + pi=self.pi0, data_description='a'*20, project=self.project0, status=SecureDirRequestStatusChoice.objects.get(name='Under Review'), @@ -680,6 +710,7 @@ def setUp(self): self.request0 = SecureDirRequest.objects.create( directory_name='test_dir', requester=self.pi0, + pi=self.pi0, data_description='a'*20, project=self.project0, status=SecureDirRequestStatusChoice.objects.get(name='Under Review'), @@ -745,6 +776,7 @@ def setUp(self): self.request0 = SecureDirRequest.objects.create( directory_name='test_dir', requester=self.pi0, + pi=self.pi0, data_description='a'*20, project=self.project0, status=SecureDirRequestStatusChoice.objects.get(name='Approved - Processing'), diff --git a/coldfront/core/allocation/tests/test_views/test_secure_dir_manage_users_views.py b/coldfront/core/allocation/tests/test_views/test_secure_dir_user_management_views.py similarity index 85% rename from coldfront/core/allocation/tests/test_views/test_secure_dir_manage_users_views.py rename to coldfront/core/allocation/tests/test_views/test_secure_dir_user_management_views.py index bad83ce34..4d8013a73 100644 --- a/coldfront/core/allocation/tests/test_views/test_secure_dir_manage_users_views.py +++ b/coldfront/core/allocation/tests/test_views/test_secure_dir_user_management_views.py @@ -21,7 +21,7 @@ AllocationUserStatusChoice, AllocationAttributeType, AllocationUserAttribute) -from coldfront.core.allocation.utils_.secure_dir_utils import create_secure_dirs +from coldfront.core.allocation.utils_.secure_dir_utils.new_directory import create_secure_directory from coldfront.core.project.models import (ProjectUser, ProjectUserStatusChoice, ProjectUserRoleChoice, Project, @@ -98,6 +98,7 @@ def setUp(self): name='Active')) pi.role = ProjectUserRoleChoice.objects.get(name='Principal ' 'Investigator') + pi.enable_notifications = True pi.save() # Create superuser @@ -112,9 +113,9 @@ def setUp(self): self.subdirectory_name = 'test_dir' call_command('add_directory_defaults') self.groups_allocation = \ - create_secure_dirs(self.project1, self.subdirectory_name, 'groups') + create_secure_directory(self.project1, self.subdirectory_name, 'groups') self.scratch_allocation = \ - create_secure_dirs(self.project1, self.subdirectory_name, 'scratch') + create_secure_directory(self.project1, self.subdirectory_name, 'scratch') for alloc in [self.groups_allocation, self.scratch_allocation]: AllocationUser.objects.create( @@ -299,12 +300,13 @@ def test_correct_users_to_remove(self): SecureDirManageUsersView.""" # Adding users to allocation + active_status = AllocationUserStatusChoice.objects.get(name='Active') for i in range(2, 5): temp_user = User.objects.create(username=f'user{i}') AllocationUser.objects.create( allocation=self.groups_allocation, user=temp_user, - status=AllocationUserStatusChoice.objects.get(name='Active')) + status=active_status) setattr(self, f'user{i}', temp_user) # Users with a pending SecureDirRemoveUserRequest should not be shown @@ -316,31 +318,23 @@ def test_correct_users_to_remove(self): # Testing users shown on groups_allocation remove users page kwargs = {'pk': self.groups_allocation.pk, 'action': 'remove'} - response = self.get_response(self.pi, - self.url, - kwargs=kwargs) - html = response.content.decode('utf-8') - self.assertIn(self.user3.username, html) - self.assertIn(self.user4.username, html) - - self.assertNotIn(self.user0.username, html) - self.assertNotIn(self.user1.username, html) - self.assertNotIn(self.user2.username, html) - self.assertNotIn(self.admin.username, html) + response = self.get_response(self.pi, self.url, kwargs=kwargs) + self.assertContains(response, self.user3.username) + self.assertContains(response, self.user4.username) + self.assertNotContains(response, self.user0.username) + self.assertNotContains(response, self.user1.username) + self.assertNotContains(response, self.user2.username) + self.assertNotContains(response, self.admin.username) # Testing users shown on scratch_allocation remove users page kwargs = {'pk': self.scratch_allocation.pk, 'action': 'remove'} - response = self.get_response(self.pi, - self.url, - kwargs=kwargs) - html = response.content.decode('utf-8') - - self.assertNotIn(self.user0.username, html) - self.assertNotIn(self.user1.username, html) - self.assertNotIn(self.user2.username, html) - self.assertNotIn(self.user3.username, html) - self.assertNotIn(self.user4.username, html) - self.assertNotIn(self.admin.username, html) + response = self.get_response(self.pi, self.url, kwargs=kwargs) + self.assertNotContains(response, self.user0.username) + self.assertNotContains(response, self.user1.username) + self.assertNotContains(response, self.user2.username) + self.assertNotContains(response, self.user3.username) + self.assertNotContains(response, self.user4.username) + self.assertNotContains(response, self.admin.username) def test_add_users(self): """Test that the correct SecureDirAddUserRequest is created""" @@ -381,17 +375,21 @@ def test_add_users(self): # Test that the correct email is sent. recipients = settings.EMAIL_ADMIN_LIST - email_body = [f'There is 1 new secure ' - f'directory user addition request for ' - f'{self.scratch_path}.', - 'Please process this request here.'] - - self.assertEqual(len(recipients), len(mail.outbox)) - for email in mail.outbox: + email_body = [ + 'There is a new request to add user', + self.scratch_path, + 'Please handle the request here', + ] + + added_user_emails = [self.user0.email] + self.assertEqual(len(mail.outbox), len(added_user_emails)) + for i in range(len(mail.outbox)): + sent_email_obj = mail.outbox[i] + self.assertEqual(sent_email_obj.to, recipients) + self.assertEqual(sent_email_obj.from_email, settings.EMAIL_SENDER) for section in email_body: - self.assertIn(section, email.body) - self.assertIn(email.to[0], recipients) - self.assertEqual(settings.EMAIL_SENDER, email.from_email) + self.assertIn(section, sent_email_obj.body) + self.assertIn(added_user_emails[i], sent_email_obj.body) def test_remove_users(self): """Test that the correct SecureDirRemoveUserRequest is created""" @@ -439,17 +437,21 @@ def test_remove_users(self): # Test that the correct email is sent. recipients = settings.EMAIL_ADMIN_LIST - email_body = [f'There are 2 new secure ' - f'directory user removal requests for ' - f'{self.groups_path}.', - 'Please process these requests here.'] - - self.assertEqual(len(recipients), len(mail.outbox)) - for email in mail.outbox: + email_body = [ + 'There is a new request to remove user', + self.groups_path, + 'Please handle the request here', + ] + + removed_user_emails = [self.user0.email, self.user1.email] + self.assertEqual(len(mail.outbox), len(removed_user_emails)) + for i in range(len(mail.outbox)): + sent_email_obj = mail.outbox[i] + self.assertEqual(sent_email_obj.to, recipients) + self.assertEqual(sent_email_obj.from_email, settings.EMAIL_SENDER) for section in email_body: - self.assertIn(section, email.body) - self.assertIn(email.to[0], recipients) - self.assertEqual(settings.EMAIL_SENDER, email.from_email) + self.assertIn(section, sent_email_obj.body) + self.assertIn(removed_user_emails[i], sent_email_obj.body) def test_content(self): """Test that the correct variables are displayed.""" @@ -694,6 +696,23 @@ def reset_status(request): def test_deny_add_request(self): """Testing that the correct status is set and emails are sent when denying a SecureDirAddUserRequest""" + # Add two managers to the project. Add one of them to the directory. + manager_role = ProjectUserRoleChoice.objects.get(name='Manager') + project_user1 = ProjectUser.objects.get( + project=self.project1, user=self.user1) + project_user1.role = manager_role + project_user1.save() + + AllocationUser.objects.create( + allocation=self.add_request.allocation, + user=self.user1, + status=AllocationUserStatusChoice.objects.get(name='Active')) + + ProjectUser.objects.create( + project=self.project1, + user=self.staff, + role=manager_role, + status=ProjectUserStatusChoice.objects.get(name='Active')) kwargs = {'pk': self.add_request.pk, 'action': 'add'} data = {'reason': 'This is a test for denying SecureDirAddUserRequest.'} @@ -709,7 +728,6 @@ def test_deny_add_request(self): utc_now_offset_aware()) # Test that the correct emails are sent - recipients = [self.pi.email, self.user0.email] email_body = [f'The request to add first0 last0 (user0) to ' f'the secure directory {self.groups_path} ' f'has been denied for the following reason:', @@ -717,13 +735,14 @@ def test_deny_add_request(self): 'If you have any questions, please contact us at'] email_subject = 'Secure Directory Addition Request Denied' - self.assertEqual(len(recipients), len(mail.outbox)) - for email in mail.outbox: - for section in email_body: - self.assertIn(section, email.body) - self.assertIn(email_subject, email.subject) - self.assertIn(email.to[0], recipients) - self.assertEqual(settings.EMAIL_SENDER, email.from_email) + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertIn(email_subject, email.subject) + self.assertEqual(email.to, [self.user0.email]) + self.assertEqual(set(email.cc), {self.pi.email, self.user1.email}) + self.assertEqual(email.from_email, settings.EMAIL_SENDER) + for section in email_body: + self.assertIn(section, email.body) # Test that the correct message is displayed. expected_message = \ @@ -743,6 +762,23 @@ def test_deny_add_request(self): def test_deny_removal_request(self): """Testing that the correct status is set and emails are sent when denying a SecureDirRemoveUserRequest""" + # Add two managers to the project. Add one of them to the directory. + manager_role = ProjectUserRoleChoice.objects.get(name='Manager') + project_user0 = ProjectUser.objects.get( + project=self.project1, user=self.user0) + project_user0.role = manager_role + project_user0.save() + + AllocationUser.objects.create( + allocation=self.remove_request.allocation, + user=self.user0, + status=AllocationUserStatusChoice.objects.get(name='Active')) + + ProjectUser.objects.create( + project=self.project1, + user=self.staff, + role=manager_role, + status=ProjectUserStatusChoice.objects.get(name='Active')) kwargs = {'pk': self.remove_request.pk, 'action': 'remove'} data = { @@ -759,7 +795,6 @@ def test_deny_removal_request(self): utc_now_offset_aware()) # Test that the correct emails are sent - recipients = [self.pi.email, self.user1.email] email_body = [f'The request to remove first1 last1 (user1) from ' f'the secure directory {self.scratch_path} has ' f'been denied for the following reason:', @@ -767,13 +802,14 @@ def test_deny_removal_request(self): 'If you have any questions, please contact us at'] email_subject = 'Secure Directory Removal Request Denied' - self.assertEqual(len(recipients), len(mail.outbox)) - for email in mail.outbox: - for section in email_body: - self.assertIn(section, email.body) - self.assertIn(email_subject, email.subject) - self.assertIn(email.to[0], recipients) - self.assertEqual(settings.EMAIL_SENDER, email.from_email) + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertIn(email_subject, email.subject) + self.assertEqual(email.to, [self.user1.email]) + self.assertEqual(set(email.cc), {self.pi.email, self.user0.email}) + self.assertEqual(email.from_email, settings.EMAIL_SENDER) + for section in email_body: + self.assertIn(section, email.body) # Test that the correct message is displayed. expected_message = \ @@ -1066,6 +1102,24 @@ def test_bad_request_status(self): def test_add_request_status_updated(self): """Testing that the request status is updated, corret emails are sent, and correct messages are shown.""" + # Add two managers to the project. Add one of them to the directory. + manager_role = ProjectUserRoleChoice.objects.get(name='Manager') + project_user1 = ProjectUser.objects.get( + project=self.project1, user=self.user1) + project_user1.role = manager_role + project_user1.save() + + AllocationUser.objects.create( + allocation=self.add_request.allocation, + user=self.user1, + status=AllocationUserStatusChoice.objects.get(name='Active')) + + ProjectUser.objects.create( + project=self.project1, + user=self.staff, + role=manager_role, + status=ProjectUserStatusChoice.objects.get(name='Active')) + kwargs = {'pk': self.add_request.pk, 'action': 'add'} data = {'status': 'Complete'} @@ -1100,25 +1154,52 @@ def test_add_request_status_updated(self): self.assertEqual(expected_message, messages[0]) # Test that the correct emails are sent. - recipients = [self.pi.email, self.user0.email] email_body = [f'The request to add first0 last0 (user0) to the secure ' f'directory {self.groups_path} has been ' - f'completed. first0 last0 now has access to ' + f'completed. The user now has access to ' f'{self.groups_path} on the cluster.', 'If you have any questions, please contact us at'] email_subject = 'Secure Directory Addition Request Complete' - self.assertEqual(len(recipients), len(mail.outbox)) - for email in mail.outbox: - for section in email_body: - self.assertIn(section, email.body) - self.assertIn(email_subject, email.subject) - self.assertIn(email.to[0], recipients) - self.assertEqual(settings.EMAIL_SENDER, email.from_email) + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertIn(email_subject, email.subject) + self.assertEqual(email.to, [self.user0.email]) + # Active PIs on the project, along with active managers who have access + # to the directory, should be CC'ed. + self.assertEqual(set(email.cc), {self.pi.email, self.user1.email}) + self.assertEqual(email.from_email, settings.EMAIL_SENDER) + for section in email_body: + self.assertIn(section, email.body) def test_remove_request_status_updated(self): """Testing that the request status is updated, corret emails are sent, and correct messages are shown.""" + # Add two managers to the project. Add one of them to the directory. + manager_role = ProjectUserRoleChoice.objects.get(name='Manager') + project_user0 = ProjectUser.objects.get( + project=self.project1, user=self.user0) + project_user0.role = manager_role + project_user0.save() + + AllocationUser.objects.create( + allocation=self.remove_request.allocation, + user=self.user0, + status=AllocationUserStatusChoice.objects.get(name='Active')) + + ProjectUser.objects.create( + project=self.project1, + user=self.staff, + role=manager_role, + status=ProjectUserStatusChoice.objects.get(name='Active')) + + kwargs = {'pk': self.remove_request.pk, 'action': 'add'} + data = {'status': 'Complete'} + + response = self.post_response(self.admin, self.url, kwargs=kwargs, + data=data) + + mail.outbox = [] kwargs = {'pk': self.remove_request.pk, 'action': 'remove'} data = {'status': 'Complete'} @@ -1154,18 +1235,20 @@ def test_remove_request_status_updated(self): self.assertEqual(expected_message, messages[0]) # Test that the correct emails are sent. - recipients = [self.pi.email, self.user1.email] email_body = [f'The request to remove first1 last1 (user1) from the ' f'secure directory {self.scratch_path} has been ' - f'completed. first1 last1 no longer has access to ' + f'completed. The user no longer has access to ' f'{self.scratch_path} on the cluster.', 'If you have any questions, please contact us at'] email_subject = 'Secure Directory Removal Request Complete' - self.assertEqual(len(recipients), len(mail.outbox)) - for email in mail.outbox: - for section in email_body: - self.assertIn(section, email.body) - self.assertIn(email_subject, email.subject) - self.assertIn(email.to[0], recipients) - self.assertEqual(settings.EMAIL_SENDER, email.from_email) + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertIn(email_subject, email.subject) + self.assertEqual(email.to, [self.user1.email]) + # Active PIs on the project, along with active managers who have access + # to the directory, should be CC'ed. + self.assertEqual(set(email.cc), {self.pi.email, self.user0.email}) + self.assertEqual(email.from_email, settings.EMAIL_SENDER) + for section in email_body: + self.assertIn(section, email.body) diff --git a/coldfront/core/allocation/urls.py b/coldfront/core/allocation/urls.py index 25836c96a..8360e2fec 100644 --- a/coldfront/core/allocation/urls.py +++ b/coldfront/core/allocation/urls.py @@ -6,7 +6,9 @@ import coldfront.core.allocation.views as allocation_views import coldfront.core.allocation.views_.cluster_access_views as \ cluster_access_views -import coldfront.core.allocation.views_.secure_dir_views as secure_dir_views +import coldfront.core.allocation.views_.secure_dir_views.new_directory.approval_views as secure_dir_new_directory_approval_views +import coldfront.core.allocation.views_.secure_dir_views.user_management.approval_views as secure_dir_user_management_approval_views +import coldfront.core.allocation.views_.secure_dir_views.user_management.request_views as secure_dir_user_management_request_views import coldfront.core.utils.views.mou_views as mou_views @@ -87,44 +89,49 @@ with flagged_paths('SECURE_DIRS_REQUESTABLE') as path: flagged_url_patterns = [ + # User Management Request Views path('/secure-dir--users/', - secure_dir_views.SecureDirManageUsersView.as_view(), + secure_dir_user_management_request_views.SecureDirManageUsersView.as_view(), name='secure-dir-manage-users'), + + # User Management Approval Views path('secure-dir--users-request-list/', - secure_dir_views.SecureDirManageUsersRequestListView.as_view(), + secure_dir_user_management_approval_views.SecureDirManageUsersRequestListView.as_view(), name='secure-dir-manage-users-request-list'), path('/secure-dir--user-update-status', - secure_dir_views.SecureDirManageUsersUpdateStatusView.as_view(), + secure_dir_user_management_approval_views.SecureDirManageUsersUpdateStatusView.as_view(), name='secure-dir-manage-user-update-status'), path('/secure-dir--user-complete-status', - secure_dir_views.SecureDirManageUsersCompleteStatusView.as_view(), + secure_dir_user_management_approval_views.SecureDirManageUsersCompleteStatusView.as_view(), name='secure-dir-manage-user-complete-status'), path('/secure-dir--user-deny-request', - secure_dir_views.SecureDirManageUsersDenyRequestView.as_view(), + secure_dir_user_management_approval_views.SecureDirManageUsersDenyRequestView.as_view(), name='secure-dir-manage-user-deny-request'), + + # New Directory Approval Views path('secure-dir-pending-requests/', - secure_dir_views.SecureDirRequestListView.as_view(completed=False), + secure_dir_new_directory_approval_views.SecureDirRequestListView.as_view(completed=False), name='secure-dir-pending-request-list'), path('secure-dir-completed-requests/', - secure_dir_views.SecureDirRequestListView.as_view(completed=True), + secure_dir_new_directory_approval_views.SecureDirRequestListView.as_view(completed=True), name='secure-dir-completed-request-list'), path('secure-dir-request-detail/', - secure_dir_views.SecureDirRequestDetailView.as_view(), + secure_dir_new_directory_approval_views.SecureDirRequestDetailView.as_view(), name='secure-dir-request-detail'), path('secure-dir-request//rdm_consultation', - secure_dir_views.SecureDirRequestReviewRDMConsultView.as_view(), + secure_dir_new_directory_approval_views.SecureDirRequestReviewRDMConsultView.as_view(), name='secure-dir-request-review-rdm-consultation'), path('secure-dir-request//mou', - secure_dir_views.SecureDirRequestReviewMOUView.as_view(), + secure_dir_new_directory_approval_views.SecureDirRequestReviewMOUView.as_view(), name='secure-dir-request-review-mou'), path('secure-dir-request//setup', - secure_dir_views.SecureDirRequestReviewSetupView.as_view(), + secure_dir_new_directory_approval_views.SecureDirRequestReviewSetupView.as_view(), name='secure-dir-request-review-setup'), path('secure-dir-request//deny', - secure_dir_views.SecureDirRequestReviewDenyView.as_view(), + secure_dir_new_directory_approval_views.SecureDirRequestReviewDenyView.as_view(), name='secure-dir-request-review-deny'), path('secure-dir-request//undeny', - secure_dir_views.SecureDirRequestUndenyRequestView.as_view(), + secure_dir_new_directory_approval_views.SecureDirRequestUndenyRequestView.as_view(), name='secure-dir-request-undeny'), path('secure-dir-request//download-unsigned-mou//', mou_views.UnsignedMOUDownloadView.as_view(), @@ -136,10 +143,10 @@ mou_views.MOUDownloadView.as_view(), name='secure-dir-request-download-mou'), path('secure-dir-request//edit-department/', - secure_dir_views.SecureDirRequestEditDepartmentView.as_view(), + secure_dir_new_directory_approval_views.SecureDirRequestEditDepartmentView.as_view(), name='secure-dir-request-edit-department'), path('secure-dir-request//notify-pi/', - secure_dir_views.SecureDirRequestNotifyPIView.as_view(), + secure_dir_new_directory_approval_views.SecureDirRequestNotifyPIView.as_view(), name='secure-dir-request-notify-pi'), ] diff --git a/coldfront/core/allocation/utils.py b/coldfront/core/allocation/utils.py index 4566ea253..d298c2c43 100644 --- a/coldfront/core/allocation/utils.py +++ b/coldfront/core/allocation/utils.py @@ -267,102 +267,6 @@ def review_cluster_access_requests_url(): return urljoin(domain, view) -def create_secure_dirs(project, subdirectory_name, scratch_or_groups): - """ - Creates one secure directory allocation: either a group directory or a - scratch directory, depending on scratch_or_groups. Additionally creates - an AllocationAttribute for the new allocation that corresponds to the - directory path on the cluster. - Parameters: - - project (Project): a Project object to create a secure directory - allocation for - - subdirectory_name (str): the name of the subdirectory on the cluster - - scratch_or_groups (str): one of either 'scratch' or 'groups' - Returns: - - allocation - Raises: - - TypeError, if subdirectory_name has an invalid type - - ValueError, if scratch_or_groups does not have a valid value - - ValidationError, if the Allocations already exist - """ - - if not isinstance(project, Project): - raise TypeError(f'Invalid Project {project}.') - if not isinstance(subdirectory_name, str): - raise TypeError(f'Invalid subdirectory_name {subdirectory_name}.') - if scratch_or_groups not in ['scratch', 'groups']: - raise ValueError(f'Invalid scratch_or_groups arg {scratch_or_groups}.') - - if scratch_or_groups == 'scratch': - p2p3_directory = Resource.objects.get(name='Scratch P2/P3 Directory') - else: - p2p3_directory = Resource.objects.get(name='Groups P2/P3 Directory') - - query = Allocation.objects.filter(project=project, - resources__in=[p2p3_directory]) - - if query.exists(): - raise ValidationError('Allocation already exist') - - allocation = Allocation.objects.create( - project=project, - status=AllocationStatusChoice.objects.get(name='Active'), - start_date=utc_now_offset_aware()) - - p2p3_path = p2p3_directory.resourceattribute_set.get( - resource_attribute_type__name='path') - - allocation.resources.add(p2p3_directory) - - allocation_attribute_type = AllocationAttributeType.objects.get( - name='Cluster Directory Access') - - p2p3_subdirectory = AllocationAttribute.objects.create( - allocation_attribute_type=allocation_attribute_type, - allocation=allocation, - value=os.path.join(p2p3_path.value, subdirectory_name)) - - return allocation - - -def get_secure_dir_manage_user_request_objects(self, action): - """ - Sets attributes pertaining to a secure directory based on the - action being performed. - Parameters: - - self (object): object to set attributes for - - action (str): the action being performed, either 'add' or 'remove' - Raises: - - TypeError, if the 'self' object is not an object - - ValueError, if action is not one of 'add' or 'remove' - """ - - action = action.lower() - if not isinstance(self, object): - raise TypeError(f'Invalid self {self}.') - if action not in ['add', 'remove']: - raise ValueError(f'Invalid action {action}.') - - add_bool = action == 'add' - - request_obj = SecureDirAddUserRequest \ - if add_bool else SecureDirRemoveUserRequest - request_status_obj = SecureDirAddUserRequestStatusChoice \ - if add_bool else SecureDirRemoveUserRequestStatusChoice - - language_dict = { - 'preposition': 'to' if add_bool else 'from', - 'noun': 'addition' if add_bool else 'removal', - 'verb': 'add' if add_bool else 'remove' - } - - setattr(self, 'action', action.lower()) - setattr(self, 'add_bool', add_bool) - setattr(self, 'request_obj', request_obj) - setattr(self, 'request_status_obj', request_status_obj) - setattr(self, 'language_dict', language_dict) - - def has_cluster_access(user): """ Returns True if the user has cluster access, False otherwise diff --git a/coldfront/core/allocation/utils_/secure_dir_utils.py b/coldfront/core/allocation/utils_/secure_dir_utils.py deleted file mode 100644 index ac1518b7f..000000000 --- a/coldfront/core/allocation/utils_/secure_dir_utils.py +++ /dev/null @@ -1,479 +0,0 @@ -import os -import logging - -from django.contrib.auth.models import User -from django.core.exceptions import ValidationError -from django.db.models import Q - -from coldfront.config import settings -from coldfront.core.allocation.models import Allocation, AllocationStatusChoice, \ - AllocationAttributeType, AllocationAttribute, SecureDirAddUserRequest, \ - SecureDirRemoveUserRequest, SecureDirAddUserRequestStatusChoice, \ - SecureDirRemoveUserRequestStatusChoice, SecureDirRequest, \ - SecureDirRequestStatusChoice, AllocationUser, AllocationUserStatusChoice -from coldfront.core.project.models import Project, ProjectUser -from coldfront.core.resource.models import Resource, ResourceAttribute -from coldfront.core.utils.common import utc_now_offset_aware -from coldfront.core.utils.mail import send_email_template - -logger = logging.getLogger(__name__) - - -# All project-specific secure subdirectories begin with the following prefix. -SECURE_DIRECTORY_NAME_PREFIX = 'pl1_' - - -def create_secure_dirs(project, subdirectory_name, scratch_or_groups): - """ - Creates one secure directory allocation: either a group directory or a - scratch2 directory, depending on scratch_or_groups. Additionally creates - an AllocationAttribute for the new allocation that corresponds to the - directory path on the cluster. - Parameters: - - project (Project): a Project object to create a secure directory - allocation for - - subdirectory_name (str): the name of the subdirectory on the cluster - - scratch_or_groups (str): one of either 'scratch' or 'groups' - Returns: - - allocation - Raises: - - TypeError, if subdirectory_name has an invalid type - - ValueError, if scratch_or_groups does not have a valid value - - ValidationError, if the Allocations already exist - """ - - if not isinstance(project, Project): - raise TypeError(f'Invalid Project {project}.') - if not isinstance(subdirectory_name, str): - raise TypeError(f'Invalid subdirectory_name {subdirectory_name}.') - if scratch_or_groups not in ['scratch', 'groups']: - raise ValueError(f'Invalid scratch_or_groups arg {scratch_or_groups}.') - - if scratch_or_groups == 'scratch': - p2p3_directory = Resource.objects.get(name='Scratch P2/P3 Directory') - else: - p2p3_directory = Resource.objects.get(name='Groups P2/P3 Directory') - - query = Allocation.objects.filter(project=project, - resources__in=[p2p3_directory]) - - if query.exists(): - raise ValidationError('Allocation already exist') - - allocation = Allocation.objects.create( - project=project, - status=AllocationStatusChoice.objects.get(name='Active'), - start_date=utc_now_offset_aware()) - - p2p3_path = p2p3_directory.resourceattribute_set.get( - resource_attribute_type__name='path') - - allocation.resources.add(p2p3_directory) - - allocation_attribute_type = AllocationAttributeType.objects.get( - name='Cluster Directory Access') - - p2p3_subdirectory = AllocationAttribute.objects.create( - allocation_attribute_type=allocation_attribute_type, - allocation=allocation, - value=os.path.join(p2p3_path.value, subdirectory_name)) - - return allocation - - -def get_secure_dir_manage_user_request_objects(self, action): - """ - Sets attributes pertaining to a secure directory based on the - action being performed. - - Parameters: - - self (object): object to set attributes for - - action (str): the action being performed, either 'add' or 'remove' - - Raises: - - TypeError, if the 'self' object is not an object - - ValueError, if action is not one of 'add' or 'remove' - """ - - action = action.lower() - if not isinstance(self, object): - raise TypeError(f'Invalid self {self}.') - if action not in ['add', 'remove']: - raise ValueError(f'Invalid action {action}.') - - add_bool = action == 'add' - - request_obj = SecureDirAddUserRequest \ - if add_bool else SecureDirRemoveUserRequest - request_status_obj = SecureDirAddUserRequestStatusChoice \ - if add_bool else SecureDirRemoveUserRequestStatusChoice - - language_dict = { - 'preposition': 'to' if add_bool else 'from', - 'noun': 'addition' if add_bool else 'removal', - 'verb': 'add' if add_bool else 'remove' - } - - setattr(self, 'action', action.lower()) - setattr(self, 'add_bool', add_bool) - setattr(self, 'request_obj', request_obj) - setattr(self, 'request_status_obj', request_status_obj) - setattr(self, 'language_dict', language_dict) - - -def secure_dir_request_state_status(secure_dir_request): - """Return a SecureDirRequestStatusChoice, based on the - 'state' field of the given SecureDirRequest.""" - if not isinstance(secure_dir_request, SecureDirRequest): - raise TypeError( - f'Provided request has unexpected type {type(secure_dir_request)}.') - - state = secure_dir_request.state - rdm_consultation = state['rdm_consultation'] - mou = state['mou'] - setup = state['setup'] - other = state['other'] - - if (rdm_consultation['status'] == 'Denied' or - mou['status'] == 'Denied' or - setup['status'] == 'Denied' or - other['timestamp']): - return SecureDirRequestStatusChoice.objects.get(name='Denied') - - # One or more steps is pending. - if (rdm_consultation['status'] == 'Pending' or - mou['status'] == 'Pending'): - return SecureDirRequestStatusChoice.objects.get( - name='Under Review') - - # The request has been approved and is processing. - return SecureDirRequestStatusChoice.objects.get( - name='Approved - Processing') - - -class SecureDirRequestDenialRunner(object): - """An object that performs necessary database changes when a new - secure directory request is denied.""" - - def __init__(self, request_obj): - self.request_obj = request_obj - - def run(self): - self.deny_request() - self.send_email() - - def deny_request(self): - """Set the status of the request to 'Denied'.""" - self.request_obj.status = \ - SecureDirRequestStatusChoice.objects.get(name='Denied') - self.request_obj.save() - - def send_email(self): - """Send a notification email to the requester and PI.""" - if settings.EMAIL_ENABLED: - pis = self.request_obj.project.projectuser_set.filter( - role__name='Principal Investigator', - status__name='Active', - enable_notifications=True) - users_to_notify = [x.user for x in pis] - users_to_notify.append(self.request_obj.requester) - users_to_notify = set(users_to_notify) - - for user in users_to_notify: - try: - context = { - 'user_first_name': user.first_name, - 'user_last_name': user.last_name, - 'project': self.request_obj.project.name, - 'reason': self.request_obj.denial_reason().justification, - 'signature': settings.EMAIL_SIGNATURE, - 'support_email': settings.CENTER_HELP_EMAIL, - } - - send_email_template( - f'Secure Directory Request Denied', - 'email/secure_dir_request/secure_dir_request_denied.txt', - context, - settings.EMAIL_SENDER, - [user.email]) - - except Exception as e: - logger.error('Failed to send notification email. Details:\n') - logger.exception(e) - - -class SecureDirRequestApprovalRunner(object): - """An object that performs necessary database changes when a new - secure directory request is approved and completed.""" - - def __init__(self, request_obj): - self.request_obj = request_obj - self.success_messages = [] - self.error_messages = [] - - def get_messages(self): - return self.success_messages, self.error_messages - - def run(self): - self.approve_request() - groups_alloc, scratch_alloc = self.call_create_secure_dir() - if groups_alloc and scratch_alloc: - # self.create_pi_alloc_users(groups_alloc, scratch_alloc) - self.send_email(groups_alloc, scratch_alloc) - message = f'The secure directory for ' \ - f'{self.request_obj.project.name} ' \ - f'was successfully created.' - self.success_messages.append(message) - - def approve_request(self): - """Set the status of the request to 'Approved - Complete'.""" - self.request_obj.status = \ - SecureDirRequestStatusChoice.objects.get(name='Approved - Complete') - self.request_obj.completion_time = utc_now_offset_aware() - self.request_obj.save() - - def call_create_secure_dir(self): - """Creates the groups and scratch secure directories.""" - - groups_alloc, scratch_alloc = None, None - subdirectory_name = self.request_obj.directory_name - try: - groups_alloc = \ - create_secure_dirs(self.request_obj.project, - subdirectory_name, - 'groups') - except Exception as e: - message = f'Failed to create groups secure directory for ' \ - f'{self.request_obj.project.name}.' - self.error_messages.append(message) - logger.error(message) - logger.exception(e) - - try: - scratch_alloc = \ - create_secure_dirs(self.request_obj.project, - subdirectory_name, - 'scratch') - except Exception as e: - message = f'Failed to create scratch secure directory for ' \ - f'{self.request_obj.project.name}.' - self.error_messages.append(message) - logger.error(message) - logger.exception(e) - - return groups_alloc, scratch_alloc - - def create_pi_alloc_users(self, groups_alloc, scratch_alloc): - """Creates active AllocationUsers for PIs of the project.""" - - pis = ProjectUser.objects.get( - project=self.request_obj.project, - status__name='Active', - role__name='Principal Investigator' - ).values_list('user', flat=True) - - for pi in pis: - for alloc in [groups_alloc, scratch_alloc]: - AllocationUser.objects.create( - allocation=alloc, - user=pi, - status=AllocationUserStatusChoice.objects.get(name='Active') - ) - - def send_email(self, groups_alloc, scratch_alloc): - """Send a notification email to the requester and PI.""" - if settings.EMAIL_ENABLED: - pis = self.request_obj.project.projectuser_set.filter( - role__name='Principal Investigator', - status__name='Active', - enable_notifications=True) - users_to_notify = [x.user for x in pis] - users_to_notify.append(self.request_obj.requester) - users_to_notify = set(users_to_notify) - - allocation_attribute_type = AllocationAttributeType.objects.get( - name='Cluster Directory Access') - - groups_dir = AllocationAttribute.objects.get( - allocation_attribute_type=allocation_attribute_type, - allocation=groups_alloc).value - - scratch_dir = AllocationAttribute.objects.get( - allocation_attribute_type=allocation_attribute_type, - allocation=scratch_alloc).value - - for user in users_to_notify: - try: - context = { - 'user_first_name': user.first_name, - 'user_last_name': user.last_name, - 'project': self.request_obj.project.name, - 'groups_dir': groups_dir, - 'scratch_dir': scratch_dir, - 'signature': settings.EMAIL_SIGNATURE, - 'support_email': settings.CENTER_HELP_EMAIL, - } - - send_email_template( - f'Secure Directory Request Approved', - 'email/secure_dir_request/secure_dir_request_approved.txt', - context, - settings.EMAIL_SENDER, - [user.email]) - - except Exception as e: - logger.error('Failed to send notification email. Details:\n') - logger.exception(e) - - -def get_secure_dir_allocations(): - """Returns a queryset of all active secure directory allocations.""" - scratch_directory = Resource.objects.get(name='Scratch P2/P3 Directory') - groups_directory = Resource.objects.get(name='Groups P2/P3 Directory') - - queryset = Allocation.objects.filter( - resources__in=[scratch_directory, groups_directory], - status__name='Active') - - return queryset - - -def get_default_secure_dir_paths(): - """Returns the default Groups and Scratch secure directory paths.""" - - groups_path = \ - ResourceAttribute.objects.get( - resource_attribute_type__name='path', - resource__name='Groups P2/P3 Directory').value - scratch_path = \ - ResourceAttribute.objects.get( - resource_attribute_type__name='path', - resource__name='Scratch P2/P3 Directory').value - - return groups_path, scratch_path - - -def pi_eligible_to_request_secure_dir(user): - """Returns True if the user is eligible to request a secure directory. - - Parameters: - - user (User): the user to check if they are eligible - - Returns: - - bool: True if the user is eligible to request a secure directory, - False otherwise - - Raises: - - TypeError, if 'user' is not a User object - """ - - if not isinstance(user, User): - raise TypeError(f'Invalid User {user}.') - - projects_with_existing_requests = \ - set(SecureDirRequest.objects.exclude( - status__name='Denied').values_list('project__pk', flat=True)) - - eligible_project = Q(project__name__startswith='fc_') | \ - Q(project__name__startswith='ic_') | \ - Q(project__name__startswith='co_') & \ - Q(project__status__name='Active') - - eligible_pi = ProjectUser.objects.filter( - eligible_project, - user=user, - role__name='Principal Investigator', - status__name='Active', - ).exclude(project__pk__in=projects_with_existing_requests) - - return eligible_pi.exists() - - -def get_all_secure_dir_paths(): - """Returns a set of all secure directory paths.""" - - group_resource = Resource.objects.get(name='Groups P2/P3 Directory') - scratch_resource = Resource.objects.get(name='Scratch P2/P3 Directory') - - paths = \ - set(AllocationAttribute.objects.filter( - allocation_attribute_type__name='Cluster Directory Access', - allocation__resources__in=[scratch_resource, group_resource]). - values_list('value', flat=True)) - - return paths - - -def is_secure_directory_name_suffix_available(proposed_directory_name_suffix, - exclude_request_pk=None): - """Returns True if the proposed secure directory name suffix is - available and False otherwise. A name suffix is available if it is - neither in use by an existing secure directory nor in use by a - pending request for a new secure directory, with the possible - exception of the request with the given primary key from which it - came. - - Parameters: - - proposed_directory_name_suffix (str): The name of the proposed - directory, without SECURE_DIRECTORY_NAME_PREFIX - - exclude_request_pk (int): The primary key of a SecureDirRequest - object to exclude - - Returns: - - bool: True if the proposed directory name suffix is available, - False otherwise - """ - - def get_directory_name_suffix(_directory_name): - if _directory_name.startswith(SECURE_DIRECTORY_NAME_PREFIX): - _directory_name = _directory_name[ - len(SECURE_DIRECTORY_NAME_PREFIX):] - return _directory_name - - assert not proposed_directory_name_suffix.startswith( - SECURE_DIRECTORY_NAME_PREFIX) - - unavailable_name_suffixes = set() - existing_secure_directory_paths = get_all_secure_dir_paths() - for directory_path in existing_secure_directory_paths: - directory_name = os.path.basename(directory_path) - directory_name_suffix = get_directory_name_suffix(directory_name) - unavailable_name_suffixes.add(directory_name_suffix) - pending_requested_directory_names = list( - SecureDirRequest.objects - .exclude(status__name='Denied') - .exclude(pk=exclude_request_pk) - .values_list('directory_name', flat=True)) - for directory_name in pending_requested_directory_names: - directory_name_suffix = get_directory_name_suffix(directory_name) - unavailable_name_suffixes.add(directory_name_suffix) - - return proposed_directory_name_suffix not in unavailable_name_suffixes - - -def set_sec_dir_context(context_dict, request_obj): - """ - Sets the sec_dir_request, groups_path, and scratch_path items in the given - context dictionary. - - Parameters: - - context_dir (dict): the dictionary in which values are being set - - request_obj (SecureDirRequest): the relevant SecureDirRequest object - - Raises: - - TypeError, if 'context_dir' is not a dictionary - - TypeError, if 'request_obj' is not a SecureDirRequest - """ - - if not isinstance(context_dict, dict): - raise TypeError(f'Passed context_dict {context_dict} is not a dict.') - if not isinstance(request_obj, SecureDirRequest): - raise TypeError(f'Invalid SecureDirRequest {request_obj}.') - - context_dict['secure_dir_request'] = request_obj - context_dict['proposed_directory_name'] = request_obj.directory_name - groups_path, scratch_path = get_default_secure_dir_paths() - context_dict['proposed_groups_path'] = \ - os.path.join(groups_path, context_dict['proposed_directory_name']) - context_dict['proposed_scratch_path'] = \ - os.path.join(scratch_path, context_dict['proposed_directory_name']) diff --git a/coldfront/core/allocation/utils_/secure_dir_utils/__init__.py b/coldfront/core/allocation/utils_/secure_dir_utils/__init__.py new file mode 100644 index 000000000..d7cda0f18 --- /dev/null +++ b/coldfront/core/allocation/utils_/secure_dir_utils/__init__.py @@ -0,0 +1,152 @@ +from django.contrib.auth.models import User + +from coldfront.core.allocation.models import AllocationUser +from coldfront.core.allocation.models import SecureDirAddUserRequest +from coldfront.core.allocation.models import SecureDirRemoveUserRequest + +from coldfront.core.allocation.models import ProjectUser +from coldfront.core.allocation.utils import has_cluster_access + + +__all__ = [ + 'SecureDirectory', +] + + +class SecureDirectory(object): + """A wrapper around an Allocation that represents a secure + directory.""" + + def __init__(self, allocation_obj): + self._allocation_obj = allocation_obj + self._cached_path = None + + @property + def allocation(self): + """Return the underlying Allocation object.""" + return self._allocation_obj + + # TODO: Double check logic. + # TODO: Order by? + def get_addable_users(self): + """Return a list of users that are eligible to be added to the + directory. + + A user must meet the following criteria to be eligible: + - Is an active member of any project belonging to any active + PI of the project that owns the directory + - Has cluster access under any project + - Is not already part of the directory + - Does not have any pending requests to be added to the + directory + - Does not have any pending requests to be removed from the + directory + """ + pis = self._allocation_obj.project.pis(active_only=True) + + pi_project_pks = ( + ProjectUser.objects.filter( + user__in=pis, + role__name='Principal Investigator', + status__name='Active' + ).values_list('project__pk', flat=True).distinct()) + + pi_project_users = ProjectUser.objects.filter( + project__in=pi_project_pks, + status__name='Active') + + eligible_users = { + project_user.user for project_user in pi_project_users} + + eligible_user_pks = { + user.pk + for user in eligible_users + if has_cluster_access(user)} + + for allocation_user in self._allocation_obj.allocationuser_set.filter( + status__name='Active'): + eligible_user_pks.discard(allocation_user.user.pk) + + pending_management_request_kwargs = { + 'allocation': self._allocation_obj, + 'status__name__in': ['Pending', 'Processing'] + } + for request_obj in SecureDirAddUserRequest.objects.filter( + **pending_management_request_kwargs): + eligible_user_pks.discard(request_obj.user.pk) + for request_obj in SecureDirRemoveUserRequest.objects.filter( + **pending_management_request_kwargs): + eligible_user_pks.discard(request_obj.user.pk) + + return User.objects.filter(pk__in=eligible_user_pks) + + def get_path(self): + """Return the path to the secure directory. Cache the path in + the instance to avoid repeat database lookups.""" + if self._cached_path is None: + allocation_attribute = \ + self._allocation_obj.allocationattribute_set.get( + allocation_attribute_type__name='Cluster Directory Access') + self._cached_path = allocation_attribute.value + return self._cached_path + + # TODO: Double check logic. + # TODO: Order by? + def get_removable_users(self): + """Return a list of users that are eligible to be removed from the + directory. + + A user must meet the following criteria to be eligible: + - Does not have any pending requests to be removed from the + directory + - Is not a PI of the project that owns the directory + """ + allocation_users = self._allocation_obj.allocationuser_set.filter( + status__name='Active') + + eligible_users = { + allocation_user.user for allocation_user in allocation_users} + + eligible_user_pks = {user.pk for user in eligible_users} + + pending_management_request_kwargs = { + 'allocation': self._allocation_obj, + 'status__name__in': ['Pending', 'Processing'] + } + for request_obj in SecureDirRemoveUserRequest.objects.filter( + **pending_management_request_kwargs): + eligible_user_pks.discard(request_obj.user.pk) + + pis = self._allocation_obj.project.pis(active_only=True) + for pi in pis: + eligible_user_pks.discard(pi.pk) + + return User.objects.filter(pk__in=eligible_user_pks) + + def user_can_manage(self, user): + """Return whether the given User has permissions to manage this + directory. The following users do: + - Superusers + - Active PIs of the project, regardless of whether they have + been added to the directory + - Active managers of the project who have been added to the + directory + """ + if user.is_superuser: + return True + + project = self._allocation_obj.project + if user in project.pis(active_only=True): + return True + + if user in project.managers(active_only=True): + user_on_allocation = AllocationUser.objects.filter( + allocation=self._allocation_obj, + user=user, + status__name='Active') + if user_on_allocation: + return True + + return False + + # TODO: What other methods? diff --git a/coldfront/core/allocation/utils_/secure_dir_utils/new_directory.py b/coldfront/core/allocation/utils_/secure_dir_utils/new_directory.py new file mode 100644 index 000000000..0c20e640e --- /dev/null +++ b/coldfront/core/allocation/utils_/secure_dir_utils/new_directory.py @@ -0,0 +1,665 @@ +import logging +import os + +from urllib.parse import urljoin + +from django.core.exceptions import ValidationError +from django.db import transaction +from django.db.models import Q +from django.urls import reverse + +from coldfront.config import settings +from coldfront.core.allocation.models import Allocation +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 SecureDirRequest +from coldfront.core.allocation.models import SecureDirRequestStatusChoice +from coldfront.core.allocation.utils import has_cluster_access +from coldfront.core.allocation.utils_.secure_dir_utils import SecureDirectory +from coldfront.core.allocation.utils_.secure_dir_utils.user_management import SecureDirectoryAddUserRequestRunner + +from coldfront.core.project.models import Project +from coldfront.core.project.models import ProjectStatusChoice + +from coldfront.core.resource.models import Resource, ResourceAttribute +from coldfront.core.resource.utils_.allowance_utils.constants import BRCAllowances +from coldfront.core.resource.utils_.allowance_utils.interface import ComputingAllowanceInterface +from coldfront.core.resource.utils_.allowance_utils.interface import ComputingAllowanceInterfaceError + +from coldfront.core.utils.common import utc_now_offset_aware +from coldfront.core.utils.email.email_strategy import validate_email_strategy_or_get_default +from coldfront.core.utils.mail import send_email_template + + +logger = logging.getLogger(__name__) + + +# All project-specific secure subdirectories begin with the following prefix. +SECURE_DIRECTORY_NAME_PREFIX = 'pl1_' + + +def create_secure_directory(project, subdirectory_name, scratch_or_groups): + """ + Creates one secure directory allocation: either a group directory or a + scratch2 directory, depending on scratch_or_groups. Additionally creates + an AllocationAttribute for the new allocation that corresponds to the + directory path on the cluster. + Parameters: + - project (Project): a Project object to create a secure directory + allocation for + - subdirectory_name (str): the name of the subdirectory on the cluster + - scratch_or_groups (str): one of either 'scratch' or 'groups' + Returns: + - allocation + Raises: + - TypeError, if subdirectory_name has an invalid type + - ValueError, if scratch_or_groups does not have a valid value + - ValidationError, if the Allocations already exist + """ + + if not isinstance(project, Project): + raise TypeError(f'Invalid Project {project}.') + if not isinstance(subdirectory_name, str): + raise TypeError(f'Invalid subdirectory_name {subdirectory_name}.') + if scratch_or_groups not in ['scratch', 'groups']: + raise ValueError(f'Invalid scratch_or_groups arg {scratch_or_groups}.') + + if scratch_or_groups == 'scratch': + p2p3_directory = Resource.objects.get(name='Scratch P2/P3 Directory') + else: + p2p3_directory = Resource.objects.get(name='Groups P2/P3 Directory') + + query = Allocation.objects.filter(project=project, + resources__in=[p2p3_directory]) + + if query.exists(): + raise ValidationError('Allocation already exist') + + allocation = Allocation.objects.create( + project=project, + status=AllocationStatusChoice.objects.get(name='Active'), + start_date=utc_now_offset_aware()) + + p2p3_path = p2p3_directory.resourceattribute_set.get( + resource_attribute_type__name='path') + + allocation.resources.add(p2p3_directory) + + allocation_attribute_type = AllocationAttributeType.objects.get( + name='Cluster Directory Access') + + p2p3_subdirectory = AllocationAttribute.objects.create( + allocation_attribute_type=allocation_attribute_type, + allocation=allocation, + value=os.path.join(p2p3_path.value, subdirectory_name)) + + return allocation + + +def secure_dir_request_state_status(secure_dir_request): + """Return a SecureDirRequestStatusChoice, based on the + 'state' field of the given SecureDirRequest.""" + if not isinstance(secure_dir_request, SecureDirRequest): + raise TypeError( + f'Provided request has unexpected type {type(secure_dir_request)}.') + + state = secure_dir_request.state + rdm_consultation = state['rdm_consultation'] + mou = state['mou'] + setup = state['setup'] + other = state['other'] + + if (rdm_consultation['status'] == 'Denied' or + mou['status'] == 'Denied' or + setup['status'] == 'Denied' or + other['timestamp']): + return SecureDirRequestStatusChoice.objects.get(name='Denied') + + # One or more steps is pending. + if (rdm_consultation['status'] == 'Pending' or + mou['status'] == 'Pending'): + return SecureDirRequestStatusChoice.objects.get( + name='Under Review') + + # The request has been approved and is processing. + return SecureDirRequestStatusChoice.objects.get( + name='Approved - Processing') + + +class SecureDirRequestRunner(object): + """An object that performs necessary checks and updates, and sends + notifications, when a new secure directory is requested for a + project.""" + + def __init__(self, request_kwargs, email_strategy=None): + self._request_kwargs = request_kwargs + # Always create the request with 'Under Review' status. + self._request_kwargs['status'] = \ + SecureDirRequestStatusChoice.objects.get(name='Under Review') + self._project_obj = self._request_kwargs['project'] + self._request_obj = None + + self._email_strategy = validate_email_strategy_or_get_default( + email_strategy=email_strategy) + + def run(self): + """Perform checks and updates.""" + is_project_eligible = is_project_eligible_for_secure_dirs( + self._project_obj) + if not is_project_eligible: + raise Exception( + f'Project {self._project_obj.name} is ineligible for a secure ' + f'directory.') + + with transaction.atomic(): + self._request_obj = self._create_secure_directory_request_obj() + + self._send_emails_safe() + + def _create_secure_directory_request_obj(self): + """Create a SecureDirRequest object from provided arguments, and + return it.""" + return SecureDirRequest.objects.create(**self._request_kwargs) + + @staticmethod + def _get_request_detail_url(request_pk): + """Given the primary key of a SecureDirRequest, return a URL to + the detail page for it.""" + domain = settings.CENTER_BASE_URL + view = reverse('secure-dir-request-detail', kwargs={'pk': request_pk}) + return urljoin(domain, view) + + def _send_email_to_admins(self, secure_dir_request_obj): + """Send an email notification to cluster admins, notifying them + of the newly-created request.""" + requester = secure_dir_request_obj.requester + requester_str = ( + f'{requester.first_name} {requester.last_name} ({requester.email})') + + pi = secure_dir_request_obj.pi + pi_str = f'{pi.first_name} {pi.last_name} ({pi.email})' + + review_url = self._get_request_detail_url(secure_dir_request_obj.pk) + + context = { + 'pi_str': pi_str, + 'project_name': secure_dir_request_obj.project.name, + 'requester_str': requester_str, + 'review_url': review_url, + } + + subject = 'New Secure Directory Request' + template_name = ( + 'email/secure_dir_request/secure_dir_new_request_admin.txt') + sender = settings.EMAIL_SENDER + recipients = settings.EMAIL_ADMIN_LIST + + send_email_template(subject, template_name, context, sender, recipients) + + def _send_email_to_pi(self, secure_dir_request_obj): + """Send an email notification to the selected PI, notifying them + that a request was made under their name.""" + requester = secure_dir_request_obj.requester + requester_str = ( + f'{requester.first_name} {requester.last_name} ({requester.email})') + + pi = secure_dir_request_obj.pi + pi_str = f'{pi.first_name} {pi.last_name}' + + review_url = self._get_request_detail_url(secure_dir_request_obj.pk) + + context = { + 'pi_str': pi_str, + 'PORTAL_NAME': settings.PORTAL_NAME, + 'project_name': secure_dir_request_obj.project.name, + 'requester_str': requester_str, + 'review_url': review_url, + } + + subject = 'New Secure Directory Request' + template_name = ( + 'email/secure_dir_request/secure_dir_new_request_pi.txt') + sender = settings.EMAIL_SENDER + recipients = [pi.email] + + send_email_template(subject, template_name, context, sender, recipients) + + def _send_emails(self): + """Send email notifications.""" + # To cluster admins + email_method = self._send_email_to_admins + email_args = (self._request_obj, ) + self._email_strategy.process_email(email_method, *email_args) + + # To the PI, if not the requester + if self._request_obj.pi != self._request_obj.requester: + email_method = self._send_email_to_pi + email_args = (self._request_obj, ) + self._email_strategy.process_email(email_method, *email_args) + + def _send_emails_safe(self): + """Send emails. + + Catch all exceptions to prevent rolling back any enclosing + transaction. + """ + try: + self._send_emails() + except Exception as e: + message = ( + f'Encountered unexpected exception when sending notification ' + f'emails. Details:\n{e}') + logger.exception(message) + + +class SecureDirRequestDenialRunner(object): + """An object that performs necessary database changes when a new + secure directory request is denied.""" + + # TODO: The structure of this class and SecureDirRequestApprovalRunner are + # quite similar. Consider refactoring to avoid redundant logic. + + def __init__(self, request_obj, email_strategy=None): + self._request_obj = request_obj + self._email_strategy = validate_email_strategy_or_get_default( + email_strategy=email_strategy) + + self._success_messages = [] + self._error_messages = [] + + def get_messages(self): + return self._success_messages, self._error_messages + + def run(self): + try: + with transaction.atomic(): + self._deny_request() + except Exception as e: + log_message = ( + f'Failed to deny secure directory request ' + f'{self._request_obj.pk}. Details:\n{e}') + logger.exception(log_message) + message = 'Unexpected failure. Please contact an administrator.' + self._error_messages.append(message) + else: + message = 'Successfully denied the request.' + self._success_messages.append(message) + + self._send_emails_safe() + + def _deny_request(self): + """Set the status of the request to 'Denied'.""" + self._request_obj.status = \ + SecureDirRequestStatusChoice.objects.get(name='Denied') + self._request_obj.save() + + def _send_emails_to_users(self): + """Send notification emails to the requester, CCing all active + PIs on the project.""" + if not settings.EMAIL_ENABLED: + return + + requester = self._request_obj.requester + + context = { + 'user_first_name': requester.first_name, + 'user_last_name': requester.last_name, + 'project': self._request_obj.project.name, + 'reason': self._request_obj.denial_reason().justification, + 'signature': settings.EMAIL_SIGNATURE, + 'support_email': settings.CENTER_HELP_EMAIL, + } + + subject = 'Secure Directory Request Denied' + template_name = 'email/secure_dir_request/secure_dir_request_denied.txt' + sender = settings.EMAIL_SENDER + receiver_list = [requester.email] + + kwargs = {} + pis_to_cc = [ + pi for pi in self._request_obj.project.pis(active_only=True) + if pi != requester] + if pis_to_cc: + kwargs['cc'] = [pi.email for pi in pis_to_cc] + + send_email_template( + subject, template_name, context, sender, receiver_list, **kwargs) + + def _send_emails(self): + """Send email notifications.""" + # To users + email_method = self._send_emails_to_users + email_args = () + self._email_strategy.process_email(email_method, *email_args) + + def _send_emails_safe(self): + """Send emails. Catch and log exceptions.""" + try: + self._send_emails() + except Exception as e: + # TODO: Some email strategies do not send the email directly, so + # this failure message would not be apt. + # TODO: The language in this message and in the function names + # should be updated to something more general (i.e., notifying + # users). + logger.exception( + f'Failed to send notification emails. Details:\n{e}') + + +class SecureDirRequestApprovalRunner(object): + """An object that performs necessary database changes when a new + secure directory request is approved.""" + + # TODO: The structure of this class and SecureDirRequestDenialRunner are + # quite similar. Consider refactoring to avoid redundant logic. + + def __init__(self, request_obj, email_strategy=None): + self._request_obj = request_obj + self._email_strategy = validate_email_strategy_or_get_default( + email_strategy=email_strategy) + + self._groups_directory = None + self._scratch_directory = None + + self._success_messages = [] + self._error_messages = [] + + def get_messages(self): + return self._success_messages, self._error_messages + + def run(self): + try: + with transaction.atomic(): + self._approve_request() + self._create_secure_directories() + if self._should_add_requester_to_directories(): + self._add_requester_to_directories() + except Exception as e: + log_message = ( + f'Failed to approve secure directory request ' + f'{self._request_obj.pk}. Details:\n{e}') + logger.exception(log_message) + message = 'Unexpected failure. Please contact an administrator.' + self._error_messages.append(message) + else: + message = ( + f'Successfully approved the request and created secure ' + f'directories for {self._request_obj.project.name}.') + self._success_messages.append(message) + + self._send_emails_safe() + + def _add_requester_to_directories(self): + """Create requests to add the requester to the newly-created + directories.""" + requester = self._request_obj.requester + + for secure_directory in ( + self._groups_directory, self._scratch_directory): + + runner = SecureDirectoryAddUserRequestRunner( + secure_directory, requester, + email_strategy=self._email_strategy) + runner.run() + + def _approve_request(self): + """Set the status of the request to 'Approved - Complete'.""" + self._request_obj.status = \ + SecureDirRequestStatusChoice.objects.get(name='Approved - Complete') + self._request_obj.completion_time = utc_now_offset_aware() + self._request_obj.save() + + def _create_secure_directories(self): + """Create Allocations representing the groups and scratch secure + directories. Store corresponding SecureDirectory objects in the + instance.""" + subdirectory_name = self._request_obj.directory_name + groups_allocation = create_secure_directory( + self._request_obj.project, subdirectory_name, 'groups') + scratch_allocation = create_secure_directory( + self._request_obj.project, subdirectory_name, 'scratch') + + self._groups_directory = SecureDirectory(groups_allocation) + self._scratch_directory = SecureDirectory(scratch_allocation) + + def _send_emails_to_users(self): + """Send notification emails to the requester, CCing relevant PIs + on the project.""" + if not settings.EMAIL_ENABLED: + return + + requester = self._request_obj.requester + + groups_dir_path = self._groups_directory.get_path() + scratch_dir_path = self._scratch_directory.get_path() + + context = { + 'user_first_name': requester.first_name, + 'user_last_name': requester.last_name, + 'project': self._request_obj.project.name, + 'groups_dir_path': groups_dir_path, + 'scratch_dir_path': scratch_dir_path, + 'signature': settings.EMAIL_SIGNATURE, + 'support_email': settings.CENTER_HELP_EMAIL, + } + + subject = 'Secure Directory Request Approved' + template_name = ( + 'email/secure_dir_request/secure_dir_request_approved.txt') + sender = settings.EMAIL_SENDER + receiver_list = [requester.email] + + kwargs = {} + pis_to_cc = [ + project_user.user + for project_user in self._request_obj.project.pis_to_email() + if project_user.user != requester] + if pis_to_cc: + kwargs['cc'] = [user.email for user in pis_to_cc] + + send_email_template( + subject, template_name, context, sender, receiver_list, **kwargs) + + def _send_emails(self): + """Send email notifications.""" + # To users + email_method = self._send_emails_to_users + email_args = () + self._email_strategy.process_email(email_method, *email_args) + + def _send_emails_safe(self): + """Send emails. Catch and log exceptions.""" + try: + self._send_emails() + except Exception as e: + # TODO: Some email strategies do not send the email directly, so + # this failure message would not be apt. + # TODO: The language in this message and in the function names + # should be updated to something more general (i.e., notifying + # users). + logger.exception( + f'Failed to send notification emails. Details:\n{e}') + + def _should_add_requester_to_directories(self): + """Return whether requests should be made to add the requester + to the newly-created secure directories. + + The requester must meet the following criteria: + - Has cluster access under any project + """ + return has_cluster_access(self._request_obj.requester) + + +def get_secure_dir_allocations(project=None): + """Returns a queryset of all active secure directory allocations. + Optionally, return those for a specific project.""" + scratch_directory = Resource.objects.get(name='Scratch P2/P3 Directory') + groups_directory = Resource.objects.get(name='Groups P2/P3 Directory') + + kwargs = { + 'resources__in': [scratch_directory, groups_directory], + 'status__name': 'Active', + } + if project is not None: + assert isinstance(project, Project) + kwargs['project'] = project + + return Allocation.objects.filter(**kwargs) + + +def get_default_secure_dir_paths(): + """Returns the default Groups and Scratch secure directory paths.""" + + groups_path = \ + ResourceAttribute.objects.get( + resource_attribute_type__name='path', + resource__name='Groups P2/P3 Directory').value + scratch_path = \ + ResourceAttribute.objects.get( + resource_attribute_type__name='path', + resource__name='Scratch P2/P3 Directory').value + + return groups_path, scratch_path + + +def is_project_eligible_for_secure_dirs(project): + """Return whether the given Project is eligible to request a secure + directory. The following criteria are considered: + - Is active; + - Has a Condo, FCA, or ICA computing allowance; + - Does not already have secure directories; + - Does not have a non-"Denied" request for secure directories. + """ + assert isinstance(project, Project) + + # Is active + active_project_status = ProjectStatusChoice.objects.get(name='Active') + if project.status != active_project_status: + return False + + # Has a Condo, FCA, or ICA computing allowance + eligible_computing_allowance_names = { + BRCAllowances.CO, + BRCAllowances.FCA, + BRCAllowances.ICA, + } + computing_allowance_interface = ComputingAllowanceInterface() + try: + computing_allowance = \ + computing_allowance_interface.allowance_from_project(project) + except ComputingAllowanceInterfaceError: + # Non-primary-cluster projects (ineligible) raise this error. + return False + if computing_allowance.name not in eligible_computing_allowance_names: + return False + + eligible_project_prefixes = tuple( + computing_allowance_interface.code_from_name(computing_allowance_name) + for computing_allowance_name in eligible_computing_allowance_names) + if not project.name.startswith(eligible_project_prefixes): + return False + + # Does not already have secure directories + if get_secure_dir_allocations(project=project).exists(): + return False + + # Does not have a non-"Denied" request for secure directories + denied_request_status = SecureDirRequestStatusChoice.objects.get( + name='Denied') + non_denied_requests = SecureDirRequest.objects.filter( + Q(project=project) & ~Q(status=denied_request_status)) + if non_denied_requests.exists(): + return False + + return True + + +def get_all_secure_dir_paths(): + """Returns a set of all secure directory paths.""" + + group_resource = Resource.objects.get(name='Groups P2/P3 Directory') + scratch_resource = Resource.objects.get(name='Scratch P2/P3 Directory') + + paths = \ + set(AllocationAttribute.objects.filter( + allocation_attribute_type__name='Cluster Directory Access', + allocation__resources__in=[scratch_resource, group_resource]). + values_list('value', flat=True)) + + return paths + + +def is_secure_directory_name_suffix_available(proposed_directory_name_suffix, + exclude_request_pk=None): + """Returns True if the proposed secure directory name suffix is + available and False otherwise. A name suffix is available if it is + neither in use by an existing secure directory nor in use by a + pending request for a new secure directory, with the possible + exception of the request with the given primary key from which it + came. + + Parameters: + - proposed_directory_name_suffix (str): The name of the proposed + directory, without SECURE_DIRECTORY_NAME_PREFIX + - exclude_request_pk (int): The primary key of a SecureDirRequest + object to exclude + + Returns: + - bool: True if the proposed directory name suffix is available, + False otherwise + """ + + def get_directory_name_suffix(_directory_name): + if _directory_name.startswith(SECURE_DIRECTORY_NAME_PREFIX): + _directory_name = _directory_name[ + len(SECURE_DIRECTORY_NAME_PREFIX):] + return _directory_name + + assert not proposed_directory_name_suffix.startswith( + SECURE_DIRECTORY_NAME_PREFIX) + + unavailable_name_suffixes = set() + existing_secure_directory_paths = get_all_secure_dir_paths() + for directory_path in existing_secure_directory_paths: + directory_name = os.path.basename(directory_path) + directory_name_suffix = get_directory_name_suffix(directory_name) + unavailable_name_suffixes.add(directory_name_suffix) + pending_requested_directory_names = list( + SecureDirRequest.objects + .exclude(status__name='Denied') + .exclude(pk=exclude_request_pk) + .values_list('directory_name', flat=True)) + for directory_name in pending_requested_directory_names: + directory_name_suffix = get_directory_name_suffix(directory_name) + unavailable_name_suffixes.add(directory_name_suffix) + + return proposed_directory_name_suffix not in unavailable_name_suffixes + + +def set_sec_dir_context(context_dict, request_obj): + """ + Sets the sec_dir_request, groups_path, and scratch_path items in the given + context dictionary. + + Parameters: + - context_dir (dict): the dictionary in which values are being set + - request_obj (SecureDirRequest): the relevant SecureDirRequest object + + Raises: + - TypeError, if 'context_dir' is not a dictionary + - TypeError, if 'request_obj' is not a SecureDirRequest + """ + + if not isinstance(context_dict, dict): + raise TypeError(f'Passed context_dict {context_dict} is not a dict.') + if not isinstance(request_obj, SecureDirRequest): + raise TypeError(f'Invalid SecureDirRequest {request_obj}.') + + context_dict['secure_dir_request'] = request_obj + context_dict['proposed_directory_name'] = request_obj.directory_name + groups_path, scratch_path = get_default_secure_dir_paths() + context_dict['proposed_groups_path'] = \ + os.path.join(groups_path, context_dict['proposed_directory_name']) + context_dict['proposed_scratch_path'] = \ + os.path.join(scratch_path, context_dict['proposed_directory_name']) diff --git a/coldfront/core/allocation/utils_/secure_dir_utils/user_management.py b/coldfront/core/allocation/utils_/secure_dir_utils/user_management.py new file mode 100644 index 000000000..56e8e1d64 --- /dev/null +++ b/coldfront/core/allocation/utils_/secure_dir_utils/user_management.py @@ -0,0 +1,207 @@ +import logging + +from abc import ABC +from abc import abstractmethod + +from django.conf import settings +from django.contrib.auth.models import User +from django.db import transaction +from django.urls import reverse + +from coldfront.core.allocation.models import SecureDirAddUserRequest +from coldfront.core.allocation.models import SecureDirAddUserRequestStatusChoice +from coldfront.core.allocation.models import SecureDirRemoveUserRequest +from coldfront.core.allocation.models import SecureDirRemoveUserRequestStatusChoice +from coldfront.core.allocation.utils_.secure_dir_utils import SecureDirectory + +from coldfront.core.utils.email.email_strategy import validate_email_strategy_or_get_default +from coldfront.core.utils.mail import send_email_template + + +logger = logging.getLogger(__name__) + + +def get_secure_dir_manage_user_request_objects(self, action): + """ + Sets attributes pertaining to a secure directory based on the + action being performed. + + Parameters: + - self (object): object to set attributes for + - action (str): the action being performed, either 'add' or 'remove' + + Raises: + - TypeError, if the 'self' object is not an object + - ValueError, if action is not one of 'add' or 'remove' + """ + + action = action.lower() + if not isinstance(self, object): + raise TypeError(f'Invalid self {self}.') + if action not in ['add', 'remove']: + raise ValueError(f'Invalid action {action}.') + + add_bool = action == 'add' + + request_obj = SecureDirAddUserRequest \ + if add_bool else SecureDirRemoveUserRequest + request_status_obj = SecureDirAddUserRequestStatusChoice \ + if add_bool else SecureDirRemoveUserRequestStatusChoice + + language_dict = { + 'preposition': 'to' if add_bool else 'from', + 'noun': 'addition' if add_bool else 'removal', + 'verb': 'add' if add_bool else 'remove' + } + + setattr(self, 'action', action.lower()) + setattr(self, 'add_bool', add_bool) + setattr(self, 'request_obj', request_obj) + setattr(self, 'request_status_obj', request_status_obj) + setattr(self, 'language_dict', language_dict) + + +class SecureDirectoryManageUserRequestRunner(ABC): + """An abstract class that performs processing when a user is + requested to be added to or removed from a secure directory.""" + + # TODO: Add success_messages and error_messages? + + request_model = None + request_status_model = None + + @abstractmethod + def __init__(self, secure_directory, user, email_strategy=None): + assert isinstance(secure_directory, SecureDirectory) + assert isinstance(user, User) + self._secure_directory = secure_directory + self._user = user + self._request_obj = None + self._email_strategy = validate_email_strategy_or_get_default( + email_strategy=email_strategy) + + def run(self): + """Create a request object and send notification emails.""" + with transaction.atomic(): + self._request_obj = self._create_request_obj() + + self._send_emails_safe() + + def _create_request_obj(self): + """Create a request object from provided arguments.""" + pending_status = self.request_status_model.objects.get( + name__icontains='Pending') + directory_path = self._secure_directory.get_path() + return self.request_model.objects.create( + user=self._user, + allocation=self._secure_directory._allocation_obj, + status=pending_status, + directory=directory_path) + + @abstractmethod + def _send_email_to_admins(self): + """Send an email notification to cluster admins, notifying them + of the newly-created request.""" + pass + + def _send_emails(self): + """Send email notifications.""" + # To cluster admins + email_method = self._send_email_to_admins + email_args = () + self._email_strategy.process_email(email_method, *email_args) + + def _send_emails_safe(self): + """Send emails. + + Catch all exceptions to prevent rolling back any enclosing + transaction. + """ + try: + self._send_emails() + except Exception as e: + message = ( + f'Encountered unexpected exception when sending notification ' + f'emails. Details:\n{e}') + logger.exception(message) + + +class SecureDirectoryAddUserRequestRunner(SecureDirectoryManageUserRequestRunner): + """A concrete class that performs processing when a user is + requested to be added to a secure directory.""" + + request_model = SecureDirAddUserRequest + request_status_model = SecureDirAddUserRequestStatusChoice + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def _send_email_to_admins(self): + user_str = ( + f'{self._user.first_name} {self._user.last_name} ' + f'({self._user.email})') + review_url = reverse( + 'secure-dir-manage-users-request-list', + kwargs={'action': 'add', 'status': 'pending'}) + context = { + 'user_str': user_str, + 'directory_name': self._secure_directory.get_path(), + 'review_url': review_url, + } + subject = 'New Secure Directory Add User Request' + template_name = ( + 'email/secure_dir_request/new_secure_dir_add_user_request.txt') + sender = settings.EMAIL_SENDER + recipients = settings.EMAIL_ADMIN_LIST + + send_email_template(subject, template_name, context, sender, recipients) + + +class SecureDirectoryRemoveUserRequestRunner(SecureDirectoryManageUserRequestRunner): + """A concrete class that performs processing when a user is + requested to be removed from a secure directory.""" + + request_model = SecureDirRemoveUserRequest + request_status_model = SecureDirRemoveUserRequestStatusChoice + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def _send_email_to_admins(self): + user_str = ( + f'{self._user.first_name} {self._user.last_name} ' + f'({self._user.email})') + review_url = reverse( + 'secure-dir-manage-users-request-list', + kwargs={'action': 'remove', 'status': 'pending'}) + context = { + 'user_str': user_str, + 'directory_name': self._secure_directory.get_path(), + 'review_url': review_url, + } + subject = 'New Secure Directory Remove User Request' + template_name = ( + 'email/secure_dir_request/new_secure_dir_remove_user_request.txt') + sender = settings.EMAIL_SENDER + recipients = settings.EMAIL_ADMIN_LIST + send_email_template(subject, template_name, context, sender, recipients) + + +class SecureDirectoryManageUserRequestRunnerFactory(object): + """A factory for returning a class that performs processing when a + user is requested to be added to or removed from a secure + directory.""" + + def get_runner(self, action, *args, **kwargs): + """Return an instantiated runner for the given action with the + given arguments and keyword arguments.""" + return self._get_runner_class(action)(*args, **kwargs) + + @staticmethod + def _get_runner_class(action): + if action == 'add': + return SecureDirectoryAddUserRequestRunner + elif action == 'remove': + return SecureDirectoryRemoveUserRequestRunner + else: + raise ValueError(f'Invalid action {action}.') diff --git a/coldfront/core/allocation/views.py b/coldfront/core/allocation/views.py index 63f3b4431..53da2b6e7 100644 --- a/coldfront/core/allocation/views.py +++ b/coldfront/core/allocation/views.py @@ -42,6 +42,7 @@ from coldfront.core.allocation.utils import (generate_guauge_data_from_usage, get_user_resources, generate_user_su_pie_data) +from coldfront.core.allocation.utils_.secure_dir_utils import SecureDirectory from coldfront.core.billing.models import BillingActivity from coldfront.core.project.models import (Project, ProjectUser, ProjectUserStatusChoice) @@ -98,14 +99,11 @@ def test_func(self): pk = self.kwargs.get('pk') allocation_obj = get_object_or_404(Allocation, pk=pk) + secure_directory = SecureDirectory(allocation_obj) - is_pi = allocation_obj.project.projectuser_set.filter( - user=self.request.user, - role__name='Principal Investigator', - status__name='Active').exists() - - if is_pi: - return True + if self._is_secure_dir_allocation(allocation_obj): + if secure_directory.user_can_manage(self.request.user): + return True user_can_access_project = allocation_obj.project.projectuser_set.filter( user=self.request.user, status__name__in=['Active', 'New', ]).exists() @@ -122,53 +120,6 @@ def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) pk = self.kwargs.get('pk') allocation_obj = get_object_or_404(Allocation, pk=pk) - allocation_users = allocation_obj.allocationuser_set.order_by('user__username') - - # Manually display "Service Units" for each user if applicable. - # TODO: Avoid doing this manually. - kwargs = { - 'allocation_attribute_type__name__in': [ - 'Service Units', 'Billing Activity'], - } - has_service_units = allocation_obj.allocationattribute_set.filter( - **kwargs) - allocation_user_su_usages = {} - allocation_user_billing_ids = {} - for allocation_user in allocation_users: - username = allocation_user.user.username - user_attributes = \ - allocation_user.allocationuserattribute_set.select_related( - 'allocationuserattributeusage' - ).filter(**kwargs) - try: - su_attribute = user_attributes.filter( - allocation_attribute_type__name='Service Units').first() - usage = str(su_attribute.allocationuserattributeusage.value) - except (AllocationUserAttribute.DoesNotExist, - AttributeError, - ValueError): - usage = '0.00' - allocation_user_su_usages[username] = usage - if not flag_enabled('LRC_ONLY'): - continue - try: - billing_attribute = user_attributes.filter( - allocation_attribute_type__name='Billing Activity').first() - billing_id = BillingActivity.objects.get( - pk=int(billing_attribute.value)).full_id() - except (AllocationUserAttribute.DoesNotExist, - AttributeError, - BillingActivity.DoesNotExist, - ValueError): - billing_id = 'N/A' - allocation_user_billing_ids[username] = billing_id - - context['has_service_units'] = has_service_units - context['allocation_user_su_usages'] = allocation_user_su_usages - if flag_enabled('LRC_ONLY'): - context['allocation_user_billing_ids'] = \ - allocation_user_billing_ids - if self.request.user.is_superuser: attributes_with_usage = [attribute for attribute in allocation_obj.allocationattribute_set.all( ).order_by('allocation_attribute_type__name') if hasattr(attribute, 'allocationattributeusage')] @@ -182,26 +133,6 @@ def get_context_data(self, **kwargs): attributes = [attribute for attribute in allocation_obj.allocationattribute_set.filter( allocation_attribute_type__is_private=False)] - - # Annotate each attribute with a display value. - filtered_attributes = [] - for attribute in attributes: - is_billing_activity = ( - attribute.allocation_attribute_type.name == 'Billing Activity') - if is_billing_activity: - attribute.display_name = 'Billing ID' - try: - attribute.display_value = BillingActivity.objects.get( - pk=int(attribute.value)).full_id() - except (BillingActivity.DoesNotExist, ValueError): - attribute.display_value = attribute.value - if flag_enabled('LRC_ONLY'): - filtered_attributes.append(attribute) - else: - attribute.display_name = str(attribute) - attribute.display_value = attribute.value - filtered_attributes.append(attribute) - guage_data = [] invalid_attributes = [] for attribute in attributes_with_usage: @@ -230,7 +161,6 @@ def get_context_data(self, **kwargs): context['guage_data'] = guage_data context['attributes_with_usage'] = attributes_with_usage - context['attributes'] = filtered_attributes # Can the user update the project? if self.request.user.is_superuser: @@ -245,14 +175,6 @@ def get_context_data(self, **kwargs): else: context['is_allowed_to_update_project'] = False - # Filter users by whether they have been removed from the allocation. - allocation_user_status_choice_removed = \ - AllocationUserStatusChoice.objects.get(name='Removed') - context['allocation_users'] = \ - allocation_users.exclude(status=allocation_user_status_choice_removed) - context['allocation_users_removed_from_proj'] = \ - allocation_users.filter(status=allocation_user_status_choice_removed) - if self.request.user.is_superuser: notes = allocation_obj.allocationusernote_set.all() else: @@ -262,24 +184,7 @@ def get_context_data(self, **kwargs): context['notes'] = notes context['ALLOCATION_ENABLE_ALLOCATION_RENEWAL'] = ALLOCATION_ENABLE_ALLOCATION_RENEWAL - context['secure_dir'] = \ - allocation_obj.resources.filter( - name__icontains='Directory').exists() - - can_edit_users = False - if allocation_obj.project.projectuser_set.filter( - user=self.request.user, - role__name='Principal Investigator', - status__name='Active').exists(): - can_edit_users = True - - if self.request.user.is_superuser: - can_edit_users = True - - context['can_edit_users'] = can_edit_users - - pie_data = generate_user_su_pie_data(allocation_user_su_usages.items()) - context['pie_data'] = pie_data + self._update_context(allocation_obj, attributes, context) return context @@ -422,6 +327,127 @@ def post(self, request, *args, **kwargs): return render(request, self.template_name, context) + def _update_context(self, allocation_obj, attributes, context): + """Update the given context (to be passed to the template), in + part based on the type of the Allocation.""" + # Annotate each attribute with a display value. + filtered_attributes = [] + for attribute in attributes: + is_billing_activity = ( + attribute.allocation_attribute_type.name == 'Billing Activity') + if is_billing_activity: + attribute.display_name = 'Billing ID' + try: + attribute.display_value = BillingActivity.objects.get( + pk=int(attribute.value)).full_id() + except (BillingActivity.DoesNotExist, ValueError): + attribute.display_value = attribute.value + if flag_enabled('LRC_ONLY'): + filtered_attributes.append(attribute) + else: + attribute.display_name = str(attribute) + attribute.display_value = attribute.value + filtered_attributes.append(attribute) + context['attributes'] = filtered_attributes + + # Only display non-removed users in the table of users. + allocation_users = allocation_obj.allocationuser_set.order_by( + 'user__username') + context['allocation_users'] = allocation_users.exclude( + status__name='Removed') + + # Add additional context for compute allocations. + if self._is_compute_allocation(allocation_obj): + self._add_compute_specific_context( + allocation_obj, allocation_users, context) + + # Add additional context for secure directory allocations. + if self._is_secure_dir_allocation(allocation_obj): + self._add_secure_dir_specific_context(allocation_obj, context) + + @staticmethod + def _add_compute_specific_context(allocation_obj, allocation_users, + context): + """Update the given context, given that the Allocation is a + compute allocation.""" + # Display service units usage for each AllocationUser in the table. + context['allocation_user_usages_visible'] = True + service_units_filter = { + 'allocation_attribute_type__name': 'Service Units', + } + allocation_user_su_usages = {} + for allocation_user in allocation_users: + attributes = \ + allocation_user.allocationuserattribute_set.select_related( + 'allocationuserattributeusage' + ).filter(**service_units_filter) + try: + su_attribute = attributes.first() + usage = str(su_attribute.allocationuserattributeusage.value) + except (AllocationUserAttribute.DoesNotExist, + AttributeError, + ValueError): + usage = '0.00' + allocation_user_su_usages[allocation_user.user.username] = usage + context['allocation_user_su_usages'] = allocation_user_su_usages + + pie_data = generate_user_su_pie_data(allocation_user_su_usages.items()) + context['pie_data'] = pie_data + + # For LRC deployments, display the billing ID associated with each user. + # TODO: Consider only displaying this for Recharge projects. + context['allocation_user_billing_ids_visible'] = flag_enabled( + 'LRC_ONLY') + if context['allocation_user_billing_ids_visible']: + billing_activity_filter = { + 'allocation_attribute_type__name': 'Billing Activity' + } + allocation_user_billing_ids = {} + for allocation_user in allocation_users: + attributes = allocation_obj.allocationattribute_set.filter( + **billing_activity_filter) + try: + billing_attribute = attributes.first() + billing_id = BillingActivity.objects.get( + pk=int(billing_attribute.value)).full_id() + except (AllocationUserAttribute.DoesNotExist, + AttributeError, + BillingActivity.DoesNotExist, + ValueError): + billing_id = 'N/A' + allocation_user_billing_ids[allocation_user.user.username] = \ + billing_id + context['allocation_user_billing_ids'] = allocation_user_billing_ids + + # Display a separate table of removed users. + context['removed_users_visible'] = True + context['allocation_users_removed_from_proj'] = allocation_users.filter( + status__name='Removed') + + def _add_secure_dir_specific_context(self, allocation_obj, context): + """Update the given context, given that the Allocation is a + secure directory allocation.""" + secure_directory = SecureDirectory(allocation_obj) + # Allow users to be added/removed by privileged users. + add_remove_users_buttons_visible = secure_directory.user_can_manage( + self.request.user) + context['add_remove_users_buttons_visible'] = \ + add_remove_users_buttons_visible + + @staticmethod + def _is_compute_allocation(allocation_obj): + """Return whether the Allocation represents access to compute + resources.""" + return allocation_obj.resources.filter( + name__endswith=' Compute').exists() + + @staticmethod + def _is_secure_dir_allocation(allocation_obj): + """Return whether the Allocation represents access to a secure + directory.""" + return allocation_obj.resources.filter( + name__endswith=' P2/P3 Directory').exists() + class AllocationListView(LoginRequiredMixin, UserPassesTestMixin, ListView): diff --git a/coldfront/core/allocation/views_/__init__.py b/coldfront/core/allocation/views_/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/coldfront/core/allocation/views_/secure_dir_views.py b/coldfront/core/allocation/views_/secure_dir_views.py deleted file mode 100644 index 7f28991dd..000000000 --- a/coldfront/core/allocation/views_/secure_dir_views.py +++ /dev/null @@ -1,1795 +0,0 @@ -import iso8601 -import logging - -from urllib.parse import urljoin - -from django.conf import settings -from django.contrib import messages -from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin -from django.contrib.auth.models import User -from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger -from django.db.models import Q -from django.forms import formset_factory -from django.http import HttpResponseRedirect -from django.shortcuts import get_object_or_404, render -from django.urls import reverse, reverse_lazy -from django.views.generic import ListView, FormView, DetailView -from django.views.generic.base import TemplateView, View -from coldfront.core.utils.views.mou_views import MOURequestNotifyPIViewMixIn -from formtools.wizard.views import SessionWizardView - -from coldfront.core.allocation.forms_.secure_dir_forms import ( - SecureDirManageUsersForm, - SecureDirManageUsersSearchForm, - SecureDirManageUsersRequestUpdateStatusForm, - SecureDirManageUsersRequestCompletionForm, SecureDirDataDescriptionForm, - SecureDirRDMConsultationForm, SecureDirDirectoryNamesForm, - SecureDirSetupForm, SecureDirRDMConsultationReviewForm, - SecureDirRequestEditDepartmentForm) -from coldfront.core.allocation.models import (Allocation, - SecureDirAddUserRequest, - SecureDirRemoveUserRequest, - AllocationUserStatusChoice, - AllocationUser, - SecureDirRequestStatusChoice, - SecureDirRequest) -from coldfront.core.allocation.utils import has_cluster_access -from coldfront.core.allocation.utils_.secure_dir_utils import \ - get_secure_dir_manage_user_request_objects, secure_dir_request_state_status, \ - SecureDirRequestDenialRunner, SecureDirRequestApprovalRunner, \ - get_secure_dir_allocations, get_default_secure_dir_paths, \ - pi_eligible_to_request_secure_dir, SECURE_DIRECTORY_NAME_PREFIX, \ - set_sec_dir_context -from coldfront.core.project.forms import ReviewStatusForm, ReviewDenyForm -from coldfront.core.project.models import ProjectUser, Project -from coldfront.core.user.utils import access_agreement_signed -from coldfront.core.utils.common import utc_now_offset_aware, \ - session_wizard_all_form_data -from coldfront.core.utils.mail import send_email_template - - -logger = logging.getLogger(__name__) - - -class SecureDirManageUsersView(LoginRequiredMixin, - UserPassesTestMixin, - TemplateView): - template_name = 'secure_dir/secure_dir_manage_users.html' - - def test_func(self): - """ UserPassesTestMixin Tests""" - if self.request.user.is_superuser: - return True - - alloc_obj = get_object_or_404(Allocation, pk=self.kwargs.get('pk')) - - if alloc_obj.project.projectuser_set.filter( - user=self.request.user, - role__name='Principal Investigator', - status__name='Active').exists(): - return True - - def dispatch(self, request, *args, **kwargs): - alloc_obj = get_object_or_404(Allocation, pk=self.kwargs.get('pk')) - get_secure_dir_manage_user_request_objects(self, - self.kwargs.get('action')) - self.directory = \ - alloc_obj.allocationattribute_set.get( - allocation_attribute_type__name='Cluster Directory Access').value - - if alloc_obj.status.name not in ['Active', 'New', ]: - messages.error( - request, f'You can only {self.language_dict["verb"]} users ' - f'{self.language_dict["preposition"]} an ' - f'active allocation.') - return HttpResponseRedirect( - reverse('allocation-detail', kwargs={'pk': alloc_obj.pk})) - else: - return super().dispatch(request, *args, **kwargs) - - def get_users_to_add(self, alloc_obj): - # Users in any projects that the PI runs should be available to add. - alloc_pis = [proj_user.user for proj_user in - alloc_obj.project.projectuser_set.filter( - Q(role__name__in=['Manager', - 'Principal Investigator']) & - Q(status__name='Active'))] - - projects = [proj_user.project for proj_user in - ProjectUser.objects.filter( - Q(role__name__in=['Manager', - 'Principal Investigator']) & - Q(status__name='Active') & - Q(user__in=alloc_pis))] - - # Users must have active cluster access to be added. - users_to_add = set([proj_user.user for proj_user in - ProjectUser.objects.filter(project__in=projects, - status__name='Active') - if has_cluster_access(proj_user.user)]) - - # Excluding active users that are already part of the allocation. - users_to_exclude = set(alloc_user.user for alloc_user in - alloc_obj.allocationuser_set.filter( - status__name='Active')) - - # Excluding users that have active join requests. - users_to_exclude |= \ - set(request.user for request in - SecureDirAddUserRequest.objects.filter( - allocation=alloc_obj, - status__name__in=['Pending', - 'Processing'])) - - # Excluding users that have active removal requests. - users_to_exclude |= \ - set(request.user for request in - SecureDirRemoveUserRequest.objects.filter( - allocation=alloc_obj, - status__name__in=['Pending', - 'Processing'])) - - users_to_add -= users_to_exclude - - user_data_list = [] - for user in users_to_add: - user_data = { - 'username': user.username, - 'first_name': user.first_name, - 'last_name': user.last_name, - 'email': user.email - } - user_data_list.append(user_data) - - return user_data_list - - def get_users_to_remove(self, alloc_obj): - users_to_remove = set(alloc_user.user for alloc_user in - alloc_obj.allocationuser_set.filter( - status__name='Active')) - - # Exclude users that have active removal requests. - users_to_remove -= set(request.user for request in - SecureDirRemoveUserRequest.objects.filter( - allocation=alloc_obj, - status__name__in=['Pending', - 'Processing'])) - - # PIs cannot request to remove themselves from their - # own secure directories. - users_to_remove -= set(proj_user.user for proj_user in - alloc_obj.project.projectuser_set.filter( - role__name='Principal Investigator', - status__name='Active')) - - user_data_list = [] - for user in users_to_remove: - user_data = { - 'username': user.username, - 'first_name': user.first_name, - 'last_name': user.last_name, - 'email': user.email - } - user_data_list.append(user_data) - - return user_data_list - - def get(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') - alloc_obj = get_object_or_404(Allocation, pk=pk) - - if self.add_bool: - user_list = self.get_users_to_add(alloc_obj) - else: - user_list = self.get_users_to_remove(alloc_obj) - - context = {} - - if user_list: - formset = formset_factory( - SecureDirManageUsersForm, max_num=len(user_list)) - formset = formset(initial=user_list, prefix='userform') - context['formset'] = formset - - context['allocation'] = alloc_obj - - context['can_manage_users'] = False - if self.request.user.is_superuser: - context['can_manage_users'] = True - - if alloc_obj.project.projectuser_set.filter( - user=self.request.user, - role__name='Principal Investigator', - status__name='Active').exists(): - context['can_manage_users'] = True - - context['directory'] = self.directory - - context['action'] = self.action - context['url'] = f'secure-dir-manage-users' - - context['button'] = 'btn-success' if self.add_bool else 'btn-danger' - - context['preposition'] = self.language_dict['preposition'] - - return render(request, self.template_name, context) - - def post(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') - alloc_obj = get_object_or_404(Allocation, pk=pk) - - allowed_to_manage_users = False - if alloc_obj.project.projectuser_set.filter( - user=self.request.user, - role__name='Principal Investigator', - status__name='Active').exists(): - allowed_to_manage_users = True - - if self.request.user.is_superuser: - allowed_to_manage_users = True - - if not allowed_to_manage_users: - message = 'You do not have permission to view the this page.' - messages.error(request, message) - - return HttpResponseRedirect( - reverse('allocation-detail', kwargs={'pk': pk})) - - if self.add_bool: - user_list = self.get_users_to_add(alloc_obj) - else: - user_list = self.get_users_to_remove(alloc_obj) - - formset = formset_factory( - SecureDirManageUsersForm, max_num=len(user_list)) - formset = formset( - request.POST, initial=user_list, prefix='userform') - - reviewed_users_count = 0 - if formset.is_valid(): - pending_status = \ - self.request_status_obj.objects.get(name__icontains='Pending') - - for form in formset: - user_form_data = form.cleaned_data - if user_form_data['selected']: - reviewed_users_count += 1 - user_obj = User.objects.get( - username=user_form_data.get('username')) - - # Create the request object - self.request_obj.objects.create( - user=user_obj, - allocation=alloc_obj, - status=pending_status, - directory=self.directory - ) - - # Email admins that there are new request(s) - if settings.EMAIL_ENABLED: - context = { - 'noun': self.language_dict['noun'], - 'verb': 'are' if reviewed_users_count > 1 else 'is', - 'plural': 's' if reviewed_users_count > 1 else '', - 'determiner': 'these' if reviewed_users_count > 1 else 'this', - 'num_requests': reviewed_users_count, - 'project_name': alloc_obj.project.name, - 'directory_name': self.directory, - 'review_url': 'secure-dir-manage-users-request-list', - 'action': self.action - } - - try: - subject = f'Pending Secure Directory '\ - f'{self.language_dict["noun"]} Requests' - plain_template = 'email/secure_dir_request/'\ - 'pending_secure_dir_manage_' \ - 'user_requests.txt' - html_template = 'email/secure_dir_request/' \ - 'pending_secure_dir_manage_' \ - 'user_requests.html' - send_email_template(subject, - plain_template, - context, - settings.EMAIL_SENDER, - settings.EMAIL_ADMIN_LIST, - html_template=html_template) - - except Exception as e: - message = f'Failed to send notification email.' - messages.error(request, message) - logger.error(message) - logger.exception(e) - - message = ( - f'Successfully requested to {self.action} ' - f'{reviewed_users_count} user' - f'{"s" if reviewed_users_count > 1 else ""} ' - f'{self.language_dict["preposition"]} the secure directory ' - f'{self.directory}. {settings.PROGRAM_NAME_SHORT} staff have ' - f'been notified.') - messages.success(request, message) - - else: - for error in formset.errors: - messages.error(request, error) - - return HttpResponseRedirect( - reverse('allocation-detail', kwargs={'pk': pk})) - - -class SecureDirManageUsersRequestListView(LoginRequiredMixin, - UserPassesTestMixin, - ListView): - template_name = 'secure_dir/secure_dir_manage_user_request_list.html' - paginate_by = 30 - - def test_func(self): - """UserPassesTestMixin tests.""" - if self.request.user.is_superuser: - return True - - if self.request.user.has_perm( - 'allocation.view_securediradduserrequest') and \ - self.request.user.has_perm( - 'allocation.view_securedirremoveuserrequest'): - return True - - message = ( - f'You do not have permission to review secure directory ' - f'{self.action} user requests.') - messages.error(self.request, message) - - def dispatch(self, request, *args, **kwargs): - get_secure_dir_manage_user_request_objects(self, - self.kwargs.get('action')) - self.status = self.kwargs.get('status') - self.completed = self.status == 'completed' - return super().dispatch(request, *args, **kwargs) - - def get_queryset(self): - order_by = self.request.GET.get('order_by') - if order_by: - direction = self.request.GET.get('direction') - if direction == 'asc': - direction = '' - else: - direction = '-' - order_by = direction + order_by - else: - order_by = '-modified' - - pending_status = self.request_status_obj.objects.filter( - Q(name__icontains='Pending') | Q(name__icontains='Processing')) - - complete_status = self.request_status_obj.objects.filter( - name__in=['Complete', 'Denied']) - - secure_dir_request_search_form = \ - SecureDirManageUsersSearchForm(self.request.GET) - - if self.completed: - request_list = self.request_obj.objects.filter( - status__in=complete_status) - else: - request_list = self.request_obj.objects.filter( - status__in=pending_status) - - if secure_dir_request_search_form.is_valid(): - data = secure_dir_request_search_form.cleaned_data - - if data.get('username'): - request_list = request_list.filter( - user__username__icontains=data.get('username')) - - if data.get('email'): - request_list = request_list.filter( - user__email__icontains=data.get('email')) - - if data.get('project_name'): - request_list = \ - request_list.filter( - allocation__project__name__icontains=data.get( - 'project_name')) - - if data.get('directory_name'): - request_list = \ - request_list.filter( - directory__icontains=data.get( - 'directory_name')) - - return request_list.order_by(order_by) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - secure_dir_request_search_form = \ - SecureDirManageUsersSearchForm(self.request.GET) - if secure_dir_request_search_form.is_valid(): - data = secure_dir_request_search_form.cleaned_data - filter_parameters = '' - for key, value in data.items(): - if value: - if isinstance(value, list): - for ele in value: - filter_parameters += '{}={}&'.format(key, ele) - else: - filter_parameters += '{}={}&'.format(key, value) - context['secure_dir_request_search_form'] = \ - secure_dir_request_search_form - else: - filter_parameters = None - context['secure_dir_request_search_form'] = \ - SecureDirManageUsersSearchForm() - - order_by = self.request.GET.get('order_by') - if order_by: - direction = self.request.GET.get('direction') - filter_parameters_with_order_by = filter_parameters + \ - 'order_by=%s&direction=%s&' % ( - order_by, direction) - else: - filter_parameters_with_order_by = filter_parameters - - if filter_parameters: - context['expand_accordion'] = 'show' - else: - context['expand_accordion'] = 'toggle' - - context['filter_parameters'] = filter_parameters - context['filter_parameters_with_order_by'] = \ - filter_parameters_with_order_by - - context['request_filter'] = ( - 'completed' if self.completed else 'pending') - - request_list = self.get_queryset() - paginator = Paginator(request_list, self.paginate_by) - - page = self.request.GET.get('page') - - try: - request_list = paginator.page(page) - except PageNotAnInteger: - request_list = paginator.page(1) - except EmptyPage: - request_list = paginator.page(paginator.num_pages) - - context['request_list'] = request_list - - context['actions_visible'] = not self.completed - - context['action'] = self.action - - context['preposition'] = self.language_dict['preposition'] - - return context - - -class SecureDirManageUsersUpdateStatusView(LoginRequiredMixin, - UserPassesTestMixin, - FormView): - form_class = SecureDirManageUsersRequestUpdateStatusForm - template_name = \ - 'secure_dir/secure_dir_manage_user_request_update_status.html' - - def test_func(self): - """UserPassesTestMixin tests.""" - if self.request.user.is_superuser: - return True - - if self.request.user.has_perm( - 'allocation.change_securediradduserrequest') or \ - self.request.user.has_perm( - 'allocation.change_securedirremoveuserrequest'): - return True - - message = ( - 'You do not have permission to update secure directory ' - 'join or removal requests.') - messages.error(self.request, message) - - def dispatch(self, request, *args, **kwargs): - get_secure_dir_manage_user_request_objects(self, - self.kwargs.get('action')) - self.secure_dir_request = get_object_or_404( - self.request_obj, pk=self.kwargs.get('pk')) - - return super().dispatch(request, *args, **kwargs) - - def form_valid(self, form): - cur_status = self.secure_dir_request.status.name - if 'Pending' not in cur_status: - message = f'Secure directory user {self.language_dict["noun"]} ' \ - f'request has unexpected status "{cur_status}."' - messages.error(self.request, message) - return HttpResponseRedirect( - reverse('secure-dir-manage-users-request-list', - kwargs={'action': self.action, 'status': 'pending'})) - - form_data = form.cleaned_data - status = form_data.get('status') - - secure_dir_status_choice = \ - self.request_status_obj.objects.filter( - name__icontains=status).first() - self.secure_dir_request.status = secure_dir_status_choice - self.secure_dir_request.save() - - message = ( - f'Secure directory {self.language_dict["noun"]} request for user ' - f'{self.secure_dir_request.user.username} for ' - f'{self.secure_dir_request.directory} has been ' - f'marked as "{status}".') - messages.success(self.request, message) - - return super().form_valid(form) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['request'] = self.secure_dir_request - context['action'] = self.action - context['noun'] = self.language_dict['noun'] - context['step'] = 'pending' - return context - - def get_initial(self): - initial = { - 'status': self.secure_dir_request.status.name, - } - return initial - - def get_success_url(self): - return reverse('secure-dir-manage-users-request-list', - kwargs={'action': self.action, 'status': 'pending'}) - - -class SecureDirManageUsersCompleteStatusView(LoginRequiredMixin, - UserPassesTestMixin, - FormView): - form_class = SecureDirManageUsersRequestCompletionForm - template_name = \ - 'secure_dir/secure_dir_manage_user_request_update_status.html' - - def test_func(self): - """UserPassesTestMixin tests.""" - if self.request.user.is_superuser: - return True - - if self.request.user.has_perm( - 'allocation.change_securediradduserrequest') or \ - self.request.user.has_perm( - 'allocation.change_securedirremoveuserrequest'): - return True - - message = ( - 'You do not have permission to update secure directory ' - 'join or removal requests.') - messages.error(self.request, message) - - def dispatch(self, request, *args, **kwargs): - get_secure_dir_manage_user_request_objects(self, - self.kwargs.get('action')) - self.secure_dir_request = get_object_or_404( - self.request_obj, pk=self.kwargs.get('pk')) - return super().dispatch(request, *args, **kwargs) - - def form_valid(self, form): - cur_status = self.secure_dir_request.status.name - if 'Processing' not in cur_status: - message = f'Secure directory user {self.language_dict["noun"]} ' \ - f'request has unexpected status "{cur_status}."' - messages.error(self.request, message) - return HttpResponseRedirect( - reverse(f'secure-dir-manage-users-request-list', - kwargs={'action': self.action, 'status': 'pending'})) - - form_data = form.cleaned_data - status = form_data.get('status') - complete = 'Complete' in status - - secure_dir_status_choice = \ - self.request_status_obj.objects.filter( - name__icontains=status).first() - self.secure_dir_request.status = secure_dir_status_choice - if complete: - self.secure_dir_request.completion_time = utc_now_offset_aware() - self.secure_dir_request.save() - - if complete: - # Creates an allocation user with an active status is the request - # was an addition request. - alloc_user, created = \ - AllocationUser.objects.get_or_create( - allocation=self.secure_dir_request.allocation, - user=self.secure_dir_request.user, - status=AllocationUserStatusChoice.objects.get(name='Active') - ) - - # Sets the allocation user status to removed if the request - # was a removal request. - if not self.add_bool: - alloc_user.status = \ - AllocationUserStatusChoice.objects.get(name='Removed') - alloc_user.save() - - # Send notification email to PIs and the user that the - # request has been completed. - pis = self.secure_dir_request.allocation.project.projectuser_set.filter( - role__name='Principal Investigator', - status__name='Active', - enable_notifications=True) - users_to_notify = [x.user for x in pis] - users_to_notify.append(self.secure_dir_request.user) - - for user in users_to_notify: - try: - context = { - 'user_first_name': user.first_name, - 'user_last_name': user.last_name, - 'managed_user_first_name': - self.secure_dir_request.user.first_name, - 'managed_user_last_name': - self.secure_dir_request.user.last_name, - 'managed_user_username': - self.secure_dir_request.user.username, - 'verb': self.language_dict['verb'], - 'preposition': self.language_dict['preposition'], - 'directory': self.secure_dir_request.directory, - 'removed': 'now' if self.add_bool else 'no longer', - 'signature': settings.EMAIL_SIGNATURE, - 'support_email': settings.CENTER_HELP_EMAIL, - } - - send_email_template( - f'Secure Directory ' - f'{self.language_dict["noun"].title()} ' - f'Request Complete', - f'email/secure_dir_request/' - f'secure_dir_manage_user_request_complete.txt', - context, - settings.EMAIL_SENDER, - [user.email]) - - except Exception as e: - message = f'Failed to send notification email.' - messages.error(self.request, message) - logger.error(message) - logger.exception(e) - - message = ( - f'Secure directory {self.language_dict["noun"]} request for user ' - f'{self.secure_dir_request.user.username} for ' - f'{self.secure_dir_request.directory} has been marked ' - f'as "{status}".') - messages.success(self.request, message) - - return super().form_valid(form) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['request'] = self.secure_dir_request - context['action'] = self.action - context['noun'] = self.language_dict['noun'] - context['step'] = 'processing' - return context - - def get_initial(self): - initial = { - 'status': self.secure_dir_request.status.name, - } - return initial - - def get_success_url(self): - return reverse(f'secure-dir-manage-users-request-list', - kwargs={'action': self.action, 'status': 'pending'}) - - -class SecureDirManageUsersDenyRequestView(LoginRequiredMixin, - UserPassesTestMixin, - View): - def test_func(self): - """UserPassesTestMixin tests.""" - if self.request.user.is_superuser: - return True - - message = ( - 'You do not have permission to deny a secure directory join or ' - 'removal request.') - messages.error(self.request, message) - - def dispatch(self, request, *args, **kwargs): - get_secure_dir_manage_user_request_objects(self, - self.kwargs.get('action')) - self.secure_dir_request = get_object_or_404( - self.request_obj, pk=self.kwargs.get('pk')) - return super().dispatch(request, *args, **kwargs) - - def post(self, request, *args, **kwargs): - status = self.secure_dir_request.status.name - if ('Processing' not in status) and ('Pending' not in status): - message = f'Secure directory user {self.language_dict["noun"]} ' \ - f'request has unexpected status "{status}."' - messages.error(request, message) - return HttpResponseRedirect( - reverse(f'secure-dir-manage-users-request-list', - kwargs={'action': self.action, 'status': 'pending'})) - - reason = self.request.POST['reason'] - self.secure_dir_request.status = \ - self.request_status_obj.objects.get(name='Denied') - self.secure_dir_request.completion_time = utc_now_offset_aware() - self.secure_dir_request.save() - - message = ( - f'Secure directory {self.language_dict["noun"]} request for user ' - f'{self.secure_dir_request.user.username} for the secure directory ' - f'{self.secure_dir_request.directory} has been ' - f'denied.') - messages.success(request, message) - - if settings.EMAIL_ENABLED: - # Send notification email to PIs and the user that the - # request has been denied. - pis = \ - self.secure_dir_request.allocation.project. \ - projectuser_set.filter( - role__name='Principal Investigator', - status__name='Active', - enable_notifications=True) - users_to_notify = [x.user for x in pis] - users_to_notify.append(self.secure_dir_request.user) - - for user in users_to_notify: - try: - context = { - 'user_first_name': user.first_name, - 'user_last_name': user.last_name, - 'managed_user_first_name': - self.secure_dir_request.user.first_name, - 'managed_user_last_name': - self.secure_dir_request.user.last_name, - 'managed_user_username': - self.secure_dir_request.user.username, - 'verb': self.language_dict['verb'], - 'preposition': self.language_dict['preposition'], - 'directory': self.secure_dir_request.directory, - 'reason': reason, - 'signature': settings.EMAIL_SIGNATURE, - 'support_email': settings.CENTER_HELP_EMAIL, - } - - send_email_template( - f'Secure Directory ' - f'{self.language_dict["noun"].title()} Request Denied', - f'email/secure_dir_request/' - f'secure_dir_manage_user_request_denied.txt', - context, - settings.EMAIL_SENDER, - [user.email]) - - except Exception as e: - message = 'Failed to send notification email.' - messages.error(self.request, message) - logger.error(message) - logger.exception(e) - - return HttpResponseRedirect( - reverse(f'secure-dir-manage-users-request-list', - kwargs={'action': self.action, 'status': 'pending'})) - - -class SecureDirRequestLandingView(LoginRequiredMixin, - UserPassesTestMixin, - TemplateView): - """A view for the secure directory request landing page.""" - - template_name = \ - 'secure_dir/secure_dir_request/secure_dir_request_landing.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['project_pk'] = kwargs.get('pk', None) - return context - - def test_func(self): - if self.request.user.is_superuser: - return True - - access_agreement = access_agreement_signed(self.request.user) - - eligible_pi = pi_eligible_to_request_secure_dir(self.request.user) - - if eligible_pi and access_agreement: - return True - - message = ( - 'You must be an eligible PI and sign the User Access Agreement ' - 'before you can request a new secure directory.') - messages.error(self.request, message) - - -class SecureDirRequestWizard(LoginRequiredMixin, - UserPassesTestMixin, - SessionWizardView): - - FORMS = [ - ('data_description', SecureDirDataDescriptionForm), - ('rdm_consultation', SecureDirRDMConsultationForm), - # ('existing_pi', SecureDirExistingPIForm), - # ('existing_project', SecureDirExistingProjectForm), - ('directory_name', SecureDirDirectoryNamesForm) - ] - - TEMPLATES = { - 'data_description': - 'secure_dir/secure_dir_request/data_description.html', - 'rdm_consultation': - 'secure_dir/secure_dir_request/rdm_consultation.html', - # 'existing_pi': - # 'secure_dir/secure_dir_request/existing_pi.html', - # 'existing_project': - # 'secure_dir/secure_dir_request/existing_project.html', - 'directory_name': - 'secure_dir/secure_dir_request/directory_name.html' - } - - form_list = [ - SecureDirDataDescriptionForm, - SecureDirRDMConsultationForm, - # SecureDirExistingPIForm, - # SecureDirExistingProjectForm, - SecureDirDirectoryNamesForm - ] - - logger = logging.getLogger(__name__) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - # Define a lookup table from form name to step number. - self.step_numbers_by_form_name = { - name: i for i, (name, _) in enumerate(self.FORMS)} - self.project = None - - def test_func(self): - if self.request.user.is_superuser: - return True - - access_agreement = access_agreement_signed(self.request.user) - - eligible_pi = pi_eligible_to_request_secure_dir(self.request.user) - - if eligible_pi and access_agreement: - return True - - message = ( - 'You must be an eligible PI and sign the User Access Agreement ' - 'before you can request a new secure directory.') - messages.error(self.request, message) - - def dispatch(self, request, *args, **kwargs): - self.project = Project.objects.get(pk=kwargs.get('pk', None)) - return super().dispatch(request, *args, **kwargs) - - def get_context_data(self, form, **kwargs): - context = super().get_context_data(form=form, **kwargs) - current_step = int(self.steps.current) - self.__set_data_from_previous_steps(current_step, context) - - groups_path, scratch_path = get_default_secure_dir_paths() - context['groups_path'] = groups_path - context['scratch_path'] = scratch_path - context['directory_name_prefix'] = SECURE_DIRECTORY_NAME_PREFIX - - return context - - def get_form_kwargs(self, step=None): - kwargs = {} - step = int(step) - # The names of steps that require the past data. - step_names = [ - 'rdm_consultation' - ] - step_numbers = [ - self.step_numbers_by_form_name[name] for name in step_names] - if step in step_numbers: - self.__set_data_from_previous_steps(step, kwargs) - - return kwargs - - def get_template_names(self): - return [self.TEMPLATES[self.FORMS[int(self.steps.current)][0]]] - - def done(self, form_list, **kwargs): - """Perform processing and store information in a request - object.""" - redirect_url = '/' - try: - form_data = session_wizard_all_form_data( - form_list, kwargs['form_dict'], len(self.form_list)) - - request_kwargs = { - 'requester': self.request.user, - } - - department = self.__get_department(form_data) - data_description = self.__get_data_description(form_data) - rdm_consultation = self.__get_rdm_consultation(form_data) - existing_project = self.project - directory_name = self.__get_directory_name(form_data) - - # Store transformed form data in a request. - request_kwargs['department'] = department - request_kwargs['data_description'] = data_description - request_kwargs['rdm_consultation'] = rdm_consultation - request_kwargs['project'] = existing_project - request_kwargs['directory_name'] = directory_name - request_kwargs['status'] = \ - SecureDirRequestStatusChoice.objects.get( - name='Under Review') - request_kwargs['request_time'] = utc_now_offset_aware() - - # Check that the project does not have an existing Secure - # Directory or Secure Directory Request. - sec_dir_allocations = \ - get_secure_dir_allocations().filter(project=existing_project) - - sec_dir_requests = \ - SecureDirRequest.objects.\ - filter(project=existing_project).\ - exclude(status__name='Denied') - - if sec_dir_allocations.exists() or sec_dir_requests.exists(): - message = f'The project {existing_project.name} already has ' \ - f'a secure directory associated with it.' - messages.error(self.request, message) - return HttpResponseRedirect(redirect_url) - - request = SecureDirRequest.objects.create( - **request_kwargs) - - # Send a notification email to admins. - if settings.EMAIL_ENABLED: - try: - self.send_admin_notification_email(request) - except Exception as e: - self.logger.error( - 'Failed to send notification email. Details:\n') - self.logger.exception(e) - # Send a notification email to the PIs. - try: - self.send_pi_notification_email(request) - except Exception as e: - self.logger.error( - 'Failed to send notification email. Details:\n') - self.logger.exception(e) - - except Exception as e: - self.logger.exception(e) - message = 'Unexpected failure. Please contact an administrator.' - messages.error(self.request, message) - else: - message = ( - 'Thank you for your submission. It will be reviewed and ' - 'processed by administrators.') - messages.success(self.request, message) - - return HttpResponseRedirect(redirect_url) - - @staticmethod - def condition_dict(): - """Return a mapping from a string index `i` into FORMS - (zero-indexed) to a function determining whether FORMS[int(i)] - should be included.""" - view = SecureDirRequestWizard - return { - '1': view.show_rdm_consultation_form_condition - } - - def show_rdm_consultation_form_condition(self): - step_name = 'data_description' - step = str(self.step_numbers_by_form_name[step_name]) - cleaned_data = self.get_cleaned_data_for_step(step) or {} - return cleaned_data.get('rdm_consultation', False) - - def __get_department(self, form_data): - """Return the department that the user submitted.""" - step_number = self.step_numbers_by_form_name['data_description'] - data = form_data[step_number] - return data.get('department') - - def __get_data_description(self, form_data): - """Return the data description the user submitted.""" - step_number = self.step_numbers_by_form_name['data_description'] - data = form_data[step_number] - return data.get('data_description') - - def __get_rdm_consultation(self, form_data): - """Return the consultants the user spoke to.""" - step_number = self.step_numbers_by_form_name['rdm_consultation'] - data = form_data[step_number] - return data.get('rdm_consultants', None) - - def __get_directory_name(self, form_data): - """Return the name of the directory.""" - step_number = self.step_numbers_by_form_name['directory_name'] - data = form_data[step_number] - return data.get('directory_name', None) - - def __set_data_from_previous_steps(self, step, dictionary): - """Update the given dictionary with data from previous steps.""" - rdm_consultation_step = \ - self.step_numbers_by_form_name['rdm_consultation'] - if step > rdm_consultation_step: - rdm_consultation_form_data = self.get_cleaned_data_for_step( - str(rdm_consultation_step)) - dictionary.update({'breadcrumb_rdm_consultation': - 'Yes' if rdm_consultation_form_data - else 'No'}) - - dictionary.update({'breadcrumb_project': f'Project: {self.project.name}'}) - - def send_admin_notification_email(self, request): - requester = request.requester - requester_str = ( - f'{requester.first_name} {requester.last_name} ({requester.email})') - - review_url = urljoin( - settings.CENTER_BASE_URL, - reverse('secure-dir-request-detail', kwargs={'pk': request.pk})) - - context = { - 'project_name': request.project.name, - 'requester_str': requester_str, - 'review_url': review_url, - } - - send_email_template( - f'New Secure Directory Request', - 'email/secure_dir_request/secure_dir_new_request_admin.txt', - context, - settings.EMAIL_SENDER, - settings.EMAIL_ADMIN_LIST) - - def send_pi_notification_email(self, request): - requester = request.requester - requester_str = ( - f'{requester.first_name} {requester.last_name} ({requester.email})') - - review_url = urljoin( - settings.CENTER_BASE_URL, - reverse('secure-dir-request-detail', kwargs={'pk': request.pk})) - - pi_emails = request.project.pis_emails() - pi_emails.remove(requester.email) - - context = { - 'project_name': request.project.name, - 'requester_str': requester_str, - 'review_url': review_url, - } - - send_email_template( - f'New Secure Directory Request', - 'email/secure_dir_request/secure_dir_new_request_pi.txt', - context, - settings.EMAIL_SENDER, - pi_emails) - - -class SecureDirRequestListView(LoginRequiredMixin, - UserPassesTestMixin, - TemplateView): - - template_name = 'secure_dir/secure_dir_request/secure_dir_request_list.html' - # Show completed requests if True; else, show pending requests. - completed = False - - def test_func(self): - """UserPassesTestMixin tests.""" - if self.request.user.is_superuser: - return True - - if self.request.user.has_perm('allocation.view_securedirrequest'): - return True - - message = ( - 'You do not have permission to view the previous page.') - messages.error(self.request, message) - - def get_queryset(self): - order_by = self.request.GET.get('order_by') - if order_by: - direction = self.request.GET.get('direction') - if direction == 'asc': - direction = '' - else: - direction = '-' - order_by = direction + order_by - else: - order_by = '-modified' - - return SecureDirRequest.objects.order_by(order_by) - - def get_context_data(self, **kwargs): - """Include either pending or completed requests.""" - context = super().get_context_data(**kwargs) - kwargs = {} - - request_list = self.get_queryset() - - if self.completed: - status__name__in = [ - 'Approved - Complete', 'Denied'] - else: - status__name__in = ['Under Review', 'Approved - Processing'] - - kwargs['status__name__in'] = status__name__in - context['secure_dir_request_list'] = request_list.filter(**kwargs) - - context['request_filter'] = ( - 'completed' if self.completed else 'pending') - - return context - - -class SecureDirRequestMixin(object): - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.request_obj = None - - def redirect_if_disallowed_status(self, http_request, - disallowed_status_names=( - 'Approved - Complete', - 'Denied')): - """Return a redirect response to the detail view for this - project request if its status has one of the given disallowed - names, after sending a message to the user. Otherwise, return - None.""" - if not isinstance(self.request_obj, SecureDirRequest): - raise TypeError( - f'Request object has unexpected type ' - f'{type(self.request_obj)}.') - status_name = self.request_obj.status.name - if status_name in disallowed_status_names: - message = ( - f'You cannot perform this action on a request with status ' - f'{status_name}.') - messages.error(http_request, message) - return HttpResponseRedirect( - self.request_detail_url(self.request_obj.pk)) - return None - - @staticmethod - def request_detail_url(pk): - """Return the URL to the detail view for the request with the - given primary key.""" - return reverse('secure-dir-request-detail', kwargs={'pk': pk}) - - def set_request_obj(self, pk): - """Set this instance's request_obj to be the SecureDirRequest with - the given primary key.""" - self.request_obj = get_object_or_404(SecureDirRequest, pk=pk) - - -class SecureDirRequestDetailView(LoginRequiredMixin, - UserPassesTestMixin, - SecureDirRequestMixin, - DetailView): - model = SecureDirRequest - template_name = \ - 'secure_dir/secure_dir_request/secure_dir_request_detail.html' - context_object_name = 'secure_dir_request' - - logger = logging.getLogger(__name__) - - error_message = 'Unexpected failure. Please contact an administrator.' - - redirect = reverse_lazy('secure-dir-pending-request-list') - - def test_func(self): - """UserPassesTestMixin tests.""" - if self.request.user.is_superuser: - return True - - if self.request.user.has_perm('allocation.view_securedirrequest'): - return True - - pis = self.request_obj.project.projectuser_set.filter( - role__name='Principal Investigator', - status__name='Active').values_list('user__pk', flat=True) - if self.request.user.pk in pis: - return True - - message = 'You do not have permission to view the previous page.' - messages.error(self.request, message) - - def dispatch(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') - self.set_request_obj(pk) - return super().dispatch(request, *args, **kwargs) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - try: - latest_update_timestamp = \ - self.request_obj.latest_update_timestamp() - if not latest_update_timestamp: - latest_update_timestamp = 'No updates yet.' - else: - # TODO: Upgrade to Python 3.7+ to use this. - # latest_update_timestamp = datetime.datetime.fromisoformat( - # latest_update_timestamp) - latest_update_timestamp = iso8601.parse_date( - latest_update_timestamp) - except Exception as e: - self.logger.exception(e) - messages.error(self.request, self.error_message) - latest_update_timestamp = 'Failed to determine timestamp.' - context['latest_update_timestamp'] = latest_update_timestamp - - if self.request_obj.status.name == 'Denied': - try: - denial_reason = self.request_obj.denial_reason() - category = denial_reason.category - justification = denial_reason.justification - timestamp = denial_reason.timestamp - except Exception as e: - self.logger.exception(e) - messages.error(self.request, self.error_message) - category = 'Unknown Category' - justification = ( - 'Failed to determine denial reason. Please contact an ' - 'administrator.') - timestamp = 'Unknown Timestamp' - context['denial_reason'] = { - 'category': category, - 'justification': justification, - 'timestamp': timestamp, - } - context['support_email'] = settings.CENTER_HELP_EMAIL - - context['checklist'] = self.get_checklist() - context['setup_status'] = self.get_setup_status() - context['is_checklist_complete'] = self.is_checklist_complete() - - context['is_allowed_to_manage_request'] = \ - self.request.user.is_superuser - - context['secure_dir_request'] = self.request_obj - - context['can_download_mou'] = self.request_obj \ - .state['notified']['status'] == 'Complete' - context['can_upload_mou'] = \ - self.request_obj.status.name == 'Under Review' - context['mou_uploaded'] = bool(self.request_obj.mou_file) - - context['unsigned_download_url'] = reverse('secure-dir-request-download-unsigned-mou', - kwargs={'pk': self.request_obj.pk, - 'request_type': 'secure-dir'}) - context['signed_download_url'] = reverse('secure-dir-request-download-mou', - kwargs={'pk': self.request_obj.pk, - 'request_type': 'secure-dir'}) - context['signed_upload_url'] = reverse('secure-dir-request-upload-mou', - kwargs={'pk': self.request_obj.pk, - 'request_type': 'secure-dir'}) - context['mou_type'] = 'Researcher Use Agreement' - - set_sec_dir_context(context, self.request_obj) - - return context - - def get_checklist(self): - """Return a nested list, where each row contains the details of - one item on the checklist. - Each row is of the form: [task text, status name, latest update - timestamp, is "Manage" button available, URL of "Manage" - button.]""" - pk = self.request_obj.pk - state = self.request_obj.state - checklist = [] - - rdm = state['rdm_consultation'] - checklist.append([ - 'Confirm that the PI has consulted with RDM.', - rdm['status'], - rdm['timestamp'], - True, - reverse( - 'secure-dir-request-review-rdm-consultation', kwargs={'pk': pk}) - ]) - rdm_consulted = rdm['status'] == 'Approved' - - notified = state['notified'] - task_text = ( - 'Confirm or edit allowance details, and ' - 'enable/notify the PI to sign the Researcher Use Agreement.') - checklist.append([ - task_text, - notified['status'], - notified['timestamp'], - True, - reverse('secure-dir-request-notify-pi', - kwargs={'pk': pk}) - ]) - is_notified = notified['status'] == 'Complete' - - mou = state['mou'] - checklist.append([ - 'Confirm that the PI has signed the Researcher Use Agreement.', - mou['status'], - mou['timestamp'], - is_notified, - reverse( - 'secure-dir-request-review-mou', kwargs={'pk': pk}) - ]) - mou_signed = mou['status'] == 'Approved' - - setup = state['setup'] - checklist.append([ - 'Perform secure directory setup on the cluster.', - self.get_setup_status(), - setup['timestamp'], - rdm_consulted and is_notified and mou_signed, - reverse('secure-dir-request-review-setup', kwargs={'pk': pk}) - ]) - - return checklist - - def post(self, request, *args, **kwargs): - """Approve the request.""" - if not self.request.user.is_superuser: - message = 'You do not have permission to access this page.' - messages.error(request, message) - pk = self.request_obj.pk - - return HttpResponseRedirect( - reverse('secure-dir-request-detail', kwargs={'pk': pk})) - - if not self.is_checklist_complete(): - message = 'Please complete the checklist before final activation.' - messages.error(request, message) - pk = self.request_obj.pk - return HttpResponseRedirect( - reverse('secure-dir-request-detail', kwargs={'pk': pk})) - - # Check that the project does not have any Secure Directories yet. - sec_dir_allocations = get_secure_dir_allocations() - if sec_dir_allocations.filter(project=self.request_obj.project).exists(): - message = f'The project {self.request_obj.project.name} already ' \ - f'has a secure directory associated with it.' - messages.error(self.request, message) - pk = self.request_obj.pk - return HttpResponseRedirect( - reverse('secure-dir-request-detail', kwargs={'pk': pk})) - - # Approve the request and send emails to the PI and requester. - runner = SecureDirRequestApprovalRunner(self.request_obj) - runner.run() - - success_messages, error_messages = runner.get_messages() - - for message in success_messages: - messages.success(self.request, message) - for message in error_messages: - messages.error(self.request, message) - - return HttpResponseRedirect(self.redirect) - - def get_setup_status(self): - """Return one of the following statuses for the 'setup' step of - the request: 'N/A', 'Pending', 'Completed'.""" - state = self.request_obj.state - if (state['rdm_consultation']['status'] == 'Denied' or - state['mou']['status'] == 'Denied'): - return 'N/A' - return state['setup']['status'] - - def is_checklist_complete(self): - status_choice = secure_dir_request_state_status(self.request_obj) - return (status_choice.name == 'Approved - Processing' and - self.request_obj.state['setup']['status'] == 'Completed') - - -class SecureDirRequestReviewRDMConsultView(LoginRequiredMixin, - UserPassesTestMixin, - SecureDirRequestMixin, - FormView): - form_class = SecureDirRDMConsultationReviewForm - template_name = ( - 'secure_dir/secure_dir_request/secure_dir_consult_rdm.html') - - def test_func(self): - """UserPassesTestMixin tests.""" - if self.request.user.is_superuser: - return True - message = 'You do not have permission to view the previous page.' - messages.error(self.request, message) - return False - - def dispatch(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') - self.set_request_obj(pk=pk) - redirect = self.redirect_if_disallowed_status(request) - if redirect is not None: - return redirect - return super().dispatch(request, *args, **kwargs) - - def form_valid(self, form): - form_data = form.cleaned_data - status = form_data['status'] - justification = form_data['justification'] - rdm_update = form_data['rdm_update'] - timestamp = utc_now_offset_aware().isoformat() - self.request_obj.state['rdm_consultation'] = { - 'status': status, - 'justification': justification, - 'timestamp': timestamp, - } - self.request_obj.status = \ - secure_dir_request_state_status(self.request_obj) - self.request_obj.rdm_consultation = rdm_update - self.request_obj.save() - - if status == 'Denied': - runner = SecureDirRequestDenialRunner(self.request_obj) - runner.run() - - message = ( - f'RDM consultation status for {self.request_obj.project.name}\'s ' - f'secure directory request has been set to {status}.') - messages.success(self.request, message) - - return super().form_valid(form) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - set_sec_dir_context(context, self.request_obj) - return context - - def get_initial(self): - initial = super().get_initial() - rdm_consultation = self.request_obj.state['rdm_consultation'] - initial['status'] = rdm_consultation['status'] - initial['justification'] = rdm_consultation['justification'] - initial['rdm_update'] = self.request_obj.rdm_consultation - - return initial - - def get_success_url(self): - return reverse( - 'secure-dir-request-detail', - kwargs={'pk': self.kwargs.get('pk')}) - - -class SecureDirRequestReviewMOUView(LoginRequiredMixin, - UserPassesTestMixin, - SecureDirRequestMixin, - FormView): - form_class = ReviewStatusForm - template_name = ( - 'secure_dir/secure_dir_request/secure_dir_mou.html') - - def test_func(self): - """UserPassesTestMixin tests.""" - if self.request.user.is_superuser: - return True - message = 'You do not have permission to view the previous page.' - messages.error(self.request, message) - return False - - def dispatch(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') - self.set_request_obj(pk=pk) - redirect = self.redirect_if_disallowed_status(request) - if redirect is not None: - return redirect - return super().dispatch(request, *args, **kwargs) - - def form_valid(self, form): - form_data = form.cleaned_data - status = form_data['status'] - justification = form_data['justification'] - timestamp = utc_now_offset_aware().isoformat() - self.request_obj.state['mou'] = { - 'status': status, - 'justification': justification, - 'timestamp': timestamp, - } - self.request_obj.status = \ - secure_dir_request_state_status(self.request_obj) - self.request_obj.save() - - if status == 'Denied': - runner = SecureDirRequestDenialRunner(self.request_obj) - runner.run() - - message = ( - f'MOU status for the secure directory request of project ' - f'{self.request_obj.project.pk} has been set to {status}.') - messages.success(self.request, message) - - return super().form_valid(form) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - set_sec_dir_context(context, self.request_obj) - return context - - def get_initial(self): - initial = super().get_initial() - mou = self.request_obj.state['mou'] - initial['status'] = mou['status'] - initial['justification'] = mou['justification'] - return initial - - def get_success_url(self): - return reverse( - 'secure-dir-request-detail', - kwargs={'pk': self.kwargs.get('pk')}) - - -class SecureDirRequestReviewSetupView(LoginRequiredMixin, - UserPassesTestMixin, - SecureDirRequestMixin, - FormView): - form_class = SecureDirSetupForm - template_name = ( - 'secure_dir/secure_dir_request/secure_dir_setup.html') - - def test_func(self): - """UserPassesTestMixin tests.""" - if self.request.user.is_superuser: - return True - message = 'You do not have permission to view the previous page.' - messages.error(self.request, message) - return False - - def dispatch(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') - self.set_request_obj(pk=pk) - redirect = self.redirect_if_disallowed_status(request) - if redirect is not None: - return redirect - return super().dispatch(request, *args, **kwargs) - - def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs['dir_name'] = self.request_obj.directory_name - kwargs['request_pk'] = self.request_obj.pk - return kwargs - - def form_valid(self, form): - form_data = form.cleaned_data - status = form_data['status'] - justification = form_data['justification'] - directory_name = form_data['directory_name'] - timestamp = utc_now_offset_aware().isoformat() - self.request_obj.state['setup'] = { - 'status': status, - 'justification': justification, - 'timestamp': timestamp, - } - self.request_obj.status = \ - secure_dir_request_state_status(self.request_obj) - if directory_name: - self.request_obj.directory_name = directory_name - self.request_obj.save() - - if status == 'Denied': - runner = SecureDirRequestDenialRunner(self.request_obj) - runner.run() - - message = ( - f'Setup status for {self.request_obj.project.name}\'s ' - f'secure directory request has been set to {status}.') - messages.success(self.request, message) - - return super().form_valid(form) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - set_sec_dir_context(context, self.request_obj) - return context - - def get_initial(self): - initial = super().get_initial() - setup = self.request_obj.state['setup'] - initial['status'] = setup['status'] - initial['justification'] = setup['justification'] - return initial - - def get_success_url(self): - return reverse( - 'secure-dir-request-detail', - kwargs={'pk': self.kwargs.get('pk')}) - - -class SecureDirRequestReviewDenyView(LoginRequiredMixin, UserPassesTestMixin, - SecureDirRequestMixin, FormView): - form_class = ReviewDenyForm - template_name = ( - 'secure_dir/secure_dir_request/secure_dir_review_deny.html') - - def test_func(self): - """UserPassesTestMixin tests.""" - if self.request.user.is_superuser: - return True - message = 'You do not have permission to view the previous page.' - messages.error(self.request, message) - return False - - def dispatch(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') - self.set_request_obj(pk) - redirect = self.redirect_if_disallowed_status(request) - if redirect is not None: - return redirect - return super().dispatch(request, *args, **kwargs) - - def form_valid(self, form): - form_data = form.cleaned_data - justification = form_data['justification'] - timestamp = utc_now_offset_aware().isoformat() - self.request_obj.state['other'] = { - 'justification': justification, - 'timestamp': timestamp, - } - self.request_obj.status = \ - secure_dir_request_state_status(self.request_obj) - - runner = SecureDirRequestDenialRunner(self.request_obj) - runner.run() - - self.request_obj.save() - - message = ( - f'Status for {self.request_obj.project.name}\'s ' - f'secure directory request has been set to ' - f'{self.request_obj.status}.') - messages.success(self.request, message) - - return super().form_valid(form) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - set_sec_dir_context(context, self.request_obj) - return context - - def get_initial(self): - initial = super().get_initial() - other = self.request_obj.state['other'] - initial['justification'] = other['justification'] - return initial - - def get_success_url(self): - return reverse( - 'secure-dir-request-detail', - kwargs={'pk': self.kwargs.get('pk')}) - - -class SecureDirRequestUndenyRequestView(LoginRequiredMixin, - UserPassesTestMixin, - SecureDirRequestMixin, - View): - - def test_func(self): - """UserPassesTestMixin tests.""" - if self.request.user.is_superuser: - return True - message = ( - 'You do not have permission to undeny a secure directory request.') - messages.error(self.request, message) - - def dispatch(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') - self.set_request_obj(pk) - - disallowed_status_names = list( - SecureDirRequestStatusChoice.objects.filter( - ~Q(name='Denied')).values_list('name', flat=True)) - redirect = self.redirect_if_disallowed_status( - request, disallowed_status_names=disallowed_status_names) - if redirect is not None: - return redirect - - return super().dispatch(request, *args, **kwargs) - - def get(self, request, *args, **kwargs): - state = self.request_obj.state - - rdm_consultation = state['rdm_consultation'] - if rdm_consultation['status'] == 'Denied': - rdm_consultation['status'] = 'Pending' - rdm_consultation['timestamp'] = '' - rdm_consultation['justification'] = '' - - mou = state['mou'] - if mou['status'] == 'Denied': - mou['status'] = 'Pending' - mou['timestamp'] = '' - mou['justification'] = '' - - setup = state['setup'] - if setup['status'] != 'Pending': - setup['status'] = 'Pending' - setup['timestamp'] = '' - setup['justification'] = '' - - other = state['other'] - if other['timestamp']: - other['justification'] = '' - other['timestamp'] = '' - - self.request_obj.status = \ - secure_dir_request_state_status(self.request_obj) - self.request_obj.save() - - message = ( - f'Secure directory request for {self.request_obj.project.name} has ' - f'been un-denied and will need to be reviewed again.') - messages.success(request, message) - - return HttpResponseRedirect( - reverse( - 'secure-dir-request-detail', - kwargs={'pk': kwargs.get('pk')})) - -class SecureDirRequestEditDepartmentView(LoginRequiredMixin, - UserPassesTestMixin, - SecureDirRequestMixin, - FormView): - template_name = 'secure_dir/secure_dir_request/secure_dir_request_edit_department.html' - form_class = SecureDirRequestEditDepartmentForm - - logger = logging.getLogger(__name__) - - error_message = 'Unexpected failure. Please contact an administrator.' - - def test_func(self): - """UserPassesTestMixin tests.""" - if self.request.user.is_superuser: - return True - message = 'You do not have permission to view the previous page.' - messages.error(self.request, message) - return False - - def dispatch(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') - self.set_request_obj(pk) - return super().dispatch(request, *args, **kwargs) - - def get_context_data(self, *args, **kwargs): - context = super().get_context_data(**kwargs) - context['form'].initial['department'] = self.request_obj.department - context['secure_dir_request'] = self.request_obj - context['notify_pi'] = False - return context - - def form_valid(self, form): - """Save the form.""" - self.request_obj.department = form.cleaned_data.get('department') - self.request_obj.save() - message = 'The request has been updated.' - messages.success(self.request, message) - return HttpResponseRedirect(reverse('secure-dir-request-detail', - kwargs={'pk':self.request_obj.pk})) - - def form_invalid(self, form): - """Handle invalid forms.""" - message = 'Please correct the errors below.' - messages.error(self.request, message) - return self.render_to_response( - self.get_context_data(form=form)) - -class SecureDirRequestNotifyPIView(MOURequestNotifyPIViewMixIn, - SecureDirRequestEditDepartmentView): - def email_pi(self): - super()._email_pi('Secure Directory Request Ready To Be Signed', - self.request_obj.requester.get_full_name(), - reverse('secure-dir-request-detail', - kwargs={'pk': self.request_obj.pk}), - 'Researcher User Agreement', - f'{self.request_obj.project.name} secure directory request', - self.request_obj.requester.email) diff --git a/coldfront/core/allocation/views_/secure_dir_views/__init__.py b/coldfront/core/allocation/views_/secure_dir_views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/coldfront/core/allocation/views_/secure_dir_views/new_directory/__init__.py b/coldfront/core/allocation/views_/secure_dir_views/new_directory/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/coldfront/core/allocation/views_/secure_dir_views/new_directory/approval_views.py b/coldfront/core/allocation/views_/secure_dir_views/new_directory/approval_views.py new file mode 100644 index 000000000..ee423770b --- /dev/null +++ b/coldfront/core/allocation/views_/secure_dir_views/new_directory/approval_views.py @@ -0,0 +1,769 @@ +import iso8601 +import logging + +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.mixins import UserPassesTestMixin +from django.db.models import Q +from django.http import HttpResponseRedirect +from django.shortcuts import get_object_or_404 +from django.urls import reverse +from django.urls import reverse_lazy +from django.views.generic import DetailView +from django.views.generic import FormView +from django.views.generic.base import TemplateView +from django.views.generic.base import View + +from coldfront.core.allocation.forms_.secure_dir_forms import SecureDirRDMConsultationReviewForm +from coldfront.core.allocation.forms_.secure_dir_forms import SecureDirRequestEditDepartmentForm +from coldfront.core.allocation.forms_.secure_dir_forms import SecureDirSetupForm +from coldfront.core.allocation.models import SecureDirRequest +from coldfront.core.allocation.models import SecureDirRequestStatusChoice +from coldfront.core.allocation.utils_.secure_dir_utils.new_directory import get_secure_dir_allocations +from coldfront.core.allocation.utils_.secure_dir_utils.new_directory import secure_dir_request_state_status +from coldfront.core.allocation.utils_.secure_dir_utils.new_directory import SecureDirRequestApprovalRunner +from coldfront.core.allocation.utils_.secure_dir_utils.new_directory import SecureDirRequestDenialRunner +from coldfront.core.allocation.utils_.secure_dir_utils.new_directory import set_sec_dir_context + +from coldfront.core.project.forms import ReviewStatusForm, ReviewDenyForm + +from coldfront.core.utils.common import utc_now_offset_aware +from coldfront.core.utils.email.email_strategy import EnqueueEmailStrategy +from coldfront.core.utils.views.mou_views import MOURequestNotifyPIViewMixIn + + +logger = logging.getLogger(__name__) + + +class SecureDirRequestListView(LoginRequiredMixin, + UserPassesTestMixin, + TemplateView): + + template_name = 'secure_dir/secure_dir_request/secure_dir_request_list.html' + # Show completed requests if True; else, show pending requests. + completed = False + + def test_func(self): + """UserPassesTestMixin tests.""" + if self.request.user.is_superuser: + return True + + if self.request.user.has_perm('allocation.view_securedirrequest'): + return True + + message = ( + 'You do not have permission to view the previous page.') + messages.error(self.request, message) + + def get_queryset(self): + order_by = self.request.GET.get('order_by') + if order_by: + direction = self.request.GET.get('direction') + if direction == 'asc': + direction = '' + else: + direction = '-' + order_by = direction + order_by + else: + order_by = '-modified' + + return SecureDirRequest.objects.order_by(order_by) + + def get_context_data(self, **kwargs): + """Include either pending or completed requests.""" + context = super().get_context_data(**kwargs) + kwargs = {} + + request_list = self.get_queryset() + + if self.completed: + status__name__in = [ + 'Approved - Complete', 'Denied'] + else: + status__name__in = ['Under Review', 'Approved - Processing'] + + kwargs['status__name__in'] = status__name__in + context['secure_dir_request_list'] = request_list.filter(**kwargs) + + context['request_filter'] = ( + 'completed' if self.completed else 'pending') + + return context + + +class SecureDirRequestMixin(object): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request_obj = None + + def redirect_if_disallowed_status(self, http_request, + disallowed_status_names=( + 'Approved - Complete', + 'Denied')): + """Return a redirect response to the detail view for this + project request if its status has one of the given disallowed + names, after sending a message to the user. Otherwise, return + None.""" + if not isinstance(self.request_obj, SecureDirRequest): + raise TypeError( + f'Request object has unexpected type ' + f'{type(self.request_obj)}.') + status_name = self.request_obj.status.name + if status_name in disallowed_status_names: + message = ( + f'You cannot perform this action on a request with status ' + f'{status_name}.') + messages.error(http_request, message) + return HttpResponseRedirect( + self.request_detail_url(self.request_obj.pk)) + return None + + @staticmethod + def request_detail_url(pk): + """Return the URL to the detail view for the request with the + given primary key.""" + return reverse('secure-dir-request-detail', kwargs={'pk': pk}) + + def set_request_obj(self, pk): + """Set this instance's request_obj to be the SecureDirRequest with + the given primary key.""" + self.request_obj = get_object_or_404(SecureDirRequest, pk=pk) + + +class SecureDirRequestDetailView(LoginRequiredMixin, + UserPassesTestMixin, + SecureDirRequestMixin, + DetailView): + model = SecureDirRequest + template_name = \ + 'secure_dir/secure_dir_request/secure_dir_request_detail.html' + context_object_name = 'secure_dir_request' + + logger = logging.getLogger(__name__) + + error_message = 'Unexpected failure. Please contact an administrator.' + + redirect = reverse_lazy('secure-dir-pending-request-list') + + def test_func(self): + """Allow access to: + - Superusers + - Users with access to view SecureDirRequests + - Active PIs of the project + - The user who made the request + """ + user = self.request.user + + if user.is_superuser: + return True + + if user.has_perm('allocation.view_securedirrequest'): + return True + + pis = self.request_obj.project.projectuser_set.filter( + role__name='Principal Investigator', + status__name='Active').values_list('user__pk', flat=True) + if user.pk in pis: + return True + + if user == self.request_obj.requester: + return True + + message = 'You do not have permission to view the previous page.' + messages.error(self.request, message) + + def dispatch(self, request, *args, **kwargs): + pk = self.kwargs.get('pk') + self.set_request_obj(pk) + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + try: + latest_update_timestamp = \ + self.request_obj.latest_update_timestamp() + if not latest_update_timestamp: + latest_update_timestamp = 'No updates yet.' + else: + # TODO: Upgrade to Python 3.7+ to use this. + # latest_update_timestamp = datetime.datetime.fromisoformat( + # latest_update_timestamp) + latest_update_timestamp = iso8601.parse_date( + latest_update_timestamp) + except Exception as e: + self.logger.exception(e) + messages.error(self.request, self.error_message) + latest_update_timestamp = 'Failed to determine timestamp.' + context['latest_update_timestamp'] = latest_update_timestamp + + if self.request_obj.status.name == 'Denied': + try: + denial_reason = self.request_obj.denial_reason() + category = denial_reason.category + justification = denial_reason.justification + timestamp = denial_reason.timestamp + except Exception as e: + self.logger.exception(e) + messages.error(self.request, self.error_message) + category = 'Unknown Category' + justification = ( + 'Failed to determine denial reason. Please contact an ' + 'administrator.') + timestamp = 'Unknown Timestamp' + context['denial_reason'] = { + 'category': category, + 'justification': justification, + 'timestamp': timestamp, + } + context['support_email'] = settings.CENTER_HELP_EMAIL + + context['checklist'] = self.get_checklist() + context['setup_status'] = self.get_setup_status() + context['is_checklist_complete'] = self.is_checklist_complete() + + context['is_allowed_to_manage_request'] = \ + self.request.user.is_superuser + + context['secure_dir_request'] = self.request_obj + + context['can_download_mou'] = self.request_obj \ + .state['notified']['status'] == 'Complete' + context['can_upload_mou'] = \ + self.request_obj.status.name == 'Under Review' + context['mou_uploaded'] = bool(self.request_obj.mou_file) + + context['unsigned_download_url'] = reverse('secure-dir-request-download-unsigned-mou', + kwargs={'pk': self.request_obj.pk, + 'request_type': 'secure-dir'}) + context['signed_download_url'] = reverse('secure-dir-request-download-mou', + kwargs={'pk': self.request_obj.pk, + 'request_type': 'secure-dir'}) + context['signed_upload_url'] = reverse('secure-dir-request-upload-mou', + kwargs={'pk': self.request_obj.pk, + 'request_type': 'secure-dir'}) + context['mou_type'] = 'Researcher Use Agreement' + + set_sec_dir_context(context, self.request_obj) + + return context + + def get_checklist(self): + """Return a nested list, where each row contains the details of + one item on the checklist. + Each row is of the form: [task text, status name, latest update + timestamp, is "Manage" button available, URL of "Manage" + button.]""" + pk = self.request_obj.pk + state = self.request_obj.state + checklist = [] + + rdm = state['rdm_consultation'] + checklist.append([ + 'Confirm that the PI has consulted with RDM.', + rdm['status'], + rdm['timestamp'], + True, + reverse( + 'secure-dir-request-review-rdm-consultation', kwargs={'pk': pk}) + ]) + rdm_consulted = rdm['status'] == 'Approved' + + notified = state['notified'] + task_text = ( + 'Confirm or edit directory details, and enable/notify the PI to ' + 'sign the Researcher Use Agreement.') + checklist.append([ + task_text, + notified['status'], + notified['timestamp'], + True, + reverse('secure-dir-request-notify-pi', + kwargs={'pk': pk}) + ]) + is_notified = notified['status'] == 'Complete' + + mou = state['mou'] + checklist.append([ + 'Confirm that the PI has signed the Researcher Use Agreement.', + mou['status'], + mou['timestamp'], + is_notified, + reverse( + 'secure-dir-request-review-mou', kwargs={'pk': pk}) + ]) + mou_signed = mou['status'] == 'Approved' + + setup = state['setup'] + checklist.append([ + 'Perform secure directory setup on the cluster.', + self.get_setup_status(), + setup['timestamp'], + rdm_consulted and is_notified and mou_signed, + reverse('secure-dir-request-review-setup', kwargs={'pk': pk}) + ]) + + return checklist + + def post(self, request, *args, **kwargs): + """Approve the request.""" + pk = self.request_obj.pk + redirect_to_detail = HttpResponseRedirect( + reverse('secure-dir-request-detail', kwargs={'pk': pk})) + + if not self.request.user.is_superuser: + message = 'You do not have permission to access this page.' + messages.error(request, message) + return redirect_to_detail + + if not self.is_checklist_complete(): + message = 'Please complete the checklist before final activation.' + messages.error(request, message) + return redirect_to_detail + + # Check that the project does not have any Secure Directories yet. + sec_dir_allocations = get_secure_dir_allocations() + if sec_dir_allocations.filter(project=self.request_obj.project).exists(): + message = f'The project {self.request_obj.project.name} already ' \ + f'has a secure directory associated with it.' + messages.error(self.request, message) + return redirect_to_detail + + email_strategy = EnqueueEmailStrategy() + + # Approve the request. + runner = SecureDirRequestApprovalRunner( + self.request_obj, email_strategy=email_strategy) + runner.run() + + try: + email_strategy.send_queued_emails() + except Exception as e: + logger.exception(e) + + success_messages, error_messages = runner.get_messages() + for message in success_messages: + messages.success(self.request, message) + for message in error_messages: + messages.error(self.request, message) + + return HttpResponseRedirect(self.redirect) + + def get_setup_status(self): + """Return one of the following statuses for the 'setup' step of + the request: 'N/A', 'Pending', 'Completed'.""" + state = self.request_obj.state + if (state['rdm_consultation']['status'] == 'Denied' or + state['mou']['status'] == 'Denied'): + return 'N/A' + return state['setup']['status'] + + def is_checklist_complete(self): + status_choice = secure_dir_request_state_status(self.request_obj) + return (status_choice.name == 'Approved - Processing' and + self.request_obj.state['setup']['status'] == 'Completed') + + +class SecureDirRequestReviewRDMConsultView(LoginRequiredMixin, + UserPassesTestMixin, + SecureDirRequestMixin, + FormView): + form_class = SecureDirRDMConsultationReviewForm + template_name = ( + 'secure_dir/secure_dir_request/secure_dir_consult_rdm.html') + + def test_func(self): + """UserPassesTestMixin tests.""" + if self.request.user.is_superuser: + return True + message = 'You do not have permission to view the previous page.' + messages.error(self.request, message) + return False + + def dispatch(self, request, *args, **kwargs): + pk = self.kwargs.get('pk') + self.set_request_obj(pk=pk) + redirect = self.redirect_if_disallowed_status(request) + if redirect is not None: + return redirect + return super().dispatch(request, *args, **kwargs) + + def form_valid(self, form): + form_data = form.cleaned_data + status = form_data['status'] + justification = form_data['justification'] + rdm_update = form_data['rdm_update'] + timestamp = utc_now_offset_aware().isoformat() + self.request_obj.state['rdm_consultation'] = { + 'status': status, + 'justification': justification, + 'timestamp': timestamp, + } + self.request_obj.status = \ + secure_dir_request_state_status(self.request_obj) + self.request_obj.rdm_consultation = rdm_update + self.request_obj.save() + + if status == 'Denied': + runner = SecureDirRequestDenialRunner(self.request_obj) + runner.run() + + message = ( + f'RDM consultation status for {self.request_obj.project.name}\'s ' + f'secure directory request has been set to {status}.') + messages.success(self.request, message) + + return super().form_valid(form) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + set_sec_dir_context(context, self.request_obj) + return context + + def get_initial(self): + initial = super().get_initial() + rdm_consultation = self.request_obj.state['rdm_consultation'] + initial['status'] = rdm_consultation['status'] + initial['justification'] = rdm_consultation['justification'] + initial['rdm_update'] = self.request_obj.rdm_consultation + + return initial + + def get_success_url(self): + return reverse( + 'secure-dir-request-detail', + kwargs={'pk': self.kwargs.get('pk')}) + + +class SecureDirRequestReviewMOUView(LoginRequiredMixin, + UserPassesTestMixin, + SecureDirRequestMixin, + FormView): + form_class = ReviewStatusForm + template_name = ( + 'secure_dir/secure_dir_request/secure_dir_mou.html') + + def test_func(self): + """UserPassesTestMixin tests.""" + if self.request.user.is_superuser: + return True + message = 'You do not have permission to view the previous page.' + messages.error(self.request, message) + return False + + def dispatch(self, request, *args, **kwargs): + pk = self.kwargs.get('pk') + self.set_request_obj(pk=pk) + redirect = self.redirect_if_disallowed_status(request) + if redirect is not None: + return redirect + return super().dispatch(request, *args, **kwargs) + + def form_valid(self, form): + form_data = form.cleaned_data + status = form_data['status'] + justification = form_data['justification'] + timestamp = utc_now_offset_aware().isoformat() + self.request_obj.state['mou'] = { + 'status': status, + 'justification': justification, + 'timestamp': timestamp, + } + self.request_obj.status = \ + secure_dir_request_state_status(self.request_obj) + self.request_obj.save() + + if status == 'Denied': + runner = SecureDirRequestDenialRunner(self.request_obj) + runner.run() + + message = ( + f'MOU status for the secure directory request of project ' + f'{self.request_obj.project.pk} has been set to {status}.') + messages.success(self.request, message) + + return super().form_valid(form) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + set_sec_dir_context(context, self.request_obj) + return context + + def get_initial(self): + initial = super().get_initial() + mou = self.request_obj.state['mou'] + initial['status'] = mou['status'] + initial['justification'] = mou['justification'] + return initial + + def get_success_url(self): + return reverse( + 'secure-dir-request-detail', + kwargs={'pk': self.kwargs.get('pk')}) + + +class SecureDirRequestReviewSetupView(LoginRequiredMixin, + UserPassesTestMixin, + SecureDirRequestMixin, + FormView): + form_class = SecureDirSetupForm + template_name = ( + 'secure_dir/secure_dir_request/secure_dir_setup.html') + + def test_func(self): + """UserPassesTestMixin tests.""" + if self.request.user.is_superuser: + return True + message = 'You do not have permission to view the previous page.' + messages.error(self.request, message) + return False + + def dispatch(self, request, *args, **kwargs): + pk = self.kwargs.get('pk') + self.set_request_obj(pk=pk) + redirect = self.redirect_if_disallowed_status(request) + if redirect is not None: + return redirect + return super().dispatch(request, *args, **kwargs) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['dir_name'] = self.request_obj.directory_name + kwargs['request_pk'] = self.request_obj.pk + return kwargs + + def form_valid(self, form): + form_data = form.cleaned_data + status = form_data['status'] + justification = form_data['justification'] + directory_name = form_data['directory_name'] + timestamp = utc_now_offset_aware().isoformat() + self.request_obj.state['setup'] = { + 'status': status, + 'justification': justification, + 'timestamp': timestamp, + } + self.request_obj.status = \ + secure_dir_request_state_status(self.request_obj) + if directory_name: + self.request_obj.directory_name = directory_name + self.request_obj.save() + + if status == 'Denied': + runner = SecureDirRequestDenialRunner(self.request_obj) + runner.run() + + message = ( + f'Setup status for {self.request_obj.project.name}\'s ' + f'secure directory request has been set to {status}.') + messages.success(self.request, message) + + return super().form_valid(form) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + set_sec_dir_context(context, self.request_obj) + return context + + def get_initial(self): + initial = super().get_initial() + setup = self.request_obj.state['setup'] + initial['status'] = setup['status'] + initial['justification'] = setup['justification'] + return initial + + def get_success_url(self): + return reverse( + 'secure-dir-request-detail', + kwargs={'pk': self.kwargs.get('pk')}) + + +class SecureDirRequestReviewDenyView(LoginRequiredMixin, UserPassesTestMixin, + SecureDirRequestMixin, FormView): + form_class = ReviewDenyForm + template_name = ( + 'secure_dir/secure_dir_request/secure_dir_review_deny.html') + + def test_func(self): + """UserPassesTestMixin tests.""" + if self.request.user.is_superuser: + return True + message = 'You do not have permission to view the previous page.' + messages.error(self.request, message) + return False + + def dispatch(self, request, *args, **kwargs): + pk = self.kwargs.get('pk') + self.set_request_obj(pk) + redirect = self.redirect_if_disallowed_status(request) + if redirect is not None: + return redirect + return super().dispatch(request, *args, **kwargs) + + def form_valid(self, form): + form_data = form.cleaned_data + + justification = form_data['justification'] + timestamp = utc_now_offset_aware().isoformat() + self.request_obj.state['other'] = { + 'justification': justification, + 'timestamp': timestamp, + } + self.request_obj.save() + + # Deny the request. + runner = SecureDirRequestDenialRunner(self.request_obj) + runner.run() + + success_messages, error_messages = runner.get_messages() + for message in success_messages: + messages.success(self.request, message) + for message in error_messages: + messages.error(self.request, message) + + return super().form_valid(form) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + set_sec_dir_context(context, self.request_obj) + return context + + def get_initial(self): + initial = super().get_initial() + other = self.request_obj.state['other'] + initial['justification'] = other['justification'] + return initial + + def get_success_url(self): + return reverse( + 'secure-dir-request-detail', + kwargs={'pk': self.kwargs.get('pk')}) + + +class SecureDirRequestUndenyRequestView(LoginRequiredMixin, + UserPassesTestMixin, + SecureDirRequestMixin, + View): + + def test_func(self): + """UserPassesTestMixin tests.""" + if self.request.user.is_superuser: + return True + message = ( + 'You do not have permission to undeny a secure directory request.') + messages.error(self.request, message) + + def dispatch(self, request, *args, **kwargs): + pk = self.kwargs.get('pk') + self.set_request_obj(pk) + + disallowed_status_names = list( + SecureDirRequestStatusChoice.objects.filter( + ~Q(name='Denied')).values_list('name', flat=True)) + redirect = self.redirect_if_disallowed_status( + request, disallowed_status_names=disallowed_status_names) + if redirect is not None: + return redirect + + return super().dispatch(request, *args, **kwargs) + + def get(self, request, *args, **kwargs): + state = self.request_obj.state + + rdm_consultation = state['rdm_consultation'] + if rdm_consultation['status'] == 'Denied': + rdm_consultation['status'] = 'Pending' + rdm_consultation['timestamp'] = '' + rdm_consultation['justification'] = '' + + mou = state['mou'] + if mou['status'] == 'Denied': + mou['status'] = 'Pending' + mou['timestamp'] = '' + mou['justification'] = '' + + setup = state['setup'] + if setup['status'] != 'Pending': + setup['status'] = 'Pending' + setup['timestamp'] = '' + setup['justification'] = '' + + other = state['other'] + if other['timestamp']: + other['justification'] = '' + other['timestamp'] = '' + + self.request_obj.status = \ + secure_dir_request_state_status(self.request_obj) + self.request_obj.save() + + message = ( + f'Secure directory request for {self.request_obj.project.name} has ' + f'been un-denied and will need to be reviewed again.') + messages.success(request, message) + + return HttpResponseRedirect( + reverse( + 'secure-dir-request-detail', + kwargs={'pk': kwargs.get('pk')})) + + +class SecureDirRequestEditDepartmentView(LoginRequiredMixin, + UserPassesTestMixin, + SecureDirRequestMixin, + FormView): + template_name = 'secure_dir/secure_dir_request/secure_dir_request_edit_department.html' + form_class = SecureDirRequestEditDepartmentForm + + logger = logging.getLogger(__name__) + + error_message = 'Unexpected failure. Please contact an administrator.' + + def test_func(self): + """UserPassesTestMixin tests.""" + if self.request.user.is_superuser: + return True + message = 'You do not have permission to view the previous page.' + messages.error(self.request, message) + return False + + def dispatch(self, request, *args, **kwargs): + pk = self.kwargs.get('pk') + self.set_request_obj(pk) + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(**kwargs) + context['form'].initial['department'] = self.request_obj.department + context['secure_dir_request'] = self.request_obj + context['notify_pi'] = False + return context + + def form_valid(self, form): + """Save the form.""" + self.request_obj.department = form.cleaned_data.get('department') + self.request_obj.save() + message = 'The request has been updated.' + messages.success(self.request, message) + return HttpResponseRedirect(reverse('secure-dir-request-detail', + kwargs={'pk':self.request_obj.pk})) + + def form_invalid(self, form): + """Handle invalid forms.""" + message = 'Please correct the errors below.' + messages.error(self.request, message) + return self.render_to_response( + self.get_context_data(form=form)) + + +class SecureDirRequestNotifyPIView(MOURequestNotifyPIViewMixIn, + SecureDirRequestEditDepartmentView): + def email_pi(self): + super()._email_pi('Secure Directory Request Ready To Be Signed', + self.request_obj.pi.get_full_name(), + reverse('secure-dir-request-detail', + kwargs={'pk': self.request_obj.pk}), + 'Researcher Use Agreement', + f'{self.request_obj.project.name} secure directory request', + self.request_obj.pi.email) diff --git a/coldfront/core/allocation/views_/secure_dir_views/new_directory/request_views.py b/coldfront/core/allocation/views_/secure_dir_views/new_directory/request_views.py new file mode 100644 index 000000000..37eec7581 --- /dev/null +++ b/coldfront/core/allocation/views_/secure_dir_views/new_directory/request_views.py @@ -0,0 +1,269 @@ +import logging + +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.mixins import UserPassesTestMixin +from django.http import HttpResponseRedirect +from django.shortcuts import get_object_or_404 +from django.views.generic.base import TemplateView + +from formtools.wizard.views import SessionWizardView + +from coldfront.core.allocation.forms_.secure_dir_forms import SecureDirDataDescriptionForm +from coldfront.core.allocation.forms_.secure_dir_forms import SecureDirDirectoryNamesForm +from coldfront.core.allocation.forms_.secure_dir_forms import SecureDirPISelectionForm +from coldfront.core.allocation.forms_.secure_dir_forms import SecureDirRDMConsultationForm +from coldfront.core.allocation.utils_.secure_dir_utils.new_directory import get_default_secure_dir_paths +from coldfront.core.allocation.utils_.secure_dir_utils.new_directory import is_project_eligible_for_secure_dirs +from coldfront.core.allocation.utils_.secure_dir_utils.new_directory import SECURE_DIRECTORY_NAME_PREFIX +from coldfront.core.allocation.utils_.secure_dir_utils.new_directory import SecureDirRequestRunner + +from coldfront.core.project.models import Project +from coldfront.core.project.utils_.permissions_utils import is_user_manager_or_pi_of_project + +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 + + +logger = logging.getLogger(__name__) + + +class NewSecureDirRequestViewAccessibilityMixin(UserPassesTestMixin): + """A mixin for determining whether views related to requesting new + secure directories are accessible. + + Inheriting views must take a Project primary key via a "pk" URL + argument.""" + + def test_func(self): + """Allow access to: + - Superusers + - Active PIs and Managers of the project who have signed the + access agreement + + Disallow access if the project is ineligible to request + secure directories. + """ + project_obj = get_object_or_404(Project, pk=self.kwargs['pk']) + user = self.request.user + is_user_authorized = ( + user.is_superuser or + is_user_manager_or_pi_of_project(user, project_obj)) + + if not is_user_authorized: + return False + + if not is_project_eligible_for_secure_dirs(project_obj): + message = ( + f'Project {project_obj.name} is ineligible for secure ' + f'directories.') + messages.error(self.request, message) + return False + + if user.is_superuser: + return True + + if not access_agreement_signed(user): + message = ( + 'Please sign the User Access Agreement before requesting a new ' + 'secure directory.') + messages.error(message) + return False + + return True + + +class SecureDirRequestLandingView(LoginRequiredMixin, + NewSecureDirRequestViewAccessibilityMixin, + TemplateView): + """A view for the secure directory request landing page.""" + + template_name = \ + 'secure_dir/secure_dir_request/secure_dir_request_landing.html' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._project_obj = None + + def dispatch(self, request, *args, **kwargs): + self._project_obj = get_object_or_404(Project, pk=kwargs['pk']) + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['project'] = self._project_obj + return context + + +class SecureDirRequestWizard(LoginRequiredMixin, + NewSecureDirRequestViewAccessibilityMixin, + SessionWizardView): + + FORMS = [ + ('pi_selection', SecureDirPISelectionForm), + ('data_description', SecureDirDataDescriptionForm), + ('rdm_consultation', SecureDirRDMConsultationForm), + ('directory_name', SecureDirDirectoryNamesForm) + ] + + TEMPLATES = { + 'pi_selection': 'secure_dir/secure_dir_request/pi_selection.html', + 'data_description': + 'secure_dir/secure_dir_request/data_description.html', + 'rdm_consultation': + 'secure_dir/secure_dir_request/rdm_consultation.html', + 'directory_name': 'secure_dir/secure_dir_request/directory_name.html' + } + + form_list = [ + SecureDirPISelectionForm, + SecureDirDataDescriptionForm, + SecureDirRDMConsultationForm, + SecureDirDirectoryNamesForm + ] + + logger = logging.getLogger(__name__) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Define a lookup table from form name to step number. + self.step_numbers_by_form_name = { + name: i for i, (name, _) in enumerate(self.FORMS)} + self._project = None + + def dispatch(self, request, *args, **kwargs): + self._project = get_object_or_404(Project, pk=kwargs.get('pk', None)) + # The inherited NewSecureDirRequestViewAccessibilityMixin ensures that + # any Project with an existing secure directory or a request to create + # one is denied access to this page. + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, form, **kwargs): + context = super().get_context_data(form=form, **kwargs) + current_step = int(self.steps.current) + self._set_data_from_previous_steps(current_step, context) + + groups_path, scratch_path = get_default_secure_dir_paths() + context['groups_path'] = groups_path + context['scratch_path'] = scratch_path + context['directory_name_prefix'] = SECURE_DIRECTORY_NAME_PREFIX + + return context + + def get_form_kwargs(self, step=None): + kwargs = {} + step = int(step) + if step == self.step_numbers_by_form_name['pi_selection']: + kwargs['project_pk'] = self._project.pk + return kwargs + + def get_template_names(self): + return [self.TEMPLATES[self.FORMS[int(self.steps.current)][0]]] + + def done(self, form_list, **kwargs): + """Run the runner for handling a new request.""" + try: + form_data = session_wizard_all_form_data( + form_list, kwargs['form_dict'], len(self.form_list)) + request_kwargs = self._get_secure_dir_request_kwargs(form_data) + secure_dir_request_runner = SecureDirRequestRunner(request_kwargs) + secure_dir_request_runner.run() + except Exception as e: + self.logger.exception(e) + message = 'Unexpected failure. Please contact an administrator.' + messages.error(self.request, message) + else: + message = ( + 'Thank you for your submission. It will be reviewed and ' + 'processed by administrators.') + messages.success(self.request, message) + redirect_url = '/' + return HttpResponseRedirect(redirect_url) + + @staticmethod + def condition_dict(): + """Return a mapping from a string index `i` into FORMS + (zero-indexed) to a function determining whether FORMS[int(i)] + should be included.""" + view = SecureDirRequestWizard + return { + '2': view.show_rdm_consultation_form_condition, + } + + def show_rdm_consultation_form_condition(self): + step_name = 'data_description' + step = str(self.step_numbers_by_form_name[step_name]) + cleaned_data = self.get_cleaned_data_for_step(step) or {} + return cleaned_data.get('rdm_consultation', False) + + def _get_secure_dir_request_kwargs(self, form_data): + """Return keyword arguments needed to create a SecureDirRequest + from the HttpRequest and provided form data. + + Note that the status need not be given because it is set by the + runner. + """ + return { + 'requester': self.request.user, + 'pi': self._get_pi_user(form_data), + 'department': self._get_department(form_data), + 'data_description': self._get_data_description(form_data), + 'rdm_consultation': self._get_rdm_consultation(form_data), + 'project': self._project, + 'directory_name': self._get_directory_name(form_data), + 'request_time': utc_now_offset_aware(), + } + + def _get_department(self, form_data): + """Return the department that the user submitted.""" + step_number = self.step_numbers_by_form_name['data_description'] + data = form_data[step_number] + return data.get('department') + + def _get_data_description(self, form_data): + """Return the data description the user submitted.""" + step_number = self.step_numbers_by_form_name['data_description'] + data = form_data[step_number] + return data.get('data_description') + + def _get_rdm_consultation(self, form_data): + """Return the consultants the user spoke to.""" + step_number = self.step_numbers_by_form_name['rdm_consultation'] + data = form_data[step_number] + return data.get('rdm_consultants', None) + + def _get_directory_name(self, form_data): + """Return the name of the directory.""" + step_number = self.step_numbers_by_form_name['directory_name'] + data = form_data[step_number] + return data.get('directory_name', None) + + def _get_pi_user(self, form_data): + """Return the selected PI User object.""" + step_number = self.step_numbers_by_form_name['pi_selection'] + data = form_data[step_number] + pi_project_user = data['pi'] + return pi_project_user.user + + def _set_data_from_previous_steps(self, step, dictionary): + """Update the given dictionary with data from previous steps.""" + dictionary['breadcrumb_project'] = f'Project: {self._project.name}' + + pi_selection_step = self.step_numbers_by_form_name['pi_selection'] + if step > pi_selection_step: + pi_selection_form_data = self.get_cleaned_data_for_step( + str(pi_selection_step)) + dictionary['breadcrumb_pi'] = ( + f'PI: {pi_selection_form_data["pi"].user.username}') + + rdm_consultation_step = self.step_numbers_by_form_name[ + 'rdm_consultation'] + if step > rdm_consultation_step: + rdm_consultation_form_data = self.get_cleaned_data_for_step( + str(rdm_consultation_step)) + has_consulted_rdm = ( + 'Yes' if rdm_consultation_form_data else 'No') + dictionary['breadcrumb_rdm_consultation'] = ( + f'Consulted RDM: {has_consulted_rdm}') diff --git a/coldfront/core/allocation/views_/secure_dir_views/user_management/__init__.py b/coldfront/core/allocation/views_/secure_dir_views/user_management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/coldfront/core/allocation/views_/secure_dir_views/user_management/approval_views.py b/coldfront/core/allocation/views_/secure_dir_views/user_management/approval_views.py new file mode 100644 index 000000000..59b87e5c9 --- /dev/null +++ b/coldfront/core/allocation/views_/secure_dir_views/user_management/approval_views.py @@ -0,0 +1,531 @@ +import logging + +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.mixins import UserPassesTestMixin +from django.core.paginator import EmptyPage +from django.core.paginator import PageNotAnInteger +from django.core.paginator import Paginator +from django.db import transaction +from django.db.models import Q +from django.http import HttpResponseRedirect +from django.shortcuts import get_object_or_404 +from django.urls import reverse +from django.views.generic import FormView +from django.views.generic import ListView +from django.views.generic.base import View + +from coldfront.core.allocation.forms_.secure_dir_forms import SecureDirManageUsersRequestCompletionForm +from coldfront.core.allocation.forms_.secure_dir_forms import SecureDirManageUsersRequestUpdateStatusForm +from coldfront.core.allocation.forms_.secure_dir_forms import SecureDirManageUsersSearchForm +from coldfront.core.allocation.models import AllocationUser +from coldfront.core.allocation.models import AllocationUserStatusChoice +from coldfront.core.allocation.utils_.secure_dir_utils.user_management import get_secure_dir_manage_user_request_objects + +from coldfront.core.utils.common import utc_now_offset_aware +from coldfront.core.utils.mail import send_email_template + + +logger = logging.getLogger(__name__) + + +class SecureDirManageUsersRequestListView(LoginRequiredMixin, + UserPassesTestMixin, + ListView): + template_name = 'secure_dir/secure_dir_manage_user_request_list.html' + paginate_by = 30 + + def test_func(self): + """UserPassesTestMixin tests.""" + if self.request.user.is_superuser: + return True + + if self.request.user.has_perm( + 'allocation.view_securediradduserrequest') and \ + self.request.user.has_perm( + 'allocation.view_securedirremoveuserrequest'): + return True + + message = ( + f'You do not have permission to review secure directory ' + f'{self.action} user requests.') + messages.error(self.request, message) + + def dispatch(self, request, *args, **kwargs): + get_secure_dir_manage_user_request_objects(self, + self.kwargs.get('action')) + self.status = self.kwargs.get('status') + self.completed = self.status == 'completed' + return super().dispatch(request, *args, **kwargs) + + def get_queryset(self): + order_by = self.request.GET.get('order_by') + if order_by: + direction = self.request.GET.get('direction') + if direction == 'asc': + direction = '' + else: + direction = '-' + order_by = direction + order_by + else: + order_by = '-modified' + + pending_status = self.request_status_obj.objects.filter( + Q(name__icontains='Pending') | Q(name__icontains='Processing')) + + complete_status = self.request_status_obj.objects.filter( + name__in=['Complete', 'Denied']) + + secure_dir_request_search_form = \ + SecureDirManageUsersSearchForm(self.request.GET) + + if self.completed: + request_list = self.request_obj.objects.filter( + status__in=complete_status) + else: + request_list = self.request_obj.objects.filter( + status__in=pending_status) + + if secure_dir_request_search_form.is_valid(): + data = secure_dir_request_search_form.cleaned_data + + if data.get('username'): + request_list = request_list.filter( + user__username__icontains=data.get('username')) + + if data.get('email'): + request_list = request_list.filter( + user__email__icontains=data.get('email')) + + if data.get('project_name'): + request_list = \ + request_list.filter( + allocation__project__name__icontains=data.get( + 'project_name')) + + if data.get('directory_name'): + request_list = \ + request_list.filter( + directory__icontains=data.get( + 'directory_name')) + + return request_list.order_by(order_by) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + secure_dir_request_search_form = \ + SecureDirManageUsersSearchForm(self.request.GET) + if secure_dir_request_search_form.is_valid(): + data = secure_dir_request_search_form.cleaned_data + filter_parameters = '' + for key, value in data.items(): + if value: + if isinstance(value, list): + for ele in value: + filter_parameters += '{}={}&'.format(key, ele) + else: + filter_parameters += '{}={}&'.format(key, value) + context['secure_dir_request_search_form'] = \ + secure_dir_request_search_form + else: + filter_parameters = None + context['secure_dir_request_search_form'] = \ + SecureDirManageUsersSearchForm() + + order_by = self.request.GET.get('order_by') + if order_by: + direction = self.request.GET.get('direction') + filter_parameters_with_order_by = filter_parameters + \ + 'order_by=%s&direction=%s&' % ( + order_by, direction) + else: + filter_parameters_with_order_by = filter_parameters + + if filter_parameters: + context['expand_accordion'] = 'show' + else: + context['expand_accordion'] = 'toggle' + + context['filter_parameters'] = filter_parameters + context['filter_parameters_with_order_by'] = \ + filter_parameters_with_order_by + + context['request_filter'] = ( + 'completed' if self.completed else 'pending') + + request_list = self.get_queryset() + paginator = Paginator(request_list, self.paginate_by) + + page = self.request.GET.get('page') + + try: + request_list = paginator.page(page) + except PageNotAnInteger: + request_list = paginator.page(1) + except EmptyPage: + request_list = paginator.page(paginator.num_pages) + + context['request_list'] = request_list + + context['actions_visible'] = not self.completed + + context['action'] = self.action + + context['preposition'] = self.language_dict['preposition'] + + return context + + +class SecureDirManageUsersUpdateStatusView(LoginRequiredMixin, + UserPassesTestMixin, + FormView): + form_class = SecureDirManageUsersRequestUpdateStatusForm + template_name = \ + 'secure_dir/secure_dir_manage_user_request_update_status.html' + + def test_func(self): + """UserPassesTestMixin tests.""" + if self.request.user.is_superuser: + return True + + if self.request.user.has_perm( + 'allocation.change_securediradduserrequest') or \ + self.request.user.has_perm( + 'allocation.change_securedirremoveuserrequest'): + return True + + message = ( + 'You do not have permission to update secure directory ' + 'join or removal requests.') + messages.error(self.request, message) + + def dispatch(self, request, *args, **kwargs): + get_secure_dir_manage_user_request_objects(self, + self.kwargs.get('action')) + self.secure_dir_request = get_object_or_404( + self.request_obj, pk=self.kwargs.get('pk')) + + return super().dispatch(request, *args, **kwargs) + + def form_valid(self, form): + cur_status = self.secure_dir_request.status.name + if 'Pending' not in cur_status: + message = f'Secure directory user {self.language_dict["noun"]} ' \ + f'request has unexpected status "{cur_status}."' + messages.error(self.request, message) + return HttpResponseRedirect( + reverse('secure-dir-manage-users-request-list', + kwargs={'action': self.action, 'status': 'pending'})) + + form_data = form.cleaned_data + status = form_data.get('status') + + secure_dir_status_choice = \ + self.request_status_obj.objects.filter( + name__icontains=status).first() + self.secure_dir_request.status = secure_dir_status_choice + self.secure_dir_request.save() + + message = ( + f'Secure directory {self.language_dict["noun"]} request for user ' + f'{self.secure_dir_request.user.username} for ' + f'{self.secure_dir_request.directory} has been ' + f'marked as "{status}".') + messages.success(self.request, message) + + return super().form_valid(form) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['request'] = self.secure_dir_request + context['action'] = self.action + context['noun'] = self.language_dict['noun'] + context['step'] = 'pending' + return context + + def get_initial(self): + initial = { + 'status': self.secure_dir_request.status.name, + } + return initial + + def get_success_url(self): + return reverse('secure-dir-manage-users-request-list', + kwargs={'action': self.action, 'status': 'pending'}) + + +class SecureDirManageUsersCompleteStatusView(LoginRequiredMixin, + UserPassesTestMixin, + FormView): + form_class = SecureDirManageUsersRequestCompletionForm + template_name = \ + 'secure_dir/secure_dir_manage_user_request_update_status.html' + + # TODO: Much of the code in this and the denial view is duplicated. + # Some uncommitted utility classes are in the progress of being + # written to house the business logic and minimize duplication. + + def test_func(self): + """UserPassesTestMixin tests.""" + if self.request.user.is_superuser: + return True + + if self.request.user.has_perm( + 'allocation.change_securediradduserrequest') or \ + self.request.user.has_perm( + 'allocation.change_securedirremoveuserrequest'): + return True + + message = ( + 'You do not have permission to update secure directory ' + 'join or removal requests.') + messages.error(self.request, message) + + def dispatch(self, request, *args, **kwargs): + get_secure_dir_manage_user_request_objects(self, + self.kwargs.get('action')) + self.secure_dir_request = get_object_or_404( + self.request_obj, pk=self.kwargs.get('pk')) + return super().dispatch(request, *args, **kwargs) + + def form_valid(self, form): + cur_status = self.secure_dir_request.status.name + if 'Processing' not in cur_status: + message = f'Secure directory user {self.language_dict["noun"]} ' \ + f'request has unexpected status "{cur_status}."' + messages.error(self.request, message) + return HttpResponseRedirect( + reverse(f'secure-dir-manage-users-request-list', + kwargs={'action': self.action, 'status': 'pending'})) + + form_data = form.cleaned_data + status = form_data.get('status') + complete = 'Complete' in status + + with transaction.atomic(): + secure_dir_status_choice = \ + self.request_status_obj.objects.filter( + name__icontains=status).first() + self.secure_dir_request.status = secure_dir_status_choice + if complete: + self.secure_dir_request.completion_time = utc_now_offset_aware() + self.secure_dir_request.save() + + if complete: + if self.add_bool: + active_allocation_user_status = \ + AllocationUserStatusChoice.objects.get(name='Active') + else: + active_allocation_user_status = \ + AllocationUserStatusChoice.objects.get(name='Removed') + AllocationUser.objects.update_or_create( + allocation=self.secure_dir_request.allocation, + user=self.secure_dir_request.user, + defaults={'status': active_allocation_user_status}) + + try: + self._send_emails() + except Exception as e: + logger.exception( + f'Failed to send notification emails. Details:\n{e}') + message = 'Failed to send notification emails to users.' + messages.error(self.request, message) + + message = ( + f'Secure directory {self.language_dict["noun"]} request for user ' + f'{self.secure_dir_request.user.username} for ' + f'{self.secure_dir_request.directory} has been marked ' + f'as "{status}".') + messages.success(self.request, message) + + return super().form_valid(form) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['request'] = self.secure_dir_request + context['action'] = self.action + context['noun'] = self.language_dict['noun'] + context['step'] = 'processing' + return context + + def get_initial(self): + initial = { + 'status': self.secure_dir_request.status.name, + } + return initial + + def get_success_url(self): + return reverse(f'secure-dir-manage-users-request-list', + kwargs={'action': self.action, 'status': 'pending'}) + + def _send_emails(self): + """Send notification emails to the user and relevant project + administrators.""" + if not settings.EMAIL_ENABLED: + return + + managed_user = self.secure_dir_request.user + + context = { + 'center_name': settings.CENTER_NAME, + 'managed_user_str': ( + f'{managed_user.first_name} {managed_user.last_name} ' + f'({managed_user.username})'), + 'verb': self.language_dict['verb'], + 'preposition': self.language_dict['preposition'], + 'directory': self.secure_dir_request.directory, + 'removed': 'now' if self.add_bool else 'no longer', + 'signature': settings.EMAIL_SIGNATURE, + 'support_email': settings.CENTER_HELP_EMAIL, + } + + subject = ( + f'Secure Directory {self.language_dict["noun"].title()} Request ' + f'Complete') + template_name = ( + 'email/secure_dir_request/' + 'secure_dir_manage_user_request_complete.txt') + sender = settings.EMAIL_SENDER + receiver_list = [managed_user.email] + + # Include project administrators on the email. + users_to_cc = set() + allocation = self.secure_dir_request.allocation + project = allocation.project + pis = [project_user.user for project_user in project.pis_to_email()] + users_to_cc.update(set(pis)) + managers = project.managers(active_only=True) + active_allocation_user_status = \ + AllocationUserStatusChoice.objects.get(name='Active') + for manager in managers: + manager_in_directory = AllocationUser.objects.filter( + allocation=allocation, + user=manager, + status=active_allocation_user_status).exists() + if manager_in_directory: + users_to_cc.add(manager) + + kwargs = {} + if users_to_cc: + kwargs['cc'] = [user.email for user in users_to_cc] + + send_email_template( + subject, template_name, context, sender, receiver_list, **kwargs) + + +class SecureDirManageUsersDenyRequestView(LoginRequiredMixin, + UserPassesTestMixin, + View): + + # TODO: Much of the code in this and the complete view is + # duplicated. Some uncommitted utility classes are in the progress + # of being written to house the business logic and minimize + # duplication. + + def test_func(self): + """UserPassesTestMixin tests.""" + if self.request.user.is_superuser: + return True + + message = ( + 'You do not have permission to deny a secure directory join or ' + 'removal request.') + messages.error(self.request, message) + + def dispatch(self, request, *args, **kwargs): + get_secure_dir_manage_user_request_objects(self, + self.kwargs.get('action')) + self.secure_dir_request = get_object_or_404( + self.request_obj, pk=self.kwargs.get('pk')) + return super().dispatch(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + status = self.secure_dir_request.status.name + if ('Processing' not in status) and ('Pending' not in status): + message = f'Secure directory user {self.language_dict["noun"]} ' \ + f'request has unexpected status "{status}."' + messages.error(request, message) + return HttpResponseRedirect( + reverse(f'secure-dir-manage-users-request-list', + kwargs={'action': self.action, 'status': 'pending'})) + + reason = self.request.POST['reason'] + self.secure_dir_request.status = \ + self.request_status_obj.objects.get(name='Denied') + self.secure_dir_request.completion_time = utc_now_offset_aware() + self.secure_dir_request.save() + + message = ( + f'Secure directory {self.language_dict["noun"]} request for user ' + f'{self.secure_dir_request.user.username} for the secure directory ' + f'{self.secure_dir_request.directory} has been ' + f'denied.') + messages.success(request, message) + + try: + self._send_emails(reason) + except Exception as e: + logger.exception( + f'Failed to send notification emails. Details:\n{e}') + message = 'Failed to send notification emails to users.' + messages.error(self.request, message) + + return HttpResponseRedirect( + reverse(f'secure-dir-manage-users-request-list', + kwargs={'action': self.action, 'status': 'pending'})) + + def _send_emails(self, denial_reason): + """Send notification emails to the user and relevant project + administrators.""" + if not settings.EMAIL_ENABLED: + return + + managed_user = self.secure_dir_request.user + + context = { + 'center_name': settings.CENTER_NAME, + 'managed_user_str': ( + f'{managed_user.first_name} {managed_user.last_name} ' + f'({managed_user.username})'), + 'verb': self.language_dict['verb'], + 'preposition': self.language_dict['preposition'], + 'directory': self.secure_dir_request.directory, + 'reason': denial_reason, + 'signature': settings.EMAIL_SIGNATURE, + 'support_email': settings.CENTER_HELP_EMAIL, + } + + subject = ( + f'Secure Directory {self.language_dict["noun"].title()} Request ' + f'Denied') + template_name = ( + 'email/secure_dir_request/' + 'secure_dir_manage_user_request_denied.txt') + sender = settings.EMAIL_SENDER + receiver_list = [managed_user.email] + + # Include project administrators on the email. + users_to_cc = set() + allocation = self.secure_dir_request.allocation + project = allocation.project + pis = [project_user.user for project_user in project.pis_to_email()] + users_to_cc.update(set(pis)) + managers = project.managers(active_only=True) + active_allocation_user_status = \ + AllocationUserStatusChoice.objects.get(name='Active') + for manager in managers: + manager_in_directory = AllocationUser.objects.filter( + allocation=allocation, + user=manager, + status=active_allocation_user_status).exists() + if manager_in_directory: + users_to_cc.add(manager) + + kwargs = {} + if users_to_cc: + kwargs['cc'] = [user.email for user in users_to_cc] + + send_email_template( + subject, template_name, context, sender, receiver_list, **kwargs) diff --git a/coldfront/core/allocation/views_/secure_dir_views/user_management/request_views.py b/coldfront/core/allocation/views_/secure_dir_views/user_management/request_views.py new file mode 100644 index 000000000..7b544d44d --- /dev/null +++ b/coldfront/core/allocation/views_/secure_dir_views/user_management/request_views.py @@ -0,0 +1,192 @@ +import logging + +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.mixins import UserPassesTestMixin +from django.contrib.auth.models import User +from django.db import transaction +from django.forms import formset_factory +from django.http import HttpResponseRedirect +from django.shortcuts import get_object_or_404 +from django.urls import reverse +from django.views.generic.base import TemplateView + +from coldfront.core.allocation.forms_.secure_dir_forms import SecureDirManageUsersForm +from coldfront.core.allocation.models import Allocation + +from coldfront.core.allocation.utils_.secure_dir_utils import SecureDirectory +from coldfront.core.allocation.utils_.secure_dir_utils.user_management import get_secure_dir_manage_user_request_objects +from coldfront.core.allocation.utils_.secure_dir_utils.user_management import SecureDirectoryManageUserRequestRunnerFactory + +from coldfront.core.utils.email.email_strategy import EnqueueEmailStrategy + + +logger = logging.getLogger(__name__) + + +class SecureDirManageUsersView(LoginRequiredMixin, UserPassesTestMixin, + TemplateView): + + template_name = 'secure_dir/secure_dir_manage_users.html' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self._allocation_obj = None + self._secure_directory = None + + # These attributes are set by get_secure_dir_manage_user_request_objects + # in the dispatch method. TODO: Use a mixin instead. + self.action = None + self.add_bool = None + self.request_obj = None + self.request_status_obj = None + self.language_dict = None + + def test_func(self): + """Allow users with permissions to manage the directory to + manage users.""" + allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get('pk')) + secure_directory = SecureDirectory(allocation_obj) + return secure_directory.user_can_manage(self.request.user) + + def dispatch(self, request, *args, **kwargs): + pk = self.kwargs.get('pk') + self._allocation_obj = get_object_or_404(Allocation, pk=pk) + + if self._allocation_obj.status.name != 'Active': + message = 'You may only manage users under an active directory.' + messages.error(request, message) + return self._redirect_to_directory_allocation_detail() + + self._secure_directory = SecureDirectory(self._allocation_obj) + + # Set instance attributes based on the specified action. + get_secure_dir_manage_user_request_objects( + self, self.kwargs.get('action')) + + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + if self.add_bool: + users = self._secure_directory.get_addable_users() + else: + users = self._secure_directory.get_removable_users() + user_list = self._get_user_data(users) + + if user_list: + formset = formset_factory( + SecureDirManageUsersForm, max_num=len(user_list)) + formset = formset(initial=user_list, prefix='userform') + context['formset'] = formset + + context['action'] = self.action + context['preposition'] = self.language_dict['preposition'] + context['directory'] = self._secure_directory.get_path() + context['manage_users_url'] = reverse( + 'secure-dir-manage-users', + kwargs={'pk': self._allocation_obj.pk, 'action': self.action}) + context['allocation_url'] = reverse( + 'allocation-detail', kwargs={'pk': self._allocation_obj.pk}) + context['button_class'] = ( + 'btn-success' if self.add_bool else 'btn-danger') + + return context + + def post(self, request, *args, **kwargs): + pk = self.kwargs.get('pk') + alloc_obj = get_object_or_404(Allocation, pk=pk) + + secure_directory = SecureDirectory(alloc_obj) + if self.add_bool: + users = secure_directory.get_addable_users() + else: + users = secure_directory.get_removable_users() + user_list = self._get_user_data(users) + + formset = formset_factory( + SecureDirManageUsersForm, max_num=len(user_list)) + formset = formset( + request.POST, initial=user_list, prefix='userform') + + if formset.is_valid(): + try: + selected_user_objs = self._get_selected_users(formset) + self._process_users(secure_directory, selected_user_objs) + except Exception as e: + logger.exception(e) + message = 'Unexpected failure. Please contact an administrator.' + messages.error(request, message) + else: + num_users = len(selected_user_objs) + message = ( + f'Successfully requested to {self.action} {num_users} ' + f'user(s) {self.language_dict["preposition"]} the secure ' + f'directory {secure_directory.get_path()}.') + messages.success(request, message) + else: + for error in formset.errors: + messages.error(request, error) + + return self._redirect_to_directory_allocation_detail() + + def _redirect_to_directory_allocation_detail(self): + """Return a redirect to the detail view for the Allocation + representing the secure directory.""" + url = reverse( + 'allocation-detail', kwargs={'pk': self._allocation_obj.pk}) + return HttpResponseRedirect(url) + + @staticmethod + def _get_selected_users(formset): + """Given a formset containing usernames that may have been + selected, return the corresponding User objects of the ones that + were.""" + user_objs = [] + for form in formset: + user_form_data = form.cleaned_data + if user_form_data.get('selected', False): + user_obj = User.objects.get( + username=user_form_data.get('username')) + user_objs.append(user_obj) + return user_objs + + @staticmethod + def _get_user_data(users): + """Given a queryset of Users, return a list of dicts containing + data about each User.""" + user_data_list = [] + for user in users: + user_data = { + 'username': user.username, + 'first_name': user.first_name, + 'last_name': user.last_name, + 'email': user.email + } + user_data_list.append(user_data) + return user_data_list + + def _process_users(self, secure_directory, user_objs): + """Given a list of User objects that were selected to be + added/removed to/from the given SecureDirectory, process them in + an atomic transaction. + + Only send emails if all succeeded. + """ + email_strategy = EnqueueEmailStrategy() + + with transaction.atomic(): + for user_obj in user_objs: + runner_factory = SecureDirectoryManageUserRequestRunnerFactory() + runner = runner_factory.get_runner( + self.action, secure_directory, user_obj, + email_strategy=email_strategy) + runner.run() + + try: + email_strategy.send_queued_emails() + except Exception as e: + logger.exception(e) diff --git a/coldfront/core/project/models.py b/coldfront/core/project/models.py index 5cd8fc0b0..5759f1d76 100644 --- a/coldfront/core/project/models.py +++ b/coldfront/core/project/models.py @@ -161,18 +161,26 @@ def needs_review(self): # # return False - def pis(self): + def pis(self, active_only=False): """Return a queryset of User objects that are PIs on this - project, ordered by username.""" + project, ordered by username. Optionally return only active + PIs.""" + kwargs = {'role__name': 'Principal Investigator'} + if active_only: + kwargs['status__name'] = 'Active' pi_user_pks = self.projectuser_set.filter( - role__name='Principal Investigator').values_list('user', flat=True) + **kwargs).values_list('user', flat=True) return User.objects.filter(pk__in=pi_user_pks).order_by('username') - def managers(self): + def managers(self, active_only=False): """Return a queryset of User objects that are Managers on this - project, ordered by username.""" + project, ordered by username. Optionally return only active + Managers.""" + kwargs = {'role__name': 'Manager'} + if active_only: + kwargs['status__name'] = 'Active' manager_user_pks = self.projectuser_set.filter( - role__name='Manager').values_list('user', flat=True) + **kwargs).values_list('user', flat=True) return User.objects.filter( pk__in=manager_user_pks).order_by('username') @@ -201,17 +209,13 @@ def managers_and_pis_to_email(self): return self.projectuser_set.filter( pi_condition | manager_condition).distinct() - def pis_emails(self): - """Returns a list of emails belonging to active PIs that have + def pis_to_email(self): + """Return a queryset of Active PI ProjectUsers who have enable_notifications=True.""" pi_condition = Q( role__name='Principal Investigator', status__name='Active', enable_notifications=True) - - return list( - self.projectuser_set.filter( - pi_condition - ).distinct().values_list('user__email', flat=True)) + return self.projectuser_set.filter(pi_condition).distinct() def __str__(self): return self.name diff --git a/coldfront/core/project/templates/project/project_detail.html b/coldfront/core/project/templates/project/project_detail.html index 80f214a89..e3192162f 100644 --- a/coldfront/core/project/templates/project/project_detail.html +++ b/coldfront/core/project/templates/project/project_detail.html @@ -2,7 +2,6 @@ {% load crispy_forms_tags %} {% load humanize %} {% load static %} -{% load feature_flags %} {% block title %} @@ -47,8 +46,7 @@

M Archive Project {% endif %} {% endif %} - {% flag_enabled 'SECURE_DIRS_REQUESTABLE' as secure_dirs_requestable %} - {% if secure_dirs_requestable and can_request_sec_dir %} + {% if request_secure_directory_visible %} Request a Secure Directory diff --git a/coldfront/core/project/urls.py b/coldfront/core/project/urls.py index 14cb672df..68e1be9f2 100644 --- a/coldfront/core/project/urls.py +++ b/coldfront/core/project/urls.py @@ -3,6 +3,7 @@ from flags.urls import flagged_paths +import coldfront.core.allocation.views_.secure_dir_views.new_directory.request_views as secure_dir_new_directory_request_views import coldfront.core.project.views as project_views import coldfront.core.project.views_.addition_views.approval_views as addition_approval_views import coldfront.core.project.views_.addition_views.request_views as addition_request_views @@ -13,7 +14,6 @@ import coldfront.core.project.views_.removal_views as removal_views import coldfront.core.project.views_.renewal_views.approval_views as renewal_approval_views import coldfront.core.project.views_.renewal_views.request_views as renewal_request_views -import coldfront.core.allocation.views_.secure_dir_views as secure_dir_views import coldfront.core.utils.views.mou_views as mou_views @@ -256,12 +256,13 @@ # Request a secure directory with flagged_paths('SECURE_DIRS_REQUESTABLE') as path: flagged_url_patterns = [ + # New Directory Request Views path('/secure-dir-request-landing', - secure_dir_views.SecureDirRequestLandingView.as_view(), + secure_dir_new_directory_request_views.SecureDirRequestLandingView.as_view(), name='secure-dir-request-landing'), path('/secure-dir-request', - secure_dir_views.SecureDirRequestWizard.as_view( - condition_dict=secure_dir_views.SecureDirRequestWizard.condition_dict(), + secure_dir_new_directory_request_views.SecureDirRequestWizard.as_view( + condition_dict=secure_dir_new_directory_request_views.SecureDirRequestWizard.condition_dict(), ), name='secure-dir-request'), ] diff --git a/coldfront/core/project/views.py b/coldfront/core/project/views.py index 60ebc42f2..d66b8e944 100644 --- a/coldfront/core/project/views.py +++ b/coldfront/core/project/views.py @@ -24,8 +24,7 @@ from coldfront.core.allocation.utils import get_project_compute_allocation from coldfront.core.allocation.utils import get_project_compute_resource_name # from coldfront.core.grant.models import Grant -from coldfront.core.allocation.utils_.secure_dir_utils import \ - pi_eligible_to_request_secure_dir +from coldfront.core.allocation.utils_.secure_dir_utils.new_directory import is_project_eligible_for_secure_dirs from coldfront.core.billing.utils.queries import is_project_billing_id_required_and_missing from coldfront.core.project.forms import (ProjectAddUserForm, ProjectAddUsersToAllocationForm, @@ -48,6 +47,7 @@ from coldfront.core.project.utils_.addition_utils import can_project_purchase_service_units 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_.permissions_utils import is_user_manager_or_pi_of_project from coldfront.core.project.utils_.renewal_utils import get_current_allowance_year_period from coldfront.core.project.utils_.renewal_utils import is_any_project_pi_renewable from coldfront.core.resource.utils_.allowance_utils.computing_allowance import ComputingAllowance @@ -266,10 +266,14 @@ def get_context_data(self, **kwargs): context['cluster_name'] = get_project_compute_resource_name( self.object).replace(' Compute', '') - # Only active PIs of active FCAs, ICAs and Condos can request - # secure directories - context['can_request_sec_dir'] = \ - pi_eligible_to_request_secure_dir(self.request.user) + # Display the "Request a Secure Directory" button when the functionality + # is enabled, the project is eligible, and the user is allowed to update + # the project. + context['request_secure_directory_visible'] = ( + flag_enabled('SECURE_DIRS_REQUESTABLE') and + is_project_eligible_for_secure_dirs(self.object) and + (self.request.user.is_superuser or + context.get('is_allowed_to_update_project', False))) # show survey responses if available allocation_requests = SavioProjectAllocationRequest.objects.filter( diff --git a/coldfront/core/utils/mou.py b/coldfront/core/utils/mou.py index 61ce28a3c..b53a30091 100644 --- a/coldfront/core/utils/mou.py +++ b/coldfront/core/utils/mou.py @@ -34,17 +34,22 @@ def get_mou_filename(request_obj): from coldfront.core.allocation.models import SecureDirRequest from coldfront.core.project.models import SavioProjectAllocationRequest - fs = settings.FILE_STORAGE + project_name = request_obj.project.name + last_name = '' type_ = '' + + fs = settings.FILE_STORAGE if isinstance(request_obj, SavioProjectAllocationRequest): + last_name = request_obj.pi.last_name type_ += fs['details']['NEW_PROJECT_REQUEST_MOU']['filename_type'] elif isinstance(request_obj, AllocationAdditionRequest): + last_name = request_obj.requester.last_name type_ += fs[ 'details']['SERVICE_UNITS_PURCHASE_REQUEST_MOU']['filename_type'] elif isinstance(request_obj, SecureDirRequest): + last_name = request_obj.pi.last_name type_ += fs['details']['SECURE_DIRECTORY_REQUEST_MOU']['filename_type'] - project_name = request_obj.project.name - last_name = request_obj.requester.last_name + filename = f'{project_name}_{last_name}_{type_}.pdf' return filename diff --git a/coldfront/core/utils/tests/test_mou_notify_upload_download.py b/coldfront/core/utils/tests/test_mou_notify_upload_download.py index 73ed9656a..8b84bc1ec 100644 --- a/coldfront/core/utils/tests/test_mou_notify_upload_download.py +++ b/coldfront/core/utils/tests/test_mou_notify_upload_download.py @@ -294,20 +294,21 @@ def setUp(self): """Setup test data""" super().setUp() self.project = self.create_active_project_with_pi('fc_testproject', self.user) - self.request = self.create_secure_dir_request(self.project, self.user) - + self.request = self.create_secure_directory_request(self.project, self.user, self.user) @staticmethod - def create_secure_dir_request(project, requester): + def create_secure_directory_request(project, requester, pi): """Create an 'Under Review' request for the given Project by the - given requester.""" + given requester under the given PI.""" return SecureDirRequest.objects.create( - directory_name = 'test_dir', - data_description = 'test description', + directory_name='test_dir', + data_description='test description', requester=requester, + pi=pi, project=project, status=SecureDirRequestStatusChoice.objects.get( name='Under Review')) + @staticmethod def rdm_consultation_url(pk): return reverse('secure-dir-request-review-rdm-consultation', kwargs={'pk': pk}) @@ -317,7 +318,7 @@ def edit_department_url(pk): return reverse(f'secure-dir-request-edit-department', kwargs={'pk': pk}) @enable_deployment('BRC') - def test_allocation_addition(self): + def test_secure_dir(self): """Test that the MOU notification task, MOU upload, and MOU download features work as expected.""" request_type = 'secure-dir' diff --git a/coldfront/core/utils/views/mou_views.py b/coldfront/core/utils/views/mou_views.py index 115577df9..dfb00289f 100644 --- a/coldfront/core/utils/views/mou_views.py +++ b/coldfront/core/utils/views/mou_views.py @@ -60,16 +60,15 @@ def test_func(self): return True if self.request.user == self.request_obj.requester: return True - if (self.request_type == 'service-units-purchase' and - is_user_manager_or_pi_of_project( - self.request.user, self.request_obj.project)): - return True - if (self.request_type == 'secure-dir' and - self.request.user in self.request_obj.project.pis()): - return True - if (self.request_type == 'new-project' and - self.request.user == self.request_obj.pi): - return True + if self.request_type == 'service-units-purchase': + return is_user_manager_or_pi_of_project( + self.request.user, self.request_obj.project) + elif self.request_type == 'secure-dir': + return ( + self.request.user == self.request_obj.requester or + self.request.user == self.request_obj.pi) + elif self.request_type == 'new-project': + return self.request.user == self.request_obj.pi return False def dispatch(self, request, *args, **kwargs): @@ -165,10 +164,13 @@ def get(self, request, *args, **kwargs): elif self.request_type == 'secure-dir': request_type = 'secure-dir' mou_kwargs['department'] = self.request_obj.department - + if self.request_type == 'new-project': first_name = self.request_obj.pi.first_name last_name = self.request_obj.pi.last_name + elif self.request_type == 'secure-dir': + first_name = self.request_obj.pi.first_name + last_name = self.request_obj.pi.last_name else: first_name = self.request_obj.requester.first_name last_name = self.request_obj.requester.last_name diff --git a/coldfront/templates/email/secure_dir_request/new_secure_dir_add_user_request.txt b/coldfront/templates/email/secure_dir_request/new_secure_dir_add_user_request.txt new file mode 100644 index 000000000..34c01000a --- /dev/null +++ b/coldfront/templates/email/secure_dir_request/new_secure_dir_add_user_request.txt @@ -0,0 +1,3 @@ +There is a new request to add user {{ user_str }} to the secure directory {{ directory_name }}. + +Please handle the request here: {{ review_url }}. diff --git a/coldfront/templates/email/secure_dir_request/new_secure_dir_remove_user_request.txt b/coldfront/templates/email/secure_dir_request/new_secure_dir_remove_user_request.txt new file mode 100644 index 000000000..727924c55 --- /dev/null +++ b/coldfront/templates/email/secure_dir_request/new_secure_dir_remove_user_request.txt @@ -0,0 +1,3 @@ +There is a new request to remove user {{ user_str }} from the secure directory {{ directory_name }}. + +Please handle the request here: {{ review_url }}. diff --git a/coldfront/templates/email/secure_dir_request/pending_secure_dir_manage_user_requests.html b/coldfront/templates/email/secure_dir_request/pending_secure_dir_manage_user_requests.html deleted file mode 100644 index 2ddef8317..000000000 --- a/coldfront/templates/email/secure_dir_request/pending_secure_dir_manage_user_requests.html +++ /dev/null @@ -1,3 +0,0 @@ -There {{ verb }} {{ num_requests }} new secure directory user {{ noun }} request{{ plural }} for {{ directory_name }}. - -Please process {{ determiner }} request{{ plural }} here. \ No newline at end of file diff --git a/coldfront/templates/email/secure_dir_request/pending_secure_dir_manage_user_requests.txt b/coldfront/templates/email/secure_dir_request/pending_secure_dir_manage_user_requests.txt deleted file mode 100644 index 101eaf5b9..000000000 --- a/coldfront/templates/email/secure_dir_request/pending_secure_dir_manage_user_requests.txt +++ /dev/null @@ -1,3 +0,0 @@ -There {{ verb }} {{ num_requests }} new secure directory user {{ noun }} request{{ plural }} for {{ directory_name }}. - -Please process {{ determiner }} request{{ plural }} here. \ No newline at end of file diff --git a/coldfront/templates/email/secure_dir_request/secure_dir_manage_user_request_complete.txt b/coldfront/templates/email/secure_dir_request/secure_dir_manage_user_request_complete.txt index c9f2fc7fb..ea7863979 100644 --- a/coldfront/templates/email/secure_dir_request/secure_dir_manage_user_request_complete.txt +++ b/coldfront/templates/email/secure_dir_request/secure_dir_manage_user_request_complete.txt @@ -1,6 +1,6 @@ -Dear {{ user_first_name }} {{ user_last_name }}, +Dear {{ center_name }} user, -The request to {{ verb }} {{ managed_user_first_name }} {{ managed_user_last_name }} ({{ managed_user_username }}) {{ preposition }} the secure directory {{ directory }} has been completed. {{ managed_user_first_name }} {{ managed_user_last_name }} {{ removed }} has access to {{ directory }} on the cluster. +The request to {{ verb }} {{ managed_user_str }} {{ preposition }} the secure directory {{ directory }} has been completed. The user {{ removed }} has access to {{ directory }} on the cluster. If you have any questions, please contact us at {{ support_email }}. diff --git a/coldfront/templates/email/secure_dir_request/secure_dir_manage_user_request_denied.txt b/coldfront/templates/email/secure_dir_request/secure_dir_manage_user_request_denied.txt index ae9de5c67..a418956eb 100644 --- a/coldfront/templates/email/secure_dir_request/secure_dir_manage_user_request_denied.txt +++ b/coldfront/templates/email/secure_dir_request/secure_dir_manage_user_request_denied.txt @@ -1,6 +1,6 @@ -Dear {{ user_first_name }} {{ user_last_name }}, +Dear {{ center_name }} user, -The request to {{ verb }} {{ managed_user_first_name }} {{ managed_user_last_name }} ({{ managed_user_username }}) {{ preposition }} the secure directory {{ directory }} has been denied for the following reason: +The request to {{ verb }} {{ managed_user_str }} {{ preposition }} the secure directory {{ directory }} has been denied for the following reason: "{{ reason }}" If you have any questions, please contact us at {{ support_email }}. diff --git a/coldfront/templates/email/secure_dir_request/secure_dir_new_request_admin.txt b/coldfront/templates/email/secure_dir_request/secure_dir_new_request_admin.txt index d965fd30b..22ff74fae 100644 --- a/coldfront/templates/email/secure_dir_request/secure_dir_new_request_admin.txt +++ b/coldfront/templates/email/secure_dir_request/secure_dir_new_request_admin.txt @@ -1,3 +1,3 @@ -There is a new secure directory request for project {{ project_name }} requested by {{ requester_str }}. +There is a new secure directory request for project {{ project_name }} under PI {{ pi_str }} requested by {{ requester_str }}. Please review the request here: {{ review_url }}. diff --git a/coldfront/templates/email/secure_dir_request/secure_dir_new_request_pi.txt b/coldfront/templates/email/secure_dir_request/secure_dir_new_request_pi.txt index 7f4f31127..8aeabeb55 100644 --- a/coldfront/templates/email/secure_dir_request/secure_dir_new_request_pi.txt +++ b/coldfront/templates/email/secure_dir_request/secure_dir_new_request_pi.txt @@ -1,10 +1,10 @@ -Dear PI of {{ project_name }}, +Dear {{ pi_str }}, -There is a new secure directory request for project {{ project_name }} requested by {{ requester_str }}. +{{ requester_str }} has made a request to create a new secure directory under project {{ project_name }}, with you as the Principal Investigator (PI) via the {{ PORTAL_NAME }} User Portal. You may view the details of the request here: {{ review_url }}. -If you have any questions or concerns, please contact us at {{ support_email }}. +If you would like to prevent this, or have any questions, please contact us at {{ support_email }}. Thank you, {{ signature }} diff --git a/coldfront/templates/email/secure_dir_request/secure_dir_request_approved.txt b/coldfront/templates/email/secure_dir_request/secure_dir_request_approved.txt index ab71485f3..f2d54b7fa 100644 --- a/coldfront/templates/email/secure_dir_request/secure_dir_request_approved.txt +++ b/coldfront/templates/email/secure_dir_request/secure_dir_request_approved.txt @@ -2,7 +2,7 @@ Dear {{ user_first_name }} {{ user_last_name }}, Your request for a secure directory for project '{{ project }}' was approved. Setup on the cluster is complete. -The paths to your secure group and scratch directories are '{{ groups_dir }}' and '{{ scratch_dir }}', respectively. +The paths to your secure group and scratch directories are '{{ groups_dir_path }}' and '{{ scratch_dir_path }}', respectively. If you have any questions, please contact us at {{ support_email }}.