diff --git a/.github/ISSUE_TEMPLATE/bug-fix.yml b/.github/ISSUE_TEMPLATE/bug-fix.yml
new file mode 100644
index 0000000000..35b0da1d4d
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug-fix.yml
@@ -0,0 +1,60 @@
+name: Bug Report
+description: Tell us about the bug/ error that you are seeing.
+title: "Bug: "
+labels: ["bug"]
+body:
+ - type: textarea
+ id: what-happened
+ attributes:
+ label: What happened?
+ description: Also tell us, what did you expect to happen?
+ value: "A bug happened!"
+ validations:
+ required: true
+ - type: dropdown
+ id: version
+ attributes:
+ label: Version
+ description: What version of our software are you running?
+ options:
+ - 1.1.5
+ - 1.1.4
+ - 1.1.3
+ - 1.1.2
+ - 1.1.1
+ - 1.1.0 or before
+ - type: dropdown
+ id: component
+ attributes:
+ label: Component
+ multiple: true
+ description: What component of the website was this issue in?
+ options:
+ - Projects
+ - Allocations
+ - Resources
+ - Administration
+ - Users
+ - Other
+ - type: dropdown
+ id: browsers
+ attributes:
+ label: What browsers are you seeing the problem on?
+ options:
+ - Firefox
+ - Chrome
+ - Safari
+ - Microsoft Edge
+ - Other
+ - type: textarea
+ id: logs
+ attributes:
+ label: Relevant log output
+ description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
+ render: shell
+ - type: textarea
+ id: tasks
+ attributes:
+ label: Tasks/ user tests when bug is fixed
+ description: If relevant, add a list of tasks for anyone working on this issue and/ or tests for them to use when fixing the issue.
+ value: "- [ ] "
diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml
new file mode 100644
index 0000000000..20b137fe20
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature-request.yml
@@ -0,0 +1,31 @@
+name: Feature Request
+description: Tell us about what new feature you want to see added to ColdFront!
+title: "Feature: "
+labels: ["documentation", "enhancement"]
+body:
+ - type: textarea
+ id: description
+ attributes:
+ label: Description
+ description: Describe your feature idea!
+ validations:
+ required: true
+ - type: dropdown
+ id: component
+ attributes:
+ label: Component
+ multiple: true
+ description: What component of the website does this issue enhance?
+ options:
+ - Projects
+ - Allocations
+ - Resources
+ - Administration
+ - Users
+ - Other
+ - type: textarea
+ id: info
+ attributes:
+ label: Additional information
+ description: If you have any example code/ ideas you would like to show, paste them here. This will be automatically formatted into code, so no need for backticks.
+ render: shell
diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md
new file mode 100644
index 0000000000..baec4c297e
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md
@@ -0,0 +1,29 @@
+## Description
+
+###### Please include a summary of the change and which issue is fixed, if applicable.
+
+Fixes #(issue number here)
+
+## Component
+
+###### What components of the website does this PR apply to? Select all that apply.
+- [ ] Projects
+- [ ] Allocations
+- [ ] Resources
+- [ ] Administration
+- [ ] Users
+- [ ] Other
+
+## Testing
+
+###### If relevant, add a list of tests below for anyone reviewing this PR to run to simplify the testing process.
+
+- [ ] Test #1:
+- [ ] Test #2:
+- [ ] Test #3:
+
+...
+
+## Documentation
+
+- [ ] Check this box if this PR will require us to update our [documentation](https://coldfront.readthedocs.io/en/latest/).
diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml
new file mode 100644
index 0000000000..65d372dc5a
--- /dev/null
+++ b/.github/workflows/actions.yml
@@ -0,0 +1,33 @@
+name: Run Tests
+
+on:
+ push:
+ branches: [ main, staging ]
+ pull_request:
+ branches: [ main, staging ]
+ workflow_dispatch:
+
+jobs:
+ test:
+ runs-on: ubuntu-20.04
+ environment: test
+ steps:
+ - uses: actions/checkout@v3
+ - uses: actions/setup-python@v4
+ with:
+ python-version: '3.9'
+
+ - name: install python packages
+ run: |
+ pip install -r requirements.txt
+
+ - name: run tests
+ run: python3 manage.py test --exclude-tag integration -b
+ env:
+ ENVIRONMENT: test
+ PLUGIN_QUMULO: True
+ AD_USER_PASS: ADPassword@123
+ AD_USERNAME: ADUsername
+ AD_SERVER_NAME: accounts-ldap.wusm.wustl.edu
+ AD_GROUPS_OU: OU=QA,OU=RIS,OU=Groups,DC=accounts,DC=ad,DC=wustl,DC=edu
+ STORAGE2_PATH: /foo/bar
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 8754014b04..c1aa64ac4c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,7 +7,7 @@ build
*.DS_Store
*.swp
env/
-venv/
+*venv/
static_root/
db.sqlite3
local_data/
@@ -16,7 +16,6 @@ local_settings.py
local_strings.py
coldfront.db
*.code-workspace
-.vscode
db.json
.env
.devcontainer/*
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000000..f4c539886b
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,10 @@
+{
+ "[python]": {
+ "editor.defaultFormatter": "ms-python.black-formatter",
+ "editor.formatOnSave": true
+ },
+ "[javascript]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "editor.formatOnSave": true
+ }
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ac0e183b84..d3c2206f57 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -131,4 +131,5 @@
[1.1.2]: https://github.com/ubccr/coldfront/releases/tag/v1.1.2
[1.1.3]: https://github.com/ubccr/coldfront/releases/tag/v1.1.3
[1.1.4]: https://github.com/ubccr/coldfront/releases/tag/v1.1.4
-[Unreleased]: https://github.com/ubccr/coldfront/compare/v1.1.4...HEAD
+[1.1.5]: https://github.com/ubccr/coldfront/releases/tag/v1.1.5
+[Unreleased]: https://github.com/ubccr/coldfront/compare/v1.1.6...HEAD
diff --git a/Dockerfile b/Dockerfile
index 0a0afa7f03..cb550c69cb 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM python:3.6
+FROM python:3.8
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
diff --git a/MANIFEST.in b/MANIFEST.in
index e5bdd3ff21..923c975c45 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -11,3 +11,5 @@ recursive-include coldfront/core/publication/templates *
recursive-include coldfront/core/grant/templates *
recursive-include coldfront/core/user/templates *
recursive-include coldfront/core/resource/templates *
+recursive-include coldfront/plugins/qumulo/templates *
+recursive-include coldfront/plugins/qumulo/static *
diff --git a/README.md b/README.md
index f3cf1dbb43..22e68267f9 100644
--- a/README.md
+++ b/README.md
@@ -48,3 +48,59 @@ subscribe ccr-open-coldfront-list@listserv.buffalo.edu first_name last_name
## License
ColdFront is released under the GPLv3 license. See the LICENSE file.
+
+## Testing
+
+### Setup
+To run tests, the following variables should be included in a `.env` file in the root directory of the repo:
+
+```
+PLUGIN_QUMULO=True
+AD_SERVER_NAME=foo
+AD_USERNAME=bar
+AD_USER_PASS=bah
+STORAGE2_PATH=/foo/bar
+```
+
+### Running
+A complete test suite can be run with `manage.py test`. You can target sub-groups of tests by including a specifying argument. Ex: `manage.py.test coldfront.plugins.qumulo.tests` will run only unit tests for the qumulo plugin.
+
+Typically, you'll want to run non integration tests separately, which can be done with `python manage.py test --exclude-tag integration`. Integrations can be run with `python manage.py test --tag integration`.
+
+### Set up Local Environment and Run tests
+
+1. Go to the root of the `coldfront-wustl-fork` repo.
+2. Create a (Python) virtual environment: `python3 -mvenv coldfront-venv`
+3. Activate the virtual environment: `source coldfront-venv/bin/activate`
+4. Install the dependencies
+```
+pip install --upgrade pip
+pip install -r requirements-dev.txt
+```
+5. Run a test to verify the installation: `python manage.py test coldfront.plugins.qumulo.tests`
+
+**Steps for local development**
+```
+python3 -mvenv coldfront-venv
+source coldfront-venv/bin/activate
+pip install --upgrade pip
+pip install -r requirements-dev.txt
+python manage.py test coldfront.plugins.qumulo.tests
+```
+
+### Integration Test ENV
+Integration Tests need to be run while connected to a VPN. The following variables need to be included for functioning integration tests. Credentials should be stored in the `pass` store.
+
+```
+PLUGIN_QUMULO=True
+QUMULO_HOST=
+QUMULO_PORT=
+QUMULO_USER=
+QUMULO_PASS=
+DEBUG=TRUE
+AD_USER_PASS=
+AD_USERNAME=
+AD_SERVER_NAME=
+AD_GROUPS_OU=OU=QA,OU=RIS,OU=Groups,DC=accounts,DC=ad,DC=wustl,DC=edu
+STORAGE2_PATH=
+```
\ No newline at end of file
diff --git a/coldfront/config/plugins/ldap_user_search.py b/coldfront/config/plugins/ldap_user_search.py
index be6e0090f3..9197bf2649 100644
--- a/coldfront/config/plugins/ldap_user_search.py
+++ b/coldfront/config/plugins/ldap_user_search.py
@@ -6,14 +6,19 @@
except ImportError:
raise ImproperlyConfigured('Please run: pip install ldap3')
-#------------------------------------------------------------------------------
-# This enables searching for users via LDAP
-#------------------------------------------------------------------------------
+# ----------------------------------------------------------------------------
+# This enables searching for users via LDAP
+# ----------------------------------------------------------------------------
LDAP_USER_SEARCH_SERVER_URI = ENV.str('LDAP_USER_SEARCH_SERVER_URI')
LDAP_USER_SEARCH_BASE = ENV.str('LDAP_USER_SEARCH_BASE')
-LDAP_USER_SEARCH_BIND_DN = ENV.str('LDAP_USER_SEARCH_BIND_DN')
-LDAP_USER_SEARCH_BIND_PASSWORD = ENV.str('LDAP_USER_SEARCH_BIND_PASSWORD')
+LDAP_USER_SEARCH_BIND_DN = ENV.str('LDAP_USER_SEARCH_BIND_DN', default=None)
+LDAP_USER_SEARCH_BIND_PASSWORD = ENV.str('LDAP_USER_SEARCH_BIND_PASSWORD', default=None)
LDAP_USER_SEARCH_CONNECT_TIMEOUT = ENV.float('LDAP_USER_SEARCH_CONNECT_TIMEOUT', default=2.5)
LDAP_USER_SEARCH_USE_SSL = ENV.bool('LDAP_USER_SEARCH_USE_SSL', default=True)
-ADDITIONAL_USER_SEARCH_CLASSES = ['coldfront.plugins.ldap_user_search.utils.LDAPUserSearch',]
+LDAP_USER_SEARCH_USE_TLS = ENV.bool('LDAP_USER_SEARCH_USE_TLS', default=False)
+LDAP_USER_SEARCH_PRIV_KEY_FILE = ENV.str("LDAP_USER_SEARCH_PRIV_KEY_FILE", default=None)
+LDAP_USER_SEARCH_CERT_FILE = ENV.str("LDAP_USER_SEARCH_CERT_FILE", default=None)
+LDAP_USER_SEARCH_CACERT_FILE = ENV.str("LDAP_USER_SEARCH_CACERT_FILE", default=None)
+
+ADDITIONAL_USER_SEARCH_CLASSES = ['coldfront.plugins.ldap_user_search.utils.LDAPUserSearch']
diff --git a/coldfront/config/plugins/openid.py b/coldfront/config/plugins/openid.py
index 45971d0cb9..0a85665781 100644
--- a/coldfront/config/plugins/openid.py
+++ b/coldfront/config/plugins/openid.py
@@ -37,3 +37,4 @@
OIDC_OP_USER_ENDPOINT = ENV.str('OIDC_OP_USER_ENDPOINT')
OIDC_VERIFY_SSL = ENV.bool('OIDC_VERIFY_SSL', default=True)
OIDC_RENEW_ID_TOKEN_EXPIRY_SECONDS = ENV.int('OIDC_RENEW_ID_TOKEN_EXPIRY_SECONDS', default=3600)
+OIDC_USE_PKCE = ENV.bool('OIDC_USE_PKCE', default=False)
\ No newline at end of file
diff --git a/coldfront/config/plugins/qumulo.py b/coldfront/config/plugins/qumulo.py
new file mode 100644
index 0000000000..08aab314eb
--- /dev/null
+++ b/coldfront/config/plugins/qumulo.py
@@ -0,0 +1,16 @@
+from coldfront.config.base import (
+ INSTALLED_APPS,
+ TEMPLATES,
+ PROJECT_ROOT,
+ STATICFILES_DIRS,
+)
+
+INSTALLED_APPS += [
+ "coldfront.plugins.qumulo",
+]
+
+STATICFILES_DIRS += [
+ PROJECT_ROOT("coldfront/plugins/qumulo/static"),
+]
+
+TEMPLATES[0]["DIRS"] += [PROJECT_ROOT("coldfront/plugins/qumulo/templates")]
diff --git a/coldfront/config/settings.py b/coldfront/config/settings.py
index 1b0f06dd84..f13ca81b49 100644
--- a/coldfront/config/settings.py
+++ b/coldfront/config/settings.py
@@ -22,6 +22,7 @@
'PLUGIN_AUTH_OIDC': 'plugins/openid.py',
'PLUGIN_AUTH_LDAP': 'plugins/ldap.py',
'PLUGIN_LDAP_USER_SEARCH': 'plugins/ldap_user_search.py',
+ 'PLUGIN_QUMULO': 'plugins/qumulo.py',
}
# This allows plugins to be enabled via environment variables. Can alternatively
diff --git a/coldfront/config/urls.py b/coldfront/config/urls.py
index 353e3602ea..7aa38f2892 100644
--- a/coldfront/config/urls.py
+++ b/coldfront/config/urls.py
@@ -11,6 +11,7 @@
admin.site.site_header = 'ColdFront Administration'
admin.site.site_title = 'ColdFront Administration'
+
urlpatterns = [
path('admin/', admin.site.urls),
path('robots.txt', TemplateView.as_view(template_name='robots.txt', content_type='text/plain'), name="robots"),
@@ -36,3 +37,6 @@
if 'django_su.backends.SuBackend' in settings.AUTHENTICATION_BACKENDS:
urlpatterns.append(path('su/', include('django_su.urls')))
+
+if 'coldfront.plugins.qumulo' in settings.INSTALLED_APPS:
+ urlpatterns.append(path('qumulo/', include('coldfront.plugins.qumulo.urls'), name='qumulo'))
diff --git a/coldfront/core/allocation/forms.py b/coldfront/core/allocation/forms.py
index 9700244779..1218453b0c 100644
--- a/coldfront/core/allocation/forms.py
+++ b/coldfront/core/allocation/forms.py
@@ -2,19 +2,22 @@
from django.db.models.functions import Lower
from django.shortcuts import get_object_or_404
-from coldfront.core.allocation.models import (Allocation, AllocationAccount,
- AllocationAttributeType,
- AllocationAttribute,
- AllocationStatusChoice)
+from coldfront.core.allocation.models import (
+ Allocation,
+ AllocationAccount,
+ AllocationAttributeType,
+ AllocationAttribute,
+ AllocationStatusChoice,
+)
from coldfront.core.allocation.utils import get_user_resources
from coldfront.core.project.models import Project
from coldfront.core.resource.models import Resource, ResourceType
from coldfront.core.utils.common import import_from_settings
-ALLOCATION_ACCOUNT_ENABLED = import_from_settings(
- 'ALLOCATION_ACCOUNT_ENABLED', False)
+ALLOCATION_ACCOUNT_ENABLED = import_from_settings("ALLOCATION_ACCOUNT_ENABLED", False)
ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS = import_from_settings(
- 'ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS', [])
+ "ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS", []
+)
class AllocationForm(forms.Form):
@@ -22,52 +25,88 @@ class AllocationForm(forms.Form):
justification = forms.CharField(widget=forms.Textarea)
quantity = forms.IntegerField(required=True)
users = forms.MultipleChoiceField(
- widget=forms.CheckboxSelectMultiple, required=False)
+ widget=forms.CheckboxSelectMultiple, required=False
+ )
allocation_account = forms.ChoiceField(required=False)
- def __init__(self, request_user, project_pk, *args, **kwargs):
+ def __init__(self, request_user, project_pk, *args, **kwargs):
super().__init__(*args, **kwargs)
project_obj = get_object_or_404(Project, pk=project_pk)
- self.fields['resource'].queryset = get_user_resources(request_user).order_by(Lower("name"))
- self.fields['quantity'].initial = 1
- user_query_set = project_obj.projectuser_set.select_related('user').filter(
- status__name__in=['Active', ]).order_by("user__username")
+ self.fields["resource"].queryset = get_user_resources(request_user).order_by(
+ Lower("name")
+ )
+ self.fields["quantity"].initial = 1
+ user_query_set = (
+ project_obj.projectuser_set.select_related("user")
+ .filter(
+ status__name__in=[
+ "Active",
+ ]
+ )
+ .order_by("user__username")
+ )
user_query_set = user_query_set.exclude(user=project_obj.pi)
if user_query_set:
- self.fields['users'].choices = ((user.user.username, "%s %s (%s)" % (
- user.user.first_name, user.user.last_name, user.user.username)) for user in user_query_set)
- self.fields['users'].help_text = ' Select users in your project to add to this allocation.'
+ self.fields["users"].choices = (
+ (
+ user.user.username,
+ "%s %s (%s)"
+ % (user.user.first_name, user.user.last_name, user.user.username),
+ )
+ for user in user_query_set
+ )
+ self.fields["users"].help_text = (
+ " Select users in your project to add to this allocation."
+ )
else:
- self.fields['users'].widget = forms.HiddenInput()
+ self.fields["users"].widget = forms.HiddenInput()
if ALLOCATION_ACCOUNT_ENABLED:
- allocation_accounts = AllocationAccount.objects.filter(
- user=request_user)
+ allocation_accounts = AllocationAccount.objects.filter(user=request_user)
if allocation_accounts:
- self.fields['allocation_account'].choices = (((account.name, account.name))
- for account in allocation_accounts)
+ self.fields["allocation_account"].choices = (
+ ((account.name, account.name)) for account in allocation_accounts
+ )
- self.fields['allocation_account'].help_text = ' Select account name to associate with resource. Click here to create an account name! '
+ self.fields["allocation_account"].help_text = (
+ ' Select account name to associate with resource. Click here to create an account name! '
+ )
else:
- self.fields['allocation_account'].widget = forms.HiddenInput()
+ self.fields["allocation_account"].widget = forms.HiddenInput()
- self.fields['justification'].help_text = ' Justification for requesting this allocation.'
+ self.fields["justification"].help_text = (
+ " Justification for requesting this allocation."
+ )
class AllocationUpdateForm(forms.Form):
+ # jprew - NOTE: these are statuses we might want to use in the future,
+ # but we don't want User Support/other users using right now
status = forms.ModelChoiceField(
- queryset=AllocationStatusChoice.objects.all().order_by(Lower("name")), empty_label=None)
+ queryset=AllocationStatusChoice.objects.exclude(
+ name__in=[
+ "Payment Pending",
+ "Payment Requested",
+ "Payment Declined",
+ "Paid",
+ "Unpaid",
+ "Renewal Requested",
+ ]
+ ).order_by(Lower("name")),
+ empty_label=None,
+ )
+
start_date = forms.DateField(
- label='Start Date',
- widget=forms.DateInput(attrs={'class': 'datepicker'}),
- required=False)
+ label="Start Date",
+ widget=forms.DateInput(attrs={"class": "datepicker"}),
+ required=False,
+ )
end_date = forms.DateField(
- label='End Date',
- widget=forms.DateInput(attrs={'class': 'datepicker'}),
- required=False)
- description = forms.CharField(max_length=512,
- label='Description',
- required=False)
+ label="End Date",
+ widget=forms.DateInput(attrs={"class": "datepicker"}),
+ required=False,
+ )
+ description = forms.CharField(max_length=512, label="Description", required=False)
is_locked = forms.BooleanField(required=False)
is_changeable = forms.BooleanField(required=False)
@@ -77,14 +116,21 @@ def clean(self):
end_date = cleaned_data.get("end_date")
if start_date and end_date and end_date < start_date:
- raise forms.ValidationError(
- 'End date cannot be less than start date'
- )
+ raise forms.ValidationError("End date cannot be less than start date")
class AllocationInvoiceUpdateForm(forms.Form):
- status = forms.ModelChoiceField(queryset=AllocationStatusChoice.objects.filter(name__in=[
- 'Payment Pending', 'Payment Requested', 'Payment Declined', 'Paid']).order_by(Lower("name")), empty_label=None)
+ status = forms.ModelChoiceField(
+ queryset=AllocationStatusChoice.objects.filter(
+ name__in=[
+ "Payment Pending",
+ "Payment Requested",
+ "Payment Declined",
+ "Paid",
+ ]
+ ).order_by(Lower("name")),
+ empty_label=None,
+ )
class AllocationAddUserForm(forms.Form):
@@ -111,49 +157,53 @@ class AllocationAttributeDeleteForm(forms.Form):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
- self.fields['pk'].widget = forms.HiddenInput()
+ self.fields["pk"].widget = forms.HiddenInput()
class AllocationSearchForm(forms.Form):
- project = forms.CharField(label='Project Title',
- max_length=100, required=False)
- username = forms.CharField(
- label='Username', max_length=100, required=False)
+ project = forms.CharField(label="Project Title", max_length=100, required=False)
+ username = forms.CharField(label="Username", max_length=100, required=False)
resource_type = forms.ModelChoiceField(
- label='Resource Type',
+ label="Resource Type",
queryset=ResourceType.objects.all().order_by(Lower("name")),
- required=False)
+ required=False,
+ )
resource_name = forms.ModelMultipleChoiceField(
- label='Resource Name',
- queryset=Resource.objects.filter(
- is_allocatable=True).order_by(Lower("name")),
- required=False)
+ label="Resource Name",
+ queryset=Resource.objects.filter(is_allocatable=True).order_by(Lower("name")),
+ required=False,
+ )
allocation_attribute_name = forms.ModelChoiceField(
- label='Allocation Attribute Name',
+ label="Allocation Attribute Name",
queryset=AllocationAttributeType.objects.all().order_by(Lower("name")),
- required=False)
+ required=False,
+ )
allocation_attribute_value = forms.CharField(
- label='Allocation Attribute Value', max_length=100, required=False)
+ label="Allocation Attribute Value", max_length=100, required=False
+ )
end_date = forms.DateField(
- label='End Date',
- widget=forms.DateInput(attrs={'class': 'datepicker'}),
- required=False)
+ label="End Date",
+ widget=forms.DateInput(attrs={"class": "datepicker"}),
+ required=False,
+ )
active_from_now_until_date = forms.DateField(
- label='Active from Now Until Date',
- widget=forms.DateInput(attrs={'class': 'datepicker'}),
- required=False)
+ label="Active from Now Until Date",
+ widget=forms.DateInput(attrs={"class": "datepicker"}),
+ required=False,
+ )
status = forms.ModelMultipleChoiceField(
widget=forms.CheckboxSelectMultiple,
queryset=AllocationStatusChoice.objects.all().order_by(Lower("name")),
- required=False)
+ required=False,
+ )
show_all_allocations = forms.BooleanField(initial=False, required=False)
class AllocationReviewUserForm(forms.Form):
ALLOCATION_REVIEW_USER_CHOICES = (
- ('keep_in_allocation_and_project', 'Keep in allocation and project'),
- ('keep_in_project_only', 'Remove from this allocation only'),
- ('remove_from_project', 'Remove from project'),
+ ("keep_in_allocation_and_project", "Keep in allocation and project"),
+ ("keep_in_project_only", "Remove from this allocation only"),
+ ("remove_from_project", "Remove from project"),
)
username = forms.CharField(max_length=150, disabled=True)
@@ -166,20 +216,21 @@ class AllocationReviewUserForm(forms.Form):
class AllocationInvoiceNoteDeleteForm(forms.Form):
pk = forms.IntegerField(required=False, disabled=True)
note = forms.CharField(widget=forms.Textarea, disabled=True)
- author = forms.CharField(
- max_length=512, required=False, disabled=True)
+ author = forms.CharField(max_length=512, required=False, disabled=True)
selected = forms.BooleanField(initial=False, required=False)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
- self.fields['pk'].widget = forms.HiddenInput()
+ self.fields["pk"].widget = forms.HiddenInput()
class AllocationAccountForm(forms.ModelForm):
class Meta:
model = AllocationAccount
- fields = ['name', ]
+ fields = [
+ "name",
+ ]
class AllocationAttributeChangeForm(forms.Form):
@@ -190,14 +241,16 @@ class AllocationAttributeChangeForm(forms.Form):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
- self.fields['pk'].widget = forms.HiddenInput()
+ self.fields["pk"].widget = forms.HiddenInput()
def clean(self):
cleaned_data = super().clean()
- if cleaned_data.get('new_value') != "":
- allocation_attribute = AllocationAttribute.objects.get(pk=cleaned_data.get('pk'))
- allocation_attribute.value = cleaned_data.get('new_value')
+ if cleaned_data.get("new_value") != "":
+ allocation_attribute = AllocationAttribute.objects.get(
+ pk=cleaned_data.get("pk")
+ )
+ allocation_attribute.value = cleaned_data.get("new_value")
allocation_attribute.clean()
@@ -210,52 +263,59 @@ class AllocationAttributeUpdateForm(forms.Form):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
- self.fields['change_pk'].widget = forms.HiddenInput()
- self.fields['attribute_pk'].widget = forms.HiddenInput()
+ self.fields["change_pk"].widget = forms.HiddenInput()
+ self.fields["attribute_pk"].widget = forms.HiddenInput()
def clean(self):
cleaned_data = super().clean()
- allocation_attribute = AllocationAttribute.objects.get(pk=cleaned_data.get('attribute_pk'))
+ allocation_attribute = AllocationAttribute.objects.get(
+ pk=cleaned_data.get("attribute_pk")
+ )
- allocation_attribute.value = cleaned_data.get('new_value')
+ allocation_attribute.value = cleaned_data.get("new_value")
allocation_attribute.clean()
class AllocationChangeForm(forms.Form):
- EXTENSION_CHOICES = [
- (0, "No Extension")
- ]
+ EXTENSION_CHOICES = [(0, "No Extension")]
for choice in ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS:
EXTENSION_CHOICES.append((choice, "{} days".format(choice)))
end_date_extension = forms.TypedChoiceField(
- label='Request End Date Extension',
- choices = EXTENSION_CHOICES,
+ label="Request End Date Extension",
+ choices=EXTENSION_CHOICES,
coerce=int,
required=False,
- empty_value=0,)
+ empty_value=0,
+ )
justification = forms.CharField(
- label='Justification for Changes',
+ label="Justification for Changes",
widget=forms.Textarea,
required=True,
- help_text='Justification for requesting this allocation change request.')
+ help_text="Justification for requesting this allocation change request.",
+ )
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class AllocationChangeNoteForm(forms.Form):
- notes = forms.CharField(
- max_length=512,
- label='Notes',
- required=False,
- widget=forms.Textarea,
- help_text="Leave any feedback about the allocation change request.")
+ notes = forms.CharField(
+ max_length=512,
+ label="Notes",
+ required=False,
+ widget=forms.Textarea,
+ help_text="Leave any feedback about the allocation change request.",
+ )
+
class AllocationAttributeCreateForm(forms.ModelForm):
class Meta:
model = AllocationAttribute
- fields = '__all__'
+ fields = "__all__"
+
def __init__(self, *args, **kwargs):
- super(AllocationAttributeCreateForm, self).__init__(*args, **kwargs)
- self.fields['allocation_attribute_type'].queryset = self.fields['allocation_attribute_type'].queryset.order_by(Lower('name'))
+ super(AllocationAttributeCreateForm, self).__init__(*args, **kwargs)
+ self.fields["allocation_attribute_type"].queryset = self.fields[
+ "allocation_attribute_type"
+ ].queryset.order_by(Lower("name"))
diff --git a/coldfront/core/allocation/signals.py b/coldfront/core/allocation/signals.py
index 4f4c67ba11..1c078f0a4b 100644
--- a/coldfront/core/allocation/signals.py
+++ b/coldfront/core/allocation/signals.py
@@ -1,5 +1,7 @@
import django.dispatch
+allocation_new = django.dispatch.Signal()
+ #providing_args=["allocation_pk"]
allocation_activate = django.dispatch.Signal()
#providing_args=["allocation_pk"]
allocation_disable = django.dispatch.Signal()
diff --git a/coldfront/core/allocation/templates/allocation/allocation_invoice_detail.html b/coldfront/core/allocation/templates/allocation/allocation_invoice_detail.html
index a90d7479f4..a47c6079b4 100644
--- a/coldfront/core/allocation/templates/allocation/allocation_invoice_detail.html
+++ b/coldfront/core/allocation/templates/allocation/allocation_invoice_detail.html
@@ -62,6 +62,10 @@
{% include "portal/extra_app_templates.html" %}
diff --git a/coldfront/core/portal/views.py b/coldfront/core/portal/views.py
index 6c861e0b07..65a903263c 100644
--- a/coldfront/core/portal/views.py
+++ b/coldfront/core/portal/views.py
@@ -31,7 +31,7 @@ def home(request):
).distinct().order_by('-created')[:5]
allocation_list = Allocation.objects.filter(
- Q(status__name__in=['Active', 'New', 'Renewal Requested', ]) &
+ Q(status__name__in=['Active', 'New', 'Renewal Requested', 'Ready for Deletion', ]) &
Q(project__status__name__in=['Active', 'New']) &
Q(project__projectuser__user=request.user) &
Q(project__projectuser__status__name__in=['Active', ]) &
diff --git a/coldfront/core/project/fields.py b/coldfront/core/project/fields.py
new file mode 100644
index 0000000000..0f493636de
--- /dev/null
+++ b/coldfront/core/project/fields.py
@@ -0,0 +1,25 @@
+from django import forms
+from django.core.exceptions import ValidationError
+
+from coldfront.core.user.models import User
+
+from coldfront.plugins.qumulo.validators import validate_single_ad_user
+
+
+class PrincipalInvestigatorField(forms.CharField):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ def to_python(self, value):
+ return User.objects.get_or_create(username=value)[0]
+
+ def clean(self, value):
+ try:
+ validate_single_ad_user(value)
+ except ValidationError as error:
+ try:
+ User.objects.get(username=value)
+ except User.DoesNotExist:
+ raise error
+
+ return super().clean(value)
diff --git a/coldfront/core/project/forms.py b/coldfront/core/project/forms.py
index beeea24e9c..18bdfe29f1 100644
--- a/coldfront/core/project/forms.py
+++ b/coldfront/core/project/forms.py
@@ -9,6 +9,7 @@
from coldfront.core.project.models import (Project, ProjectAttribute, ProjectAttributeType, ProjectReview,
ProjectUserRoleChoice)
+from coldfront.core.project.fields import PrincipalInvestigatorField
from coldfront.core.utils.common import import_from_settings
EMAIL_DIRECTOR_PENDING_PROJECT_REVIEW_EMAIL = import_from_settings(
@@ -18,6 +19,17 @@
'EMAIL_DIRECTOR_EMAIL_ADDRESS', '')
+
+class ProjectCreateForm(forms.ModelForm):
+ class Meta:
+ model = Project
+ fields = ['title', 'pi', 'description', 'field_of_science']
+
+ pi = PrincipalInvestigatorField(
+ help_text="Select the Principal Investigator for this project.",
+ label="PI",
+ )
+
class ProjectSearchForm(forms.Form):
""" Search form for the Project list page.
"""
diff --git a/coldfront/core/project/models.py b/coldfront/core/project/models.py
index 45f9470ac4..6872edb7df 100644
--- a/coldfront/core/project/models.py
+++ b/coldfront/core/project/models.py
@@ -72,20 +72,10 @@ def get_by_natural_key(self, title, pi_username):
return self.get(title=title, pi__username=pi_username)
- DEFAULT_DESCRIPTION = '''
-We do not have information about your research. Please provide a detailed description of your work and update your field of science. Thank you!
- '''
-
title = models.CharField(max_length=255,)
pi = models.ForeignKey(User, on_delete=models.CASCADE,)
description = models.TextField(
- default=DEFAULT_DESCRIPTION,
- validators=[
- MinLengthValidator(
- 10,
- 'The project description must be > 10 characters.',
- )
- ],
+ blank=True,
)
field_of_science = models.ForeignKey(FieldOfScience, on_delete=models.CASCADE, default=FieldOfScience.DEFAULT_PK)
@@ -101,8 +91,6 @@ def clean(self):
if 'Auto-Import Project'.lower() in self.title.lower():
raise ValidationError('You must update the project title. You cannot have "Auto-Import Project" in the title.')
- if 'We do not have information about your research. Please provide a detailed description of your work and update your field of science. Thank you!' in self.description:
- raise ValidationError('You must update the project description.')
@property
def last_project_review(self):
diff --git a/coldfront/core/project/templates/project/project_detail.html b/coldfront/core/project/templates/project/project_detail.html
index 2e4100cf21..de86736513 100644
--- a/coldfront/core/project/templates/project/project_detail.html
+++ b/coldfront/core/project/templates/project/project_detail.html
@@ -293,160 +293,6 @@
{{attribute}}
-
-
-
-
- {% if grants %}
-
-
-
-
- Title
- Grant PI
- Project PI Role
- Award Amount
- Grant Start Date
- Grant End Date
- Status
- Actions
-
-
-
- {% for grant in grants %}
-
- {{ grant.title }}
- {{ grant.grant_pi }}
- {{ grant.role}}
- {{ grant.total_amount_awarded|intcomma}}
- {{ grant.grant_start|date:"Y-m-d" }}
- {{ grant.grant_end|date:"Y-m-d" }}
- {% if grant.status.name == 'Active' %}
- {{ grant.status.name }}
- {% elif grant.status.name == 'Archived' %}
- {{ grant.status.name }}
- {% else %}
- {{ grant.status.name }}
- {% endif %}
- Edit
-
- {% endfor %}
-
-
-
- {% else %}
-
There are no grants to display.
- {% endif %}
-
-
-
-
-
-
-
-
-
- {% if publications %}
-
-
-
-
- Title, Author, and Journal
- Year
-
-
-
- {% for publication in publications %}
-
-
- Title: {{ publication.title }}
- {% if publication.source.url %}
- Visit source
- {% endif %}
- Author: {{ publication.author}}
- Journal: {{ publication.journal}}
-
- {{ publication.year }}
-
- {% endfor %}
-
-
-
- {% else %}
-
There are no publications to display.
- {% endif %}
-
-
-
-
-
-
-
-
-
- {% if research_outputs %}
-
-
-
- {% for research_output in research_outputs %}
-
-
- {% if research_output.title %}
- {{ research_output.title }}
- {% endif %}
-
- {{ research_output.description | linebreaks }}
-
-
-
- {% endfor %}
-
-
-
- {% else %}
-
There are no research outputs to display.
- {% endif %}
-
-
-
-
diff --git a/coldfront/core/project/templates/project/project_user_detail.html b/coldfront/core/project/templates/project/project_user_detail.html
index 76d5ea247d..d74f937fae 100644
--- a/coldfront/core/project/templates/project/project_user_detail.html
+++ b/coldfront/core/project/templates/project/project_user_detail.html
@@ -55,10 +55,17 @@
Project: {{project.title}}
Role:
{{project_user_update_form.role}}
+ {% if project_user_obj.role.name == "Manager" %}
+
+ Enable Notifications:
+ Notifications cannot be disabled if the user is a Manager
+
+ {% else %}
Enable Notifications:
{{project_user_update_form.enable_notifications}}
+ {% endif %}
{% endif %}
diff --git a/coldfront/core/project/test_views.py b/coldfront/core/project/test_views.py
new file mode 100644
index 0000000000..1da5d8cacb
--- /dev/null
+++ b/coldfront/core/project/test_views.py
@@ -0,0 +1,428 @@
+import logging
+
+from django.test import TestCase
+
+from coldfront.core.test_helpers import utils
+from coldfront.core.test_helpers.factories import (
+ UserFactory,
+ ProjectFactory,
+ ProjectUserFactory,
+ PAttributeTypeFactory,
+ ProjectAttributeFactory,
+ ProjectStatusChoiceFactory,
+ ProjectAttributeTypeFactory,
+ ProjectUserRoleChoiceFactory,
+)
+from coldfront.core.project.models import ProjectUserStatusChoice
+
+logging.disable(logging.CRITICAL)
+
+
+class ProjectViewTestBase(TestCase):
+ """Base class for project view tests"""
+
+ @classmethod
+ def setUpTestData(cls):
+ """Set up users and project for testing"""
+ cls.backend = "django.contrib.auth.backends.ModelBackend"
+ cls.project = ProjectFactory(status=ProjectStatusChoiceFactory(name="Active"))
+
+ user_role = ProjectUserRoleChoiceFactory(name="User")
+ project_user = ProjectUserFactory(project=cls.project, role=user_role)
+ cls.project_user = project_user.user
+
+ manager_role = ProjectUserRoleChoiceFactory(name="Manager")
+ pi_user = ProjectUserFactory(
+ project=cls.project, role=manager_role, user=cls.project.pi
+ )
+ cls.pi_user = pi_user.user
+ cls.admin_user = UserFactory(is_staff=True, is_superuser=True)
+ cls.nonproject_user = UserFactory(is_staff=False, is_superuser=False)
+
+ attributetype = PAttributeTypeFactory(name="string")
+ cls.projectattributetype = ProjectAttributeTypeFactory(
+ attribute_type=attributetype
+ )
+
+ def project_access_tstbase(self, url):
+ """Test basic access control for project views. For all project views:
+ - if not logged in, redirect to login page
+ - if logged in as admin, can access page
+ """
+ # If not logged in, can't see page; redirect to login page.
+ utils.test_logged_out_redirect_to_login(self, url)
+ # after login, pi and admin can access create page
+ utils.test_user_can_access(self, self.admin_user, url)
+
+
+class ProjectDetailViewTest(ProjectViewTestBase):
+ """tests for ProjectDetailView"""
+
+ @classmethod
+ def setUpTestData(cls):
+ """Set up users and project for testing"""
+ super(ProjectDetailViewTest, cls).setUpTestData()
+ cls.url = f"/project/{cls.project.pk}/"
+
+ def test_projectdetail_access(self):
+ """Test project detail page access"""
+ # logged-out user gets redirected, admin can access create page
+ self.project_access_tstbase(self.url)
+ # pi and projectuser can access
+ utils.test_user_can_access(self, self.pi_user, self.url)
+ utils.test_user_can_access(self, self.project_user, self.url)
+ # user not belonging to project cannot access
+ utils.test_user_cannot_access(self, self.nonproject_user, self.url)
+
+ def test_projectdetail_permissions(self):
+ """Test project detail page access permissions"""
+ # admin has is_allowed_to_update_project set to True
+ response = utils.login_and_get_page(self.client, self.admin_user, self.url)
+ self.assertEqual(response.context["is_allowed_to_update_project"], True)
+ # pi has is_allowed_to_update_project set to True
+ response = utils.login_and_get_page(self.client, self.pi_user, self.url)
+ self.assertEqual(response.context["is_allowed_to_update_project"], True)
+ # non-manager user has is_allowed_to_update_project set to False
+ response = utils.login_and_get_page(self.client, self.project_user, self.url)
+ self.assertEqual(response.context["is_allowed_to_update_project"], False)
+
+ def test_projectdetail_request_allocation_button_visibility(self):
+ """Test visibility of projectdetail request allocation button across user levels"""
+ button_text = "Request Resource Allocation"
+ # admin can see request allocation button
+ utils.page_contains_for_user(self, self.admin_user, self.url, button_text)
+ # pi can see request allocation button
+ utils.page_contains_for_user(self, self.pi_user, self.url, button_text)
+ # non-manager user cannot see request allocation button
+ utils.page_does_not_contain_for_user(
+ self, self.project_user, self.url, button_text
+ )
+
+ def test_projectdetail_edituser_button_visibility(self):
+ """Test visibility of projectdetail edit button across user levels"""
+ # admin can see edit button
+ utils.page_contains_for_user(self, self.admin_user, self.url, "fa-user-edit")
+ # pi can see edit button
+ utils.page_contains_for_user(self, self.pi_user, self.url, "fa-user-edit")
+ # non-manager user cannot see edit button
+ utils.page_does_not_contain_for_user(
+ self, self.project_user, self.url, "fa-user-edit"
+ )
+
+ def test_projectdetail_addnotification_button_visibility(self):
+ """Test visibility of projectdetail add notification button across user levels"""
+ # admin can see add notification button
+ utils.page_contains_for_user(
+ self, self.admin_user, self.url, "Add Notification"
+ )
+ # pi cannot see add notification button
+ utils.page_does_not_contain_for_user(
+ self, self.pi_user, self.url, "Add Notification"
+ )
+ # non-manager user cannot see add notification button
+ utils.page_does_not_contain_for_user(
+ self, self.project_user, self.url, "Add Notification"
+ )
+
+
+class ProjectCreateTest(ProjectViewTestBase):
+ """Tests for project create view"""
+
+ @classmethod
+ def setUpTestData(cls):
+ """Set up users and project for testing"""
+ super(ProjectCreateTest, cls).setUpTestData()
+ cls.url = "/project/create/"
+
+ def test_project_access(self):
+ """Test access to project create page"""
+ # logged-out user gets redirected, admin can access create page
+ self.project_access_tstbase(self.url)
+ # pi, projectuser and nonproject user cannot access create page
+ utils.test_user_cannot_access(self, self.pi_user, self.url)
+ utils.test_user_cannot_access(self, self.project_user, self.url)
+ utils.test_user_cannot_access(self, self.nonproject_user, self.url)
+
+
+class ProjectAttributeCreateTest(ProjectViewTestBase):
+ """Tests for project attribute create view"""
+
+ @classmethod
+ def setUpTestData(cls):
+ """Set up users and project for testing"""
+ super(ProjectAttributeCreateTest, cls).setUpTestData()
+ int_attributetype = PAttributeTypeFactory(name="Int")
+ cls.int_projectattributetype = ProjectAttributeTypeFactory(
+ attribute_type=int_attributetype
+ )
+ cls.url = f"/project/{cls.project.pk}/project-attribute-create/"
+
+ def test_project_access(self):
+ """Test access to project attribute create page"""
+ # logged-out user gets redirected, admin can access create page
+ self.project_access_tstbase(self.url)
+ # pi can access create page
+ utils.test_user_can_access(self, self.pi_user, self.url)
+ # project user and nonproject user cannot access create page
+ utils.test_user_cannot_access(self, self.project_user, self.url)
+ utils.test_user_cannot_access(self, self.nonproject_user, self.url)
+
+ def test_project_attribute_create_post(self):
+ """Test project attribute creation post response"""
+
+ self.client.force_login(self.admin_user, backend=self.backend)
+ response = self.client.post(
+ self.url,
+ data={
+ "proj_attr_type": self.projectattributetype.pk,
+ "value": "test_value",
+ "project": self.project.pk,
+ },
+ )
+ redirect_url = f"/project/{self.project.pk}/"
+ self.assertRedirects(
+ response, redirect_url, status_code=302, target_status_code=200
+ )
+
+ def test_project_attribute_create_post_required_values(self):
+ """ProjectAttributeCreate correctly flags missing project or value"""
+ self.client.force_login(self.admin_user, backend=self.backend)
+ # missing project
+ response = self.client.post(
+ self.url,
+ data={
+ "proj_attr_type": self.projectattributetype.pk,
+ "value": "test_value",
+ },
+ )
+ self.assertFormError(response, "form", "project", "This field is required.")
+ # missing value
+ response = self.client.post(
+ self.url,
+ data={
+ "proj_attr_type": self.projectattributetype.pk,
+ "project": self.project.pk,
+ },
+ )
+ self.assertFormError(response, "form", "value", "This field is required.")
+
+ def test_project_attribute_create_value_type_match(self):
+ """ProjectAttributeCreate correctly flags value-type mismatch"""
+
+ self.client.force_login(self.admin_user, backend=self.backend)
+ # test that value must be numeric if proj_attr_type is string
+ response = self.client.post(
+ self.url,
+ data={
+ "proj_attr_type": self.int_projectattributetype.pk,
+ "value": True,
+ "project": self.project.pk,
+ },
+ )
+ self.assertFormError(
+ response, "form", "", "Invalid Value True. Value must be an int."
+ )
+
+
+class ProjectAttributeUpdateTest(ProjectViewTestBase):
+ """Tests for ProjectAttributeUpdateView"""
+
+ @classmethod
+ def setUpTestData(cls):
+ """Set up users and project for testing"""
+ super(ProjectAttributeUpdateTest, cls).setUpTestData()
+ cls.projectattribute = ProjectAttributeFactory(
+ value=36238, proj_attr_type=cls.projectattributetype, project=cls.project
+ )
+ cls.url = f"/project/{cls.project.pk}/project-attribute-update/{cls.projectattribute.pk}"
+
+ def test_project_attribute_update_access(self):
+ """Test access to project attribute update page"""
+ self.project_access_tstbase(self.url)
+ utils.test_user_can_access(self, self.pi_user, self.url)
+ # project user, pi, and nonproject user cannot access update page
+ utils.test_user_cannot_access(self, self.project_user, self.url)
+ utils.test_user_cannot_access(self, self.nonproject_user, self.url)
+
+
+class ProjectAttributeDeleteTest(ProjectViewTestBase):
+ """Tests for ProjectAttributeDeleteView"""
+
+ @classmethod
+ def setUpTestData(cls):
+ """set up users and project for testing"""
+ super(ProjectAttributeDeleteTest, cls).setUpTestData()
+ cls.projectattribute = ProjectAttributeFactory(
+ value=36238, proj_attr_type=cls.projectattributetype, project=cls.project
+ )
+ cls.url = f"/project/{cls.project.pk}/project-attribute-delete/"
+
+ def test_project_attribute_delete_access(self):
+ """test access to project attribute delete page"""
+ # logged-out user gets redirected, admin can access delete page
+ self.project_access_tstbase(self.url)
+ # pi can access delete page
+ utils.test_user_can_access(self, self.pi_user, self.url)
+ # project user and nonproject user cannot access delete page
+ utils.test_user_cannot_access(self, self.project_user, self.url)
+ utils.test_user_cannot_access(self, self.nonproject_user, self.url)
+
+
+class ProjectListViewTest(ProjectViewTestBase):
+ """Tests for ProjectList view"""
+
+ @classmethod
+ def setUpTestData(cls):
+ """Set up users and project for testing"""
+ super(ProjectListViewTest, cls).setUpTestData()
+ # add 100 projects to test pagination, permissions, search functionality
+
+ # create 100 random users with last names following a pattern
+ last_name_base = "AntidisestablishmentarianBacterium"
+ users = [UserFactory(last_name=f"{x}{last_name_base}") for x in range(100)]
+ additional_projects = [ProjectFactory(pi=users[i]) for i in list(range(100))]
+ cls.additional_projects = [
+ p for p in additional_projects if p.pi.last_name != cls.project.pi.last_name
+ ]
+ cls.url = "/project/"
+
+ ### ProjectListView access tests ###
+
+ def test_project_list_access(self):
+ """Test project list access controls."""
+ # logged-out user gets redirected, admin can access list page
+ self.project_access_tstbase(self.url)
+ # all other users can access list page
+ utils.test_user_can_access(self, self.pi_user, self.url)
+ utils.test_user_can_access(self, self.project_user, self.url)
+ utils.test_user_can_access(self, self.nonproject_user, self.url)
+
+ ### ProjectListView display tests ###
+
+ def test_project_list_display_members(self):
+ """Project list displays only projects that user is an active member of"""
+ # deactivated projectuser won't see project on their page
+ response = utils.login_and_get_page(self.client, self.project_user, self.url)
+ self.assertEqual(len(response.context["object_list"]), 1)
+ proj_user = self.project.projectuser_set.get(user=self.project_user)
+ proj_user.status, _ = ProjectUserStatusChoice.objects.get_or_create(
+ name="Removed"
+ )
+ proj_user.save()
+ response = utils.login_and_get_page(self.client, self.project_user, self.url)
+ self.assertEqual(len(response.context["object_list"]), 0)
+
+ def test_project_list_displayall_permission_admin(self):
+ """Projectlist displayall option displays all projects to admin"""
+ url = self.url + "?show_all_projects=on"
+ response = utils.login_and_get_page(self.client, self.admin_user, url)
+ self.assertGreaterEqual(101, len(response.context["object_list"]))
+
+ def test_project_list_displayall_permission_pi(self):
+ """Projectlist displayall option displays only the pi's projects to the pi"""
+ url = self.url + "?show_all_projects=on"
+ response = utils.login_and_get_page(self.client, self.pi_user, url)
+ self.assertEqual(len(response.context["object_list"]), 1)
+
+ def test_project_list_displayall_permission_project_user(self):
+ """Projectlist displayall displays only projects projectuser belongs to"""
+ url = self.url + "?show_all_projects=on"
+ response = utils.login_and_get_page(self.client, self.project_user, url)
+ self.assertEqual(len(response.context["object_list"]), 1)
+
+ ### ProjectListView search tests ###
+
+ def test_project_list_search(self):
+ """Test that project list search works."""
+ url_base = self.url + "?show_all_projects=on"
+ url = (
+ f"{url_base}&last_name={self.project.pi.last_name}"
+ + f"&field_of_science={self.project.field_of_science.description}"
+ )
+ # search by project project_title
+ response = utils.login_and_get_page(self.client, self.admin_user, url)
+ self.assertEqual(len(response.context["object_list"]), 1)
+
+
+class ProjectRemoveUsersViewTest(ProjectViewTestBase):
+ """Tests for ProjectRemoveUsersView"""
+
+ def setUp(self):
+ """set up users and project for testing"""
+ self.url = f"/project/{self.project.pk}/remove-users/"
+
+ def test_projectremoveusersview_access(self):
+ """test access to project remove users page"""
+ self.project_access_tstbase(self.url)
+
+
+class ProjectUpdateViewTest(ProjectViewTestBase):
+ """Tests for ProjectUpdateView"""
+
+ def setUp(self):
+ """set up users and project for testing"""
+ self.url = f"/project/{self.project.pk}/update/"
+
+ def test_projectupdateview_access(self):
+ """test access to project update page"""
+ self.project_access_tstbase(self.url)
+
+
+class ProjectReviewListViewTest(ProjectViewTestBase):
+ """Tests for ProjectReviewListView"""
+
+ def setUp(self):
+ """set up users and project for testing"""
+ self.url = f"/project/project-review-list"
+
+ def test_projectreviewlistview_access(self):
+ """test access to project review list page"""
+ self.project_access_tstbase(self.url)
+
+
+class ProjectArchivedListViewTest(ProjectViewTestBase):
+ """Tests for ProjectArchivedListView"""
+
+ def setUp(self):
+ """set up users and project for testing"""
+ self.url = f"/project/archived/"
+
+ def test_projectarchivedlistview_access(self):
+ """test access to project archived list page"""
+ self.project_access_tstbase(self.url)
+
+
+class ProjectNoteCreateViewTest(ProjectViewTestBase):
+ """Tests for ProjectNoteCreateView"""
+
+ def setUp(self):
+ """set up users and project for testing"""
+ self.url = f"/project/{self.project.pk}/projectnote/add"
+
+ def test_projectnotecreateview_access(self):
+ """test access to project note create page"""
+ self.project_access_tstbase(self.url)
+
+
+class ProjectAddUsersSearchView(ProjectViewTestBase):
+ """Tests for ProjectAddUsersSearchView"""
+
+ def setUp(self):
+ """set up users and project for testing"""
+ self.url = f"/project/{self.project.pk}/add-users-search/"
+
+ def test_projectadduserssearchview_access(self):
+ """test access to project add users search page"""
+ self.project_access_tstbase(self.url)
+
+
+class ProjectUserDetailViewTest(ProjectViewTestBase):
+ """Tests for ProjectUserDetailView"""
+
+ def setUp(self):
+ """set up users and project for testing"""
+ self.url = f"/project/{self.project.pk}/user-detail/{self.project_user.pk}"
+
+ def test_projectuserdetailview_access(self):
+ """test access to project user detail page"""
+ self.project_access_tstbase(self.url)
diff --git a/coldfront/core/project/tests.py b/coldfront/core/project/tests.py
index 07ce527b21..4ae90dd6b2 100644
--- a/coldfront/core/project/tests.py
+++ b/coldfront/core/project/tests.py
@@ -1,42 +1,53 @@
+import logging
+
from django.core.exceptions import ValidationError
from django.test import TestCase
from coldfront.core.test_helpers.factories import (
+ UserFactory,
+ ProjectFactory,
FieldOfScienceFactory,
+ ProjectAttributeFactory,
ProjectStatusChoiceFactory,
- UserFactory,
+ ProjectAttributeTypeFactory,
+ PAttributeTypeFactory,
+)
+from coldfront.core.project.models import (
+ Project,
+ ProjectAttribute,
+ ProjectAttributeType,
)
-from coldfront.core.project.models import Project
+logging.disable(logging.CRITICAL)
class TestProject(TestCase):
+
class Data:
"""Collection of test data, separated for readability"""
def __init__(self):
user = UserFactory(username='cgray')
user.userprofile.is_pi = True
- user.save()
- fos = FieldOfScienceFactory(description='Chemistry')
+ field_of_science = FieldOfScienceFactory(description='Chemistry')
status = ProjectStatusChoiceFactory(name='Active')
-
self.initial_fields = {
'pi': user,
'title': 'Angular momentum in QGP holography',
'description': 'We want to estimate the quark chemical potential of a rotating sample of plasma.',
- 'field_of_science': fos,
+ 'field_of_science': field_of_science,
'status': status,
'force_review': True
}
-
+
self.unsaved_object = Project(**self.initial_fields)
-
+
def setUp(self):
self.data = self.Data()
def test_fields_generic(self):
+ """Test that generic project fields save correctly"""
self.assertEqual(0, len(Project.objects.all()))
project_obj = self.data.unsaved_object
@@ -52,8 +63,9 @@ def test_fields_generic(self):
saved_value = getattr(retrieved_project, field)
self.assertEqual(initial_value, saved_value)
self.assertEqual(project_obj, retrieved_project)
-
+
def test_title_maxlength(self):
+ """Test that the title field has a maximum length of 255 characters"""
expected_maximum_length = 255
maximum_title = 'x' * expected_maximum_length
@@ -71,6 +83,7 @@ def test_title_maxlength(self):
self.assertEqual(maximum_title, retrieved_obj.title)
def test_auto_import_project_title(self):
+ """Test that auto-imported projects must have a title"""
project_obj = self.data.unsaved_object
assert project_obj.pk is None
@@ -78,32 +91,9 @@ def test_auto_import_project_title(self):
with self.assertRaises(ValidationError):
project_obj.clean()
- def test_description_minlength(self):
- expected_minimum_length = 10
- minimum_description = 'x' * expected_minimum_length
-
- project_obj = self.data.unsaved_object
-
- project_obj.description = minimum_description[:-1]
- with self.assertRaises(ValidationError):
- project_obj.clean_fields()
-
- project_obj.description = minimum_description
- project_obj.clean_fields()
- project_obj.save()
-
- retrieved_obj = Project.objects.get(pk=project_obj.pk)
- self.assertEqual(minimum_description, retrieved_obj.description)
-
- def test_description_update_required_initially(self):
- project_obj = self.data.unsaved_object
- assert project_obj.pk is None
-
- project_obj.description = project_obj.DEFAULT_DESCRIPTION
- with self.assertRaises(ValidationError):
- project_obj.clean()
def test_pi_foreignkey_on_delete(self):
+ """Test that a project is deleted when its PI is deleted."""
project_obj = self.data.unsaved_object
project_obj.save()
@@ -117,6 +107,8 @@ def test_pi_foreignkey_on_delete(self):
self.assertEqual(0, len(Project.objects.all()))
def test_fos_foreignkey_on_delete(self):
+ """Test that a project is deleted when its field of science is deleted.
+ """
project_obj = self.data.unsaved_object
project_obj.save()
@@ -130,6 +122,7 @@ def test_fos_foreignkey_on_delete(self):
self.assertEqual(0, len(Project.objects.all()))
def test_status_foreignkey_on_delete(self):
+ """Test that a project is deleted when its status is deleted."""
project_obj = self.data.unsaved_object
project_obj.save()
@@ -142,3 +135,43 @@ def test_status_foreignkey_on_delete(self):
Project.objects.get(pk=project_obj.pk)
self.assertEqual(0, len(Project.objects.all()))
+
+class TestProjectAttribute(TestCase):
+
+ @classmethod
+ def setUpTestData(cls):
+ project_attr_types = [('Project ID', 'Text'), ('Account Number', 'Int')]
+ for atype in project_attr_types:
+ ProjectAttributeTypeFactory(
+ name=atype[0],
+ attribute_type=PAttributeTypeFactory(name=atype[1]),
+ has_usage=False,
+ is_unique=True,
+ )
+ cls.project = ProjectFactory()
+ cls.new_attr = ProjectAttributeFactory(
+ proj_attr_type=ProjectAttributeType.objects.get(name='Account Number'),
+ project=cls.project,
+ value=1243,
+ )
+
+ def test_unique_attrs_one_per_project(self):
+ """
+ Test that only one attribute of the same attribute type can be
+ saved if the attribute type is unique
+ """
+ self.assertEqual(1, len(self.project.projectattribute_set.all()))
+ proj_attr_type = ProjectAttributeType.objects.get(name='Account Number')
+ new_attr = ProjectAttribute(project=self.project, proj_attr_type=proj_attr_type)
+ with self.assertRaises(ValidationError):
+ new_attr.clean()
+
+ def test_attribute_must_match_datatype(self):
+ """Test that the attribute value must match the attribute type"""
+
+ proj_attr_type = ProjectAttributeType.objects.get(name='Account Number')
+ new_attr = ProjectAttribute(
+ project=self.project, proj_attr_type=proj_attr_type, value='abc'
+ )
+ with self.assertRaises(ValidationError):
+ new_attr.clean()
diff --git a/coldfront/core/project/views.py b/coldfront/core/project/views.py
index ae96295570..ad32016200 100644
--- a/coldfront/core/project/views.py
+++ b/coldfront/core/project/views.py
@@ -43,7 +43,8 @@
ProjectReviewForm,
ProjectSearchForm,
ProjectUserUpdateForm,
- ProjectAttributeUpdateForm)
+ ProjectAttributeUpdateForm,
+ ProjectCreateForm)
from coldfront.core.project.models import (Project,
ProjectAttribute,
ProjectReview,
@@ -450,8 +451,8 @@ def post(self, request, *args, **kwargs):
class ProjectCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
model = Project
+ form_class = ProjectCreateForm
template_name_suffix = '_create_form'
- fields = ['title', 'description', 'field_of_science', ]
def test_func(self):
""" UserPassesTestMixin Tests"""
@@ -463,7 +464,6 @@ def test_func(self):
def form_valid(self, form):
project_obj = form.save(commit=False)
- form.instance.pi = self.request.user
form.instance.status = ProjectStatusChoice.objects.get(name='New')
project_obj.save()
self.object = project_obj
@@ -904,10 +904,14 @@ def post(self, request, *args, **kwargs):
if project_user_update_form.is_valid():
form_data = project_user_update_form.cleaned_data
- project_user_obj.enable_notifications = form_data.get(
- 'enable_notifications')
project_user_obj.role = ProjectUserRoleChoice.objects.get(
name=form_data.get('role'))
+
+ if(project_user_obj.role.name=="Manager"):
+ project_user_obj.enable_notifications = True
+ else:
+ project_user_obj.enable_notifications = form_data.get(
+ 'enable_notifications')
project_user_obj.save()
messages.success(request, 'User details updated.')
@@ -988,11 +992,6 @@ def dispatch(self, request, *args, **kwargs):
request, 'You must update the project title before reviewing your project. You cannot have "Auto-Import Project" in the title.')
return HttpResponseRedirect(reverse('project-update', kwargs={'pk': project_obj.pk}))
- if 'We do not have information about your research. Please provide a detailed description of your work and update your field of science. Thank you!' in project_obj.description:
- messages.error(
- request, 'You must update the project description before reviewing your project.')
- return HttpResponseRedirect(reverse('project-update', kwargs={'pk': project_obj.pk}))
-
return super().dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
diff --git a/coldfront/core/test_helpers/factories.py b/coldfront/core/test_helpers/factories.py
index 82b9f7e18f..53298bd749 100644
--- a/coldfront/core/test_helpers/factories.py
+++ b/coldfront/core/test_helpers/factories.py
@@ -1,25 +1,99 @@
+import factory
+from django.contrib.auth.models import User
+from factory import SubFactory
+from factory.fuzzy import FuzzyChoice
+from factory.django import DjangoModelFactory
+from faker import Faker
+from faker.providers import BaseProvider, DynamicProvider
+
from coldfront.core.field_of_science.models import FieldOfScience
-from coldfront.core.resource.models import ResourceType
-from coldfront.core.project.models import Project, ProjectStatusChoice
+from coldfront.core.resource.models import ResourceType, Resource
+from coldfront.core.project.models import (
+ Project,
+ ProjectUser,
+ ProjectAttribute,
+ ProjectAttributeType,
+ ProjectUserRoleChoice,
+ ProjectUserStatusChoice,
+ ProjectStatusChoice,
+ AttributeType as PAttributeType,
+)
+from coldfront.core.allocation.models import (
+ Allocation,
+ AllocationUser,
+ AllocationUserNote,
+ AllocationAttribute,
+ AllocationStatusChoice,
+ AllocationAttributeType,
+ AllocationChangeRequest,
+ AllocationChangeStatusChoice,
+ AllocationAttributeUsage,
+ AllocationUserStatusChoice,
+ AllocationAttributeChangeRequest,
+ AttributeType as AAttributeType,
+)
from coldfront.core.grant.models import GrantFundingAgency, GrantStatusChoice
from coldfront.core.publication.models import PublicationSource
-from django.contrib.auth.models import User
-from factory.django import DjangoModelFactory
-from factory import SubFactory
+### Default values and Faker provider setup ###
+
+project_status_choice_names = ['New', 'Active', 'Archived']
+project_user_role_choice_names = ['User', 'Manager']
+field_of_science_names = ['Physics', 'Chemistry', 'Economics', 'Biology', 'Sociology']
+attr_types = ['Date', 'Int', 'Float', 'Text', 'Boolean']
+
+fake = Faker()
+
+class ColdfrontProvider(BaseProvider):
+ def project_title(self):
+ return f'{fake.last_name()}_lab'.lower()
+
+ def resource_name(self):
+ return fake.word().lower()+ '/' + fake.word().lower()
+
+ def username(self):
+ first_name = fake.first_name()
+ last_name = fake.last_name()
+ return f'{first_name}{last_name}'.lower()
+field_of_science_provider = DynamicProvider(
+ provider_name="fieldofscience", elements=field_of_science_names
+)
+attr_type_provider = DynamicProvider(provider_name="attr_types", elements=attr_types)
+
+for provider in [ColdfrontProvider, field_of_science_provider, attr_type_provider]:
+ factory.Faker.add_provider(provider)
+
+
+
+### User factories ###
class UserFactory(DjangoModelFactory):
class Meta:
model = User
+ django_get_or_create = ('username',)
+ first_name = factory.Faker('first_name')
+ last_name = factory.Faker('last_name')
+ # username = factory.Faker('username')
+ username = factory.LazyAttribute(lambda o: f'{o.first_name}{o.last_name}')
+ email = factory.LazyAttribute(lambda o: '%s@example.com' % o.username)
+
+### Field of Science factories ###
class FieldOfScienceFactory(DjangoModelFactory):
class Meta:
model = FieldOfScience
+ django_get_or_create = ('description',)
+
+ # description = FuzzyChoice(field_of_science_names)
+ description = factory.Faker('fieldofscience')
+
+### Grant factories ###
+
class GrantFundingAgencyFactory(DjangoModelFactory):
class Meta:
model = GrantFundingAgency
@@ -30,24 +104,86 @@ class Meta:
model = GrantStatusChoice
+
+### Project factories ###
+
class ProjectStatusChoiceFactory(DjangoModelFactory):
+ """Factory for ProjectStatusChoice model"""
class Meta:
model = ProjectStatusChoice
+ # ensure that names are unique
+ django_get_or_create = ('name',)
+ # randomly generate names from list of default values
+ name = FuzzyChoice(project_status_choice_names)
class ProjectFactory(DjangoModelFactory):
class Meta:
model = Project
+ django_get_or_create = ('title',)
- title = 'Test project!'
pi = SubFactory(UserFactory)
- description = 'This is a project description.'
+ title = factory.Faker('project_title')
+ description = factory.Faker('sentence')
field_of_science = SubFactory(FieldOfScienceFactory)
status = SubFactory(ProjectStatusChoiceFactory)
force_review = False
requires_review = False
+class ProjectUserRoleChoiceFactory(DjangoModelFactory):
+ class Meta:
+ model = ProjectUserRoleChoice
+ django_get_or_create = ('name',)
+ name = 'User'
+
+
+class ProjectUserStatusChoiceFactory(DjangoModelFactory):
+ class Meta:
+ model = ProjectUserStatusChoice
+ django_get_or_create = ('name',)
+ name = 'Active'
+
+
+class ProjectUserFactory(DjangoModelFactory):
+ class Meta:
+ model = ProjectUser
+ django_get_or_create = ('project', 'user')
+
+ project = SubFactory(ProjectFactory)
+ user = SubFactory(UserFactory)
+ role = SubFactory(ProjectUserRoleChoiceFactory)
+ status = SubFactory(ProjectUserStatusChoiceFactory)
+
+
+
+### Project Attribute factories ###
+
+class PAttributeTypeFactory(DjangoModelFactory):
+ class Meta:
+ model = PAttributeType
+ # django_get_or_create = ('name',)
+ name = factory.Faker('attr_type')
+
+
+class ProjectAttributeTypeFactory(DjangoModelFactory):
+ class Meta:
+ model = ProjectAttributeType
+ name = 'Test attribute type'
+ attribute_type = SubFactory(PAttributeTypeFactory)
+
+
+class ProjectAttributeFactory(DjangoModelFactory):
+ class Meta:
+ model = ProjectAttribute
+ proj_attr_type = SubFactory(ProjectAttributeTypeFactory)
+ value = 'Test attribute value'
+ project = SubFactory(ProjectFactory)
+
+
+
+### Publication factories ###
+
class PublicationSourceFactory(DjangoModelFactory):
class Meta:
model = PublicationSource
@@ -56,6 +192,130 @@ class Meta:
url = 'https://doi.org/'
+
+### Resource factories ###
+
class ResourceTypeFactory(DjangoModelFactory):
class Meta:
model = ResourceType
+ django_get_or_create = ('name',)
+ name = 'Storage'
+
+class ResourceFactory(DjangoModelFactory):
+ class Meta:
+ model = Resource
+ django_get_or_create = ('name',)
+ name = factory.Faker('resource_name')
+
+ description = factory.Faker('sentence')
+ resource_type = SubFactory(ResourceTypeFactory)
+
+
+
+### Allocation factories ###
+
+class AllocationStatusChoiceFactory(DjangoModelFactory):
+ class Meta:
+ model = AllocationStatusChoice
+ django_get_or_create = ('name',)
+ name = 'Active'
+
+
+class AllocationFactory(DjangoModelFactory):
+ class Meta:
+ model = Allocation
+ django_get_or_create = ('project',)
+ justification = factory.Faker('sentence')
+ status = SubFactory(AllocationStatusChoiceFactory)
+ project = SubFactory(ProjectFactory)
+ is_changeable = True
+
+
+
+### Allocation Attribute factories ###
+
+class AAttributeTypeFactory(DjangoModelFactory):
+ class Meta:
+ model = AAttributeType
+ django_get_or_create = ('name',)
+ name='Int'
+
+
+class AllocationAttributeTypeFactory(DjangoModelFactory):
+ class Meta:
+ model = AllocationAttributeType
+ django_get_or_create = ('name',)
+ name = 'Test attribute type'
+ attribute_type = SubFactory(AAttributeTypeFactory)
+
+
+class AllocationAttributeFactory(DjangoModelFactory):
+ class Meta:
+ model = AllocationAttribute
+ allocation_attribute_type = SubFactory(AllocationAttributeTypeFactory)
+ value = 2048
+ allocation = SubFactory(AllocationFactory)
+
+
+class AllocationAttributeUsageFactory(DjangoModelFactory):
+ class Meta:
+ model = AllocationAttributeUsage
+ django_get_or_create = ('allocation_attribute',)
+ allocation_attribute = SubFactory(AllocationAttributeFactory)
+ value = 1024
+
+
+
+### Allocation Change Request factories ###
+
+class AllocationChangeStatusChoiceFactory(DjangoModelFactory):
+ class Meta:
+ model = AllocationChangeStatusChoice
+ django_get_or_create = ('name',)
+ name = 'Pending'
+
+
+class AllocationChangeRequestFactory(DjangoModelFactory):
+ class Meta:
+ model = AllocationChangeRequest
+
+ allocation = SubFactory(AllocationFactory)
+ status = SubFactory(AllocationChangeStatusChoiceFactory)
+ justification = factory.Faker('sentence')
+
+
+class AllocationAttributeChangeRequestFactory(DjangoModelFactory):
+ class Meta:
+ model = AllocationAttributeChangeRequest
+
+ allocation_change_request = SubFactory(AllocationChangeRequestFactory)
+ allocation_attribute = SubFactory(AllocationAttributeFactory)
+ new_value = 1000
+
+
+
+### Allocation User factories ###
+
+class AllocationUserStatusChoiceFactory(DjangoModelFactory):
+ class Meta:
+ model = AllocationUserStatusChoice
+ django_get_or_create = ('name',)
+ name = 'Active'
+
+
+class AllocationUserFactory(DjangoModelFactory):
+ class Meta:
+ model = AllocationUser
+ django_get_or_create = ('allocation','user')
+ allocation = SubFactory(AllocationFactory)
+ user = SubFactory(UserFactory)
+ status = SubFactory(AllocationUserStatusChoiceFactory)
+
+
+class AllocationUserNoteFactory(DjangoModelFactory):
+ class Meta:
+ model = AllocationUserNote
+ django_get_or_create = ('allocation')
+ allocation = SubFactory(AllocationFactory)
+ author = SubFactory(AllocationUserFactory)
+ note = factory.Faker('sentence')
diff --git a/coldfront/core/test_helpers/utils.py b/coldfront/core/test_helpers/utils.py
new file mode 100644
index 0000000000..f03c25f126
--- /dev/null
+++ b/coldfront/core/test_helpers/utils.py
@@ -0,0 +1,80 @@
+"""utility functions for unit and integration testing"""
+
+def login_and_get_page(client, user, page):
+ """force login and return get response for page"""
+ client.force_login(user, backend="django.contrib.auth.backends.ModelBackend")
+ return client.get(page)
+
+def page_contains_for_user(test_case, user, url, text):
+ """Check that page contains text for user"""
+ response = login_and_get_page(test_case.client, user, url)
+ test_case.assertContains(response, text)
+
+def page_does_not_contain_for_user(test_case, user, url, text):
+ """Check that page contains text for user"""
+ response = login_and_get_page(test_case.client, user, url)
+ test_case.assertNotContains(response, text)
+
+def test_logged_out_redirect_to_login(test_case, page):
+ """
+ Confirm that attempting to access page while not logged in triggers a 302
+ redirect to a login page.
+
+ Parameters
+ ----------
+ test_case : must have client.
+ page : str
+ must begin and end with a slash.
+ """
+ # log out, in case already logged in
+ test_case.client.logout()
+ response = test_case.client.get(page)
+ test_case.assertRedirects(response, f'/user/login?next={page}')
+
+def test_redirect(test_case, page):
+ """
+ Confirm that attempting to access page in whatever test_case state is given
+ produces a redirect.
+
+ Parameters
+ ----------
+ test_case : must have client.
+ page : str
+ must begin and end with a slash.
+
+ Returns
+ -------
+ response.url : string
+ the redirected url given.
+ """
+ response = test_case.client.get(page)
+ test_case.assertEqual(response.status_code, 302)
+ return response.url
+
+def test_user_cannot_access(test_case, user, page):
+ """Confirm that accessing the page as the designated user returns a 403 response code.
+
+ Parameters
+ ----------
+ test_case : django.test.TestCase.
+ must have "client" attr set.
+ user : user object
+ page : str
+ must begin and end with a slash.
+ """
+ response = login_and_get_page(test_case.client, user, page)
+ test_case.assertEqual(response.status_code, 403)
+
+def test_user_can_access(test_case, user, page):
+ """Confirm that accessing the page as the designated user returns a 200 response code.
+
+ Parameters
+ ----------
+ test_case : django.test.TestCase.
+ must have "client" attr set.
+ user : user object
+ page : str
+ must begin and end with a slash.
+ """
+ response = login_and_get_page(test_case.client, user, page)
+ test_case.assertEqual(response.status_code, 200)
diff --git a/coldfront/core/utils/mail.py b/coldfront/core/utils/mail.py
index 286cab4805..28529e4e92 100644
--- a/coldfront/core/utils/mail.py
+++ b/coldfront/core/utils/mail.py
@@ -1,4 +1,6 @@
import logging
+import pprint
+
from smtplib import SMTPException
from django.conf import settings
@@ -53,19 +55,25 @@ def send_email(subject, body, sender, receiver_list, cc=[]):
cc=cc)
email.send(fail_silently=False)
else:
+ logger.warn(f"SENDING EMAIL TO {receiver_list}")
send_mail(subject, body, sender,
receiver_list, fail_silently=False)
except SMTPException as e:
logger.error('Failed to send email to %s from %s with subject %s',
sender, ','.join(receiver_list), subject)
+ logger.error(pprint.pformat(e))
def send_email_template(subject, template_name, template_context, sender, receiver_list):
"""Helper function for sending emails from a template
"""
+
+ logger.warn(f"EMAIL_ENABLED: {EMAIL_ENABLED}")
if not EMAIL_ENABLED:
return
-
+
+
+ logger.warn("SENDING EMAIL")
body = render_to_string(template_name, template_context)
return send_email(subject, body, sender, receiver_list)
diff --git a/coldfront/plugins/ldap_user_search/README.md b/coldfront/plugins/ldap_user_search/README.md
index d92278d76c..d33d530c74 100644
--- a/coldfront/plugins/ldap_user_search/README.md
+++ b/coldfront/plugins/ldap_user_search/README.md
@@ -10,19 +10,106 @@ search.py code in the FreeIPA plugin.
## Design
ColdFront provides an API to define additional user search classes for
-extending the default search functionality. This app implements a
-LDAPUserSearch class in utils.py which performs the LDAP search. This class is
-then registered with ColdFront by setting "ADDITIONAL\_USER\_SEARCH\_CLASSES"
-in local\_settings.py.
+extending the default search functionality. This app implements an
+`LDAPUserSearch` class in `utils.py` which performs the LDAP search. This class is
+then registered with ColdFront by setting `ADDITIONAL_USER_SEARCH_CLASSES`
+in `config/plugins/ldap_user_search.py`. This class also allows customization
+through Django settings of the attributes requested and how they're mapped to
+ColdFront users.
## Requirements
-- pip install python-ldap ldap3
+- `pip install python-ldap ldap3`
## Usage
-To enable this plugin add the following in your `local_settings.py` file:
+To enable this plugin set the following applicable environment variables:
+| Option | Default | Description |
+| --- | --- | --- |
+| `LDAP_USER_SEARCH_SERVER_URI` | N/A | URI for the LDAP server, required |
+| `LDAP_USER_SEARCH_BASE` | N/A | Search base, required |
+| `LDAP_USER_SEARCH_BIND_DN` | None | Bind DN |
+| `LDAP_USER_SEARCH_BIND_PASSWORD` | None | Bind Password |
+| `LDAP_USER_SEARCH_CONNECT_TIMEOUT` | 2.5 | Time in seconds before the connection times out |
+| `LDAP_USER_SEARCH_USE_SSL` | True | Whether or not to use SSL |
+| `LDAP_USER_SEARCH_USE_TLS` | False | Whether or not to use TLS |
+| `LDAP_USER_SEARCH_SASL_MECHANISM` | None | One of `"EXTERNAL"`, `"DIGEST-MD5"`, `"GSSAPI"`, or `None` |
+| `LDAP_USER_SEARCH_SASL_CREDENTIALS` | None | SASL authorization identity string. If you don't have one and `None` doesn't work, try `""`. |
+| `LDAP_USER_SEARCH_PRIV_KEY_FILE` | None | Path to the private key file |
+| `LDAP_USER_SEARCH_CERT_FILE` | None | Path to the certificate file |
+| `LDAP_USER_SEARCH_CACERT_FILE` | None | Path to the CA certificate file |
+
+The following can be set in your local settings:
+| `LDAP_USER_SEARCH_ATTRIBUTE_MAP` | `{"username": "uid", "last_name": "sn", "first_name": "givenName", "email": "mail"}` | A mapping from ColdFront user attributes to LDAP attributes. |
+| `LDAP_USER_SEARCH_MAPPING_CALLBACK` | See below. | Function that maps LDAP search results to ColdFront user attributes. See more below. |
+
+`LDAP_USER_SEARCH_MAPPING_CALLBACK` default:
+```py
+def parse_ldap_entry(attribute_map, entry_dict):
+ user_dict = {}
+ for user_attr, ldap_attr in attribute_map.items():
+ user_dict[user_attr] = entry_dict.get(ldap_attr)[0] if entry_dict.get(ldap_attr) else ''
+ return user_dict
+```
+
+For custom attributes, set the Django variable `LDAP_USER_SEARCH_ATTRIBUTE_MAP` in ColdFront's [local settings](https://coldfront.readthedocs.io/en/latest/config/#configuration-files). This dictionary maps from ColdFront User attributes to LDAP attributes:
+```py
+# default
+LDAP_USER_SEARCH_ATTRIBUTE_MAP = {
+ "username": "uid",
+ "last_name": "sn",
+ "first_name": "givenName",
+ "email": "mail",
+}
```
-ADDITIONAL_USER_SEARCH_CLASSES = ['coldfront.plugins.ldap_user_search.utils.LDAPUserSearch',]
+
+You can also set the attribute to search by through the variable `LDAP_USER_SEARCH_USERNAME_ONLY_ATTR`. This might be useful if you wish to instead search LDAP with an email instead of username.
+```py
+# this will make the call to search_a_user("john.doe@example.com", "email") search
+# for "john.doe@example.com" with the LDAP attribute "mail" if you're using the above map.
+LDAP_USER_SEARCH_USERNAME_ONLY_ATTR = "email"
```
+
+To set a custom mapping, define an `LDAP_USER_SEARCH_MAPPING_CALLBACK` function with parameters `attr_map` and `entry_dict` that returns a dictionary mapping ColdFront User attributes to their values. `attr_map` is just `LDAP_USER_SEARCH_ATTRIBUTE_MAP`, and `entry_dict` is further explained below.
+
+For example, if your LDAP schema provides a full name and no first and last name attributes, you can define `LDAP_USER_SEARCH_ATTRIBUTE_MAP` and `LDAP_USER_SEARCH_MAPPING_CALLBACK` as follows:
+
+```py
+LDAP_USER_SEARCH_ATTRIBUTE_MAP = {
+ "username": "uid",
+ "email": "mail",
+ "full_name": "cn",
+}
+
+def LDAP_USER_SEARCH_MAPPING_CALLBACK(attr_map, entry_dict):
+ user_dict = {
+ "username": entry_dict.get(attr_map["username"])[0],
+ "email": entry_dict.get(attr_map["email"])[0],
+ "first_name": entry_dict.get(attr_map["full_name"])[0].split(" ")[0],
+ "last_name": entry_dict.get(attr_map["full_name"])[0].split(" ")[-1],
+ }
+ return user_dict
+```
+
+`entry_dict` is provided as a dictionary mapping from the LDAP attribute to a list of values.
+```py
+entry_dict = {
+ 'mail': ['jane.emily.doe@example.com'],
+ 'cn': ['Jane E Doe'],
+ 'uid': ['janedoe1234']
+}
+```
+
+If this was the input to the above callback, `user_dict` would look like this:
+```py
+user_dict = {
+ "username": "janedoe1234",
+ "email": "jane.emily.doe@example.com",
+ "first_name": "Jane",
+ "last_name": "Doe",
+}
+```
+
+## Details
+The `search_a_user` function also allows searching for a specific attribute. Providing the `search_by` parameter with a key to the attribute map will have it search for the corresponding attribute.
diff --git a/coldfront/plugins/ldap_user_search/utils.py b/coldfront/plugins/ldap_user_search/utils.py
index b2f42cd783..1ee7ba5e50 100644
--- a/coldfront/plugins/ldap_user_search/utils.py
+++ b/coldfront/plugins/ldap_user_search/utils.py
@@ -4,10 +4,11 @@
import ldap.filter
from coldfront.core.user.utils import UserSearch
from coldfront.core.utils.common import import_from_settings
-from ldap3 import Connection, Server
+from ldap3 import Connection, Server, Tls, get_config_parameter, set_config_parameter, SASL
logger = logging.getLogger(__name__)
+
class LDAPUserSearch(UserSearch):
search_source = 'LDAP'
@@ -19,42 +20,80 @@ def __init__(self, user_search_string, search_by):
self.LDAP_BIND_PASSWORD = import_from_settings('LDAP_USER_SEARCH_BIND_PASSWORD', None)
self.LDAP_CONNECT_TIMEOUT = import_from_settings('LDAP_USER_SEARCH_CONNECT_TIMEOUT', 2.5)
self.LDAP_USE_SSL = import_from_settings('LDAP_USER_SEARCH_USE_SSL', True)
+ self.LDAP_USE_TLS = import_from_settings("LDAP_USER_SEARCH_USE_TLS", False)
+ self.LDAP_SASL_MECHANISM = import_from_settings("LDAP_USER_SEARCH_SASL_MECHANISM", None)
+ self.LDAP_SASL_CREDENTIALS = import_from_settings("LDAP_USER_SEARCH_SASL_CREDENTIALS", None)
+ self.LDAP_PRIV_KEY_FILE = import_from_settings('LDAP_USER_SEARCH_PRIV_KEY_FILE', None)
+ self.LDAP_CERT_FILE = import_from_settings('LDAP_USER_SEARCH_CERT_FILE', None)
+ self.LDAP_CACERT_FILE = import_from_settings('LDAP_USER_SEARCH_CACERT_FILE', None)
+ self.USERNAME_ONLY_ATTR = import_from_settings('LDAP_USER_SEARCH_USERNAME_ONLY_ATTR', 'username')
+ self.ATTRIBUTE_MAP = import_from_settings('LDAP_USER_SEARCH_ATTRIBUTE_MAP', {
+ "username": "uid",
+ "last_name": "sn",
+ "first_name": "givenName",
+ "email": "mail",
+ })
+ self.MAPPING_CALLBACK = import_from_settings('LDAP_USER_SEARCH_MAPPING_CALLBACK', self.parse_ldap_entry)
- self.server = Server(self.LDAP_SERVER_URI, use_ssl=self.LDAP_USE_SSL, connect_timeout=self.LDAP_CONNECT_TIMEOUT)
- self.conn = Connection(self.server, self.LDAP_BIND_DN, self.LDAP_BIND_PASSWORD, auto_bind=True)
-
- def parse_ldap_entry(self, entry):
- entry_dict = json.loads(entry.entry_to_json()).get('attributes')
+ tls = None
+ if self.LDAP_USE_TLS:
+ tls = Tls(
+ local_private_key_file=self.LDAP_PRIV_KEY_FILE,
+ local_certificate_file=self.LDAP_CERT_FILE,
+ ca_certs_file=self.LDAP_CACERT_FILE,
+ )
- user_dict = {
- 'last_name': entry_dict.get('sn')[0] if entry_dict.get('sn') else '',
- 'first_name': entry_dict.get('givenName')[0] if entry_dict.get('givenName') else '',
- 'username': entry_dict.get('uid')[0] if entry_dict.get('uid') else '',
- 'email': entry_dict.get('mail')[0] if entry_dict.get('mail') else '',
- 'source': self.search_source,
- }
+ self.server = Server(self.LDAP_SERVER_URI, use_ssl=self.LDAP_USE_SSL, connect_timeout=self.LDAP_CONNECT_TIMEOUT, tls=tls)
+ conn_params = {"auto_bind": True}
+ if self.LDAP_SASL_MECHANISM:
+ conn_params["sasl_mechanism"] = self.LDAP_SASL_MECHANISM
+ conn_params["sasl_credentials"] = self.LDAP_SASL_CREDENTIALS
+ conn_params["authentication"] = SASL
+ self.conn = Connection(self.server, self.LDAP_BIND_DN, self.LDAP_BIND_PASSWORD, **conn_params)
+ @staticmethod
+ def parse_ldap_entry(attribute_map, entry_dict):
+ user_dict = {}
+ for user_attr, ldap_attr in attribute_map.items():
+ user_dict[user_attr] = entry_dict.get(ldap_attr)[0] if entry_dict.get(ldap_attr) else ''
return user_dict
def search_a_user(self, user_search_string=None, search_by='all_fields'):
size_limit = 50
+ ldap_attrs = list(self.ATTRIBUTE_MAP.values())
+ attrs = get_config_parameter("ATTRIBUTES_EXCLUDED_FROM_CHECK")
+ attrs.extend(ldap_attrs)
+ set_config_parameter("ATTRIBUTES_EXCLUDED_FROM_CHECK", attrs)
if user_search_string and search_by == 'all_fields':
- filter = ldap.filter.filter_format("(|(givenName=*%s*)(sn=*%s*)(uid=*%s*)(mail=*%s*))", [user_search_string] * 4)
+ filter = ldap.filter.filter_format(
+ f"(|({ldap_attrs[0]}=*%s*)({ldap_attrs[1]}=*%s*)({ldap_attrs[2]}=*%s*)({ldap_attrs[3]}=*%s*))",
+ [user_search_string] * 4)
elif user_search_string and search_by == 'username_only':
- filter = ldap.filter.filter_format("(uid=%s)", [user_search_string])
+ attr = self.USERNAME_ONLY_ATTR
+ filter = ldap.filter.filter_format(
+ f"({self.ATTRIBUTE_MAP[attr]}=%s)", [user_search_string]
+ )
+ size_limit = 1
+ elif user_search_string and search_by in self.ATTRIBUTE_MAP.keys():
+ filter = ldap.filter.filter_format(
+ f"({self.ATTRIBUTE_MAP[search_by]}=%s)", [user_search_string]
+ )
size_limit = 1
else:
filter = '(objectclass=person)'
searchParameters = {'search_base': self.LDAP_USER_SEARCH_BASE,
'search_filter': filter,
- 'attributes': ['uid', 'sn', 'givenName', 'mail'],
+ 'attributes': ldap_attrs,
'size_limit': size_limit}
+ logger.debug(f"search params: {searchParameters}")
self.conn.search(**searchParameters)
users = []
for idx, entry in enumerate(self.conn.entries, 1):
- user_dict = self.parse_ldap_entry(entry)
+ entry_dict = json.loads(entry.entry_to_json()).get('attributes')
+ logger.debug(f"Entry dict: {entry_dict}")
+ user_dict = self.MAPPING_CALLBACK(self.ATTRIBUTE_MAP, entry_dict)
+ user_dict["source"] = self.search_source
users.append(user_dict)
-
logger.info("LDAP user search for %s found %s results", user_search_string, len(users))
return users
diff --git a/coldfront/plugins/mokey_oidc/README.md b/coldfront/plugins/mokey_oidc/README.md
index 0e5eb10ab9..226f1539a9 100644
--- a/coldfront/plugins/mokey_oidc/README.md
+++ b/coldfront/plugins/mokey_oidc/README.md
@@ -20,11 +20,11 @@ Django users.
- pip install mozilla-django-oidc
## Usage
+### Mokey/Hydra integration
To enable this plugin set the following environment variables:
```
-
PLUGIN_AUTH_OIDC=True
PLUGIN_MOKEY=True
OIDC_OP_JWKS_ENDPOINT="https://hydra.local/.well-known/jwks.json"
@@ -35,3 +35,9 @@ OIDC_OP_AUTHORIZATION_ENDPOINT="https://hydra.local/oauth2/auth"
OIDC_OP_TOKEN_ENDPOINT="https://hydra.local/oauth2/token"
OIDC_OP_USER_ENDPOINT="https://hydra.local/userinfo"
```
+
+### OIDC
+If you are just using OIDC and do not need Mokey/Hydra integration:
+- Set the above environment variables, but do not set `PLUGIN_MOKEY`.
+- In your [ColdFront configuration file](https://coldfront.readthedocs.io/en/latest/config/#configuration-files) (`local_settings.py` or set by the `COLDFRONT_CONFIG` environment variable), set `SESSION_COOKIE_SAMESITE = "Lax"`
+- You may also need to edit `mozilla-django-oidc` [settings](https://mozilla-django-oidc.readthedocs.io/en/stable/settings.html) in your `local_settings.py`.
diff --git a/coldfront/plugins/mokey_oidc/requirements.txt b/coldfront/plugins/mokey_oidc/requirements.txt
index 40266719ad..5527d952a7 100644
--- a/coldfront/plugins/mokey_oidc/requirements.txt
+++ b/coldfront/plugins/mokey_oidc/requirements.txt
@@ -1 +1 @@
-mozilla-django-oidc==1.2.1
+mozilla-django-oidc==4.0.0
diff --git a/coldfront/plugins/qumulo/__init__.py b/coldfront/plugins/qumulo/__init__.py
new file mode 100644
index 0000000000..239039c8ea
--- /dev/null
+++ b/coldfront/plugins/qumulo/__init__.py
@@ -0,0 +1 @@
+default_app_config = "coldfront.plugins.qumulo.apps.QumuloConfig"
diff --git a/coldfront/plugins/qumulo/apps.py b/coldfront/plugins/qumulo/apps.py
new file mode 100644
index 0000000000..2101052755
--- /dev/null
+++ b/coldfront/plugins/qumulo/apps.py
@@ -0,0 +1,8 @@
+from django.apps import AppConfig
+
+
+class QumuloConfig(AppConfig):
+ name = "coldfront.plugins.qumulo"
+
+ def ready(self):
+ import coldfront.plugins.qumulo.signals
diff --git a/coldfront/plugins/qumulo/constants.py b/coldfront/plugins/qumulo/constants.py
new file mode 100644
index 0000000000..89f85bbe22
--- /dev/null
+++ b/coldfront/plugins/qumulo/constants.py
@@ -0,0 +1,7 @@
+STORAGE_SERVICE_RATES = [
+ ("subscription", "Subscription"),
+ ("condo", "Condo"),
+ ("consumption", "Consumption"),
+]
+
+PROTOCOL_OPTIONS = [("nfs", "NFS"), ("smb", "SMB")]
diff --git a/coldfront/plugins/qumulo/fields.py b/coldfront/plugins/qumulo/fields.py
new file mode 100644
index 0000000000..f4fd6d0fbe
--- /dev/null
+++ b/coldfront/plugins/qumulo/fields.py
@@ -0,0 +1,44 @@
+import os
+from django import forms
+
+from coldfront.plugins.qumulo.validators import (
+ validate_ad_users,
+ validate_filesystem_path_unique,
+ validate_parent_directory,
+ validate_storage_root,
+)
+
+from coldfront.plugins.qumulo.widgets import MultiSelectLookupInput
+
+from pathlib import PurePath
+
+
+class ADUserField(forms.Field):
+ widget = MultiSelectLookupInput
+ default_validators = [validate_ad_users]
+
+ def prepare_value(self, value: list[str]):
+ value_str = ",".join(value)
+
+ return value_str
+
+ def to_python(self, value: list[str]):
+ return list(filter(lambda element: len(element), value))
+
+
+class StorageFileSystemPathField(forms.CharField):
+ default_validators = [
+ validate_storage_root,
+ validate_parent_directory,
+ validate_filesystem_path_unique,
+ ]
+
+ def run_validators(self, value: str) -> None:
+ is_absolute_path = PurePath(value).is_absolute()
+ if not is_absolute_path:
+ storage_root = os.environ.get("STORAGE2_PATH").strip("/")
+ full_path = f"/{storage_root}/{value}"
+ else:
+ full_path = value
+
+ return super().run_validators(full_path)
diff --git a/coldfront/plugins/qumulo/forms.py b/coldfront/plugins/qumulo/forms.py
new file mode 100644
index 0000000000..4d60088ff6
--- /dev/null
+++ b/coldfront/plugins/qumulo/forms.py
@@ -0,0 +1,247 @@
+import re
+
+from typing import Any
+from django import forms
+
+from coldfront.core.project.models import Project
+from coldfront.core.user.models import User
+from coldfront.core.field_of_science.models import FieldOfScience
+from coldfront.plugins.qumulo.fields import ADUserField, StorageFileSystemPathField
+from coldfront.plugins.qumulo.validators import (
+ validate_leading_forward_slash,
+ validate_single_ad_user_skip_admin,
+ validate_single_ad_user,
+ validate_ticket,
+ validate_storage_name,
+)
+
+from coldfront.plugins.qumulo.constants import STORAGE_SERVICE_RATES, PROTOCOL_OPTIONS
+
+
+from coldfront.core.allocation.models import (
+ AllocationStatusChoice,
+)
+
+from django.db.models.functions import Lower
+
+
+class AllocationForm(forms.Form):
+ def __init__(self, *args, **kwargs):
+ self.user_id = kwargs.pop("user_id")
+ super(forms.Form, self).__init__(*args, **kwargs)
+ self.fields["project_pk"].choices = self.get_project_choices()
+
+ class Media:
+ js = ("allocation.js",)
+
+ project_pk = forms.ChoiceField(label="Project")
+ storage_name = forms.CharField(
+ help_text="Name of the Allocation",
+ label="Name",
+ validators=[validate_storage_name],
+ )
+ cost_center = forms.CharField(
+ help_text="The cost center for billing",
+ label="Cost Center",
+ )
+ department_number = forms.CharField(
+ help_text="The department for billing",
+ label="Department Number",
+ )
+ technical_contact = forms.CharField(
+ help_text="Who should be contacted regarding technical details. Accepts one WUSTL key.",
+ label="Technical Contact",
+ validators=[validate_single_ad_user],
+ required=False,
+ )
+ billing_contact = forms.CharField(
+ help_text="Who should be contacted regarding billing details. Accepts a single WUSTL key.",
+ label="Billing Contact",
+ validators=[validate_single_ad_user],
+ required=False,
+ )
+ service_rate = forms.ChoiceField(
+ help_text="Service rate option for the Storage2 allocation",
+ label="Service Rate",
+ choices=STORAGE_SERVICE_RATES,
+ )
+ storage_quota = forms.IntegerField(
+ min_value=0,
+ max_value=2000,
+ help_text="Size of the allocation quota in TB",
+ label="Limit (TB)",
+ )
+ protocols = forms.MultipleChoiceField(
+ widget=forms.CheckboxSelectMultiple(),
+ choices=PROTOCOL_OPTIONS,
+ label="Protocols",
+ help_text="Choose one or more protocols from the above list",
+ initial=["smb"],
+ required=False,
+ )
+ storage_filesystem_path = StorageFileSystemPathField(
+ help_text="Path of the allocation resource",
+ label="Filesystem Path",
+ )
+ storage_export_path = forms.CharField(
+ help_text="Path of the allocation resource",
+ label="Export Path",
+ initial="",
+ required=False,
+ validators=[validate_leading_forward_slash],
+ )
+ storage_ticket = forms.CharField(
+ help_text="Associated IT Service Desk Ticket",
+ label="ITSD Ticket",
+ validators=[validate_ticket],
+ )
+ rw_users = ADUserField(
+ label="Read/Write Users",
+ initial="",
+ )
+ ro_users = ADUserField(label="Read Only Users", initial="", required=False)
+
+ def _upper(self, val: Any) -> Any:
+ return val.upper() if isinstance(val, str) else val
+
+ def _s3_allocation_name_explain(self, name: str):
+ preStr = "name for allocation using S3 protocol"
+ if not re.match(r"^[a-z0-9]{1}", name):
+ return "{:s} must begin with a-z or 0-9".format(preStr)
+ if not re.search(r"[a-z0-9]{1}$", name):
+ return "{:s} must end with a-z or 0-9".format(preStr)
+ if len(name) < 3 or len(name) > 63:
+ return "{:s} must be between 3 and 63 characters in length".format(preStr)
+ if re.search(r"[^a-z0-9\-\.]", name):
+ return "{:s} contains invalid characters".format(preStr)
+ return "{:s} has an unknown error".format(preStr)
+
+ def _validate_s3_allocation_name(self, name: str):
+ nameRe = re.compile(r"^[a-z0-9]{1}[a-z0-9\-\.]{1,61}[a-z0-9]{1}$")
+ if not nameRe.match(name):
+ self.add_error(
+ "storage_name",
+ "{:s}: {:s}".format(name, self._s3_allocation_name_explain(name)),
+ )
+ elif ".." in name:
+ self.add_error(
+ "storage_name",
+ "{:s}: double periods (..) not allowed for S3 protocol".format(name),
+ )
+ elif re.match(r"^\d+\.\d+\.\d+\.\d+", name):
+ self.add_error(
+ "storage_name",
+ "{:s}: S3 allocation must not be formatted as an IPV4 address".format(
+ name
+ ),
+ )
+ elif re.match(r"^xn--", name):
+ self.add_error(
+ "storage_name",
+ "{:s}: xn-- is an illegal prefix for S3 allocation".format(name),
+ )
+ elif re.match(r"^sthree-", name):
+ self.add_error(
+ "storage_name",
+ "{:s}: sthree- is an illegal prefix for S3 allocation".format(name),
+ )
+ elif re.search(r"-s3alias$", name):
+ self.add_error(
+ "storage_name",
+ "{:s}: -s3alias is an illegal suffix for S3 allocation".format(name),
+ )
+ elif re.search(r"--ol-s3$", name):
+ self.add_error(
+ "storage_name",
+ "{:s}: --ol-s3 is an illegal suffix for S3 allocation".format(name),
+ )
+
+ def clean(self) -> dict[str, Any]:
+ cleaned_data = super().clean()
+ protocols = cleaned_data.get("protocols")
+ storage_export_path = cleaned_data.get("storage_export_path")
+ storage_ticket = self._upper(cleaned_data.get("storage_ticket", None))
+
+ if "nfs" in protocols:
+ if storage_export_path == "":
+ self.add_error(
+ "storage_export_path",
+ "Export Path must be defined when using NFS protocol",
+ )
+
+ if storage_ticket is not None:
+ if "ITSD-" not in storage_ticket and len(storage_ticket) > 0:
+ self.cleaned_data["storage_ticket"] = "ITSD-{:s}".format(storage_ticket)
+ else:
+ self.cleaned_data["storage_ticket"] = storage_ticket
+
+ def get_project_choices(self) -> list[str]:
+ # jprew - NOTE: accesses to db collections should be consolidated to
+ # single classes
+ user = User.objects.get(id=self.user_id)
+
+ if user.is_superuser or user.has_perm("project.can_view_all_projects"):
+ projects = Project.objects.all()
+ else:
+ projects = Project.objects.filter(pi=self.user_id)
+
+ return map(lambda project: (project.id, project.title), projects)
+
+
+class UpdateAllocationForm(AllocationForm):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ self.fields["storage_name"].disabled = True
+ self.fields["storage_filesystem_path"].disabled = True
+
+ self.fields["storage_filesystem_path"].validators = []
+ self.fields["storage_name"].validators = []
+
+
+class ProjectCreateForm(forms.Form):
+ def __init__(self, *args, **kwargs):
+ self.user_id = kwargs.pop("user_id")
+ super().__init__(*args, **kwargs)
+ self.fields["pi"].initial = self.user_id
+ self.fields["field_of_science"].choices = self.get_fos_choices()
+ self.fields["field_of_science"].initial = FieldOfScience.DEFAULT_PK
+
+ title = forms.CharField(
+ label="Title",
+ max_length=255,
+ )
+ pi = forms.CharField(
+ label="Principal Investigator",
+ max_length=128,
+ validators=[validate_single_ad_user_skip_admin],
+ )
+ description = forms.CharField(
+ required=False,
+ widget=forms.Textarea,
+ )
+ field_of_science = forms.ChoiceField(label="Field of Science")
+
+ def get_fos_choices(self):
+ return map(lambda fos: (fos.id, fos.description), FieldOfScience.objects.all())
+
+
+class AllocationTableSearchForm(forms.Form):
+ project_name = forms.CharField(label="Project Name", max_length=100, required=False)
+ pi_last_name = forms.CharField(label="PI Surname", max_length=100, required=False)
+
+ pi_first_name = forms.CharField(
+ label="PI Given Name", max_length=100, required=False
+ )
+
+ status = forms.ModelMultipleChoiceField(
+ widget=forms.CheckboxSelectMultiple,
+ queryset=AllocationStatusChoice.objects.all().order_by(Lower("name")),
+ required=False,
+ )
+
+ department_number = forms.CharField(
+ label="Department Number", max_length=100, required=False
+ )
+
+ itsd_ticket = forms.CharField(label="ITSD Ticket", max_length=100, required=False)
diff --git a/coldfront/plugins/qumulo/management/__init__.py b/coldfront/plugins/qumulo/management/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/coldfront/plugins/qumulo/management/commands/__init__.py b/coldfront/plugins/qumulo/management/commands/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/coldfront/plugins/qumulo/management/commands/add_allocation_status.py b/coldfront/plugins/qumulo/management/commands/add_allocation_status.py
new file mode 100644
index 0000000000..8f2fc2e614
--- /dev/null
+++ b/coldfront/plugins/qumulo/management/commands/add_allocation_status.py
@@ -0,0 +1,11 @@
+from coldfront.core.allocation.models import AllocationStatusChoice
+from django.core.management.base import BaseCommand
+
+
+class Command(BaseCommand):
+ def handle(self, *args, **options):
+ print("Adding Allocation Statuses")
+ AllocationStatusChoice.objects.get_or_create(name="Pending")
+ AllocationStatusChoice.objects.get_or_create(name="Invalid")
+ AllocationStatusChoice.objects.get_or_create(name="Ready for deletion")
+ AllocationStatusChoice.objects.get_or_create(name="Deleted")
diff --git a/coldfront/plugins/qumulo/management/commands/add_qumulo_allocation_attribute_type.py b/coldfront/plugins/qumulo/management/commands/add_qumulo_allocation_attribute_type.py
new file mode 100644
index 0000000000..cee1438a66
--- /dev/null
+++ b/coldfront/plugins/qumulo/management/commands/add_qumulo_allocation_attribute_type.py
@@ -0,0 +1,128 @@
+from coldfront.core.allocation.models import AllocationAttributeType, AttributeType
+from django.core.management.base import BaseCommand
+
+
+class Command(BaseCommand):
+ def handle(self, *args, **options):
+ # jprew - NOTE - adding new flags to these get_or_create
+ # calls results in the creation of *new* AllocationAttributeType objects
+ # which will lead to errors when finding them by name
+
+ print("Adding Qumulo Allocation Attribute Types")
+ AllocationAttributeType.objects.get_or_create(
+ attribute_type=AttributeType.objects.get(name="Text"),
+ name="storage_name",
+ is_required=True,
+ is_private=False,
+ is_unique=True,
+ is_changeable=False,
+ )
+
+ AllocationAttributeType.objects.get_or_create(
+ attribute_type=AttributeType.objects.get(name="Int"),
+ name="storage_quota",
+ has_usage=True,
+ is_required=True,
+ is_private=False,
+ is_unique=True,
+ is_changeable=True,
+ )
+
+ AllocationAttributeType.objects.get_or_create(
+ attribute_type=AttributeType.objects.get(name="Text"),
+ name="storage_protocols",
+ is_required=True,
+ is_private=False,
+ is_changeable=True,
+ is_unique=True,
+ )
+
+ AllocationAttributeType.objects.get_or_create(
+ attribute_type=AttributeType.objects.get(name="Text"),
+ name="storage_filesystem_path",
+ is_required=True,
+ is_private=False,
+ is_unique=True,
+ is_changeable=False,
+ )
+
+ AllocationAttributeType.objects.get_or_create(
+ attribute_type=AttributeType.objects.get(name="Text"),
+ name="storage_ticket",
+ is_required=False,
+ is_private=False,
+ is_unique=False,
+ is_changeable=False,
+ )
+
+ AllocationAttributeType.objects.get_or_create(
+ attribute_type=AttributeType.objects.get(name="Text"),
+ name="storage_export_path",
+ is_required=True,
+ is_private=False,
+ is_unique=True,
+ is_changeable=False,
+ )
+
+ AllocationAttributeType.objects.get_or_create(
+ attribute_type=AttributeType.objects.get(name="Text"),
+ name="storage_acl_name",
+ is_required=True,
+ is_private=True,
+ is_unique=True,
+ is_changeable=False,
+ )
+
+ AllocationAttributeType.objects.get_or_create(
+ attribute_type=AttributeType.objects.get(name="Int"),
+ name="storage_allocation_pk",
+ is_required=True,
+ is_private=True,
+ is_unique=True,
+ is_changeable=False,
+ )
+
+ AllocationAttributeType.objects.get_or_create(
+ attribute_type=AttributeType.objects.get(name="Text"),
+ name="cost_center",
+ is_required=True,
+ is_private=False,
+ is_unique=True,
+ is_changeable=False,
+ )
+
+ AllocationAttributeType.objects.get_or_create(
+ attribute_type=AttributeType.objects.get(name="Text"),
+ name="department_number",
+ is_required=True,
+ is_private=False,
+ is_unique=True,
+ is_changeable=False,
+ )
+
+ AllocationAttributeType.objects.get_or_create(
+ attribute_type=AttributeType.objects.get(name="Text"),
+ name="technical_contact",
+ is_required=False,
+ is_private=False,
+ is_unique=True,
+ is_changeable=True,
+ )
+
+ AllocationAttributeType.objects.get_or_create(
+ attribute_type=AttributeType.objects.get(name="Text"),
+ name="billing_contact",
+ is_required=False,
+ is_private=False,
+ is_unique=True,
+ is_changeable=True,
+ )
+
+ AllocationAttributeType.objects.get_or_create(
+ attribute_type=AttributeType.objects.get(name="Text"),
+ name="service_rate",
+ is_required=True,
+ is_private=False,
+ is_changeable=True,
+ is_unique=False,
+ )
diff --git a/coldfront/plugins/qumulo/management/commands/add_qumulo_resource.py b/coldfront/plugins/qumulo/management/commands/add_qumulo_resource.py
new file mode 100644
index 0000000000..4bc9dc3b0c
--- /dev/null
+++ b/coldfront/plugins/qumulo/management/commands/add_qumulo_resource.py
@@ -0,0 +1,40 @@
+from coldfront.core.resource.models import Resource, ResourceType
+from django.core.management.base import BaseCommand
+
+
+class Command(BaseCommand):
+ def handle(self, *args, **options):
+ print("Adding Storage2/Qumulo resources")
+ storage_resource_type = ResourceType.objects.get(name="Storage")
+ acl_resource_type, created = ResourceType.objects.get_or_create(name="ACL")
+
+ Resource.objects.get_or_create(
+ resource_type=storage_resource_type,
+ parent_resource=None,
+ name="Storage2",
+ description="Storage allocation via Qumulo",
+ is_available=True,
+ is_public=True,
+ is_allocatable=True,
+ requires_payment=True,
+ )
+
+ Resource.objects.get_or_create(
+ name="rw",
+ description="RW ACL",
+ resource_type=acl_resource_type,
+ is_available=True,
+ is_public=False,
+ is_allocatable=True,
+ requires_payment=False,
+ )
+
+ Resource.objects.get_or_create(
+ name="ro",
+ description="RO ACL",
+ resource_type=acl_resource_type,
+ is_available=True,
+ is_public=False,
+ is_allocatable=True,
+ requires_payment=False,
+ )
diff --git a/coldfront/plugins/qumulo/management/commands/add_scheduled_ad_poller.py b/coldfront/plugins/qumulo/management/commands/add_scheduled_ad_poller.py
new file mode 100644
index 0000000000..97bbf69481
--- /dev/null
+++ b/coldfront/plugins/qumulo/management/commands/add_scheduled_ad_poller.py
@@ -0,0 +1,25 @@
+from django.core.management.base import BaseCommand
+
+from django_q.models import Schedule
+
+from coldfront.plugins.qumulo.tasks import (
+ poll_ad_groups,
+ conditionally_update_storage_allocation_statuses,
+)
+
+
+class Command(BaseCommand):
+ def handle(self, *args, **options):
+ print("Scheduling AD Poller")
+ Schedule.objects.get_or_create(
+ func="coldfront.plugins.qumulo.management.commands.add_scheduled_ad_poller.sequential_poll_and_check",
+ name="Update Pending Allocations",
+ schedule_type=Schedule.MINUTES,
+ minutes=1,
+ repeats=-1,
+ )
+
+
+def sequential_poll_and_check() -> None:
+ poll_ad_groups()
+ conditionally_update_storage_allocation_statuses()
diff --git a/coldfront/plugins/qumulo/management/commands/add_scheduled_daily_allocation_usages.py b/coldfront/plugins/qumulo/management/commands/add_scheduled_daily_allocation_usages.py
new file mode 100644
index 0000000000..b9f15849a1
--- /dev/null
+++ b/coldfront/plugins/qumulo/management/commands/add_scheduled_daily_allocation_usages.py
@@ -0,0 +1,28 @@
+import arrow
+from django.core.management.base import BaseCommand
+
+from django_q.tasks import schedule
+
+from django_q.models import Schedule
+
+from coldfront.plugins.qumulo.tasks import ingest_quotas_with_daily_usage
+
+SCHEDULED_FOR_2_30_AM = \
+ arrow \
+ .utcnow() \
+ .replace(hour=2, minute=30) \
+ .format(arrow.FORMAT_RFC3339)
+
+class Command(BaseCommand):
+
+ def handle(self, *args, **options):
+ print("Scheduling polling daily usage from QUMULO")
+ schedule(
+ "coldfront.plugins.qumulo.management.commands.add_scheduled_daily_allocation_usages.poll_allocation_daily_usages",
+ name="Ingest Allocation Daily Usages",
+ schedule_type=Schedule.DAILY,
+ next_run=SCHEDULED_FOR_2_30_AM
+ )
+
+def poll_allocation_daily_usages() -> None:
+ ingest_quotas_with_daily_usage()
diff --git a/coldfront/plugins/qumulo/management/commands/clean_active_directory_test_data.py b/coldfront/plugins/qumulo/management/commands/clean_active_directory_test_data.py
new file mode 100644
index 0000000000..5da9984438
--- /dev/null
+++ b/coldfront/plugins/qumulo/management/commands/clean_active_directory_test_data.py
@@ -0,0 +1,46 @@
+from coldfront.plugins.qumulo.utils.active_directory_api import ActiveDirectoryAPI
+from django.core.management.base import BaseCommand, CommandParser
+
+import os
+from dotenv import load_dotenv
+
+load_dotenv(override=True)
+
+
+class Command(BaseCommand):
+ help = "Cleans any Active Directory Groups from QA Server that match the provided string.\nRefer to ldap3 documentation for search string format"
+
+ def handle(self, *args, **options):
+ groups_OU = os.environ.get("AD_GROUPS_OU")
+ if "OU=QA" not in groups_OU:
+ print(
+ """INVALID ENV: This script can only be run on servers configured for access to Active Directory's QA O.U."""
+ )
+ return
+
+ print(
+ """WARNING: Run with care!!! Incorrect usage could result in unintended Active Directory resources being deleted. Only use to delete testing data."""
+ )
+ search_str = input("Provide search string:")
+
+ active_directory_api = ActiveDirectoryAPI()
+
+ active_directory_api.conn.search(
+ "dc=accounts,dc=ad,dc=wustl,dc=edu", f"(cn={search_str})"
+ )
+
+ groups = active_directory_api.conn.response
+
+ for group in groups:
+ cn = next(
+ filter(lambda element: element.startswith("CN"), group["dn"].split(","))
+ )
+
+ user_choice = input(
+ f"The following group will be deleted: {cn}. Are you sure?(yes)"
+ )
+
+ if user_choice == "yes":
+ active_directory_api.conn.delete(group["dn"])
+ else:
+ print(f"Skipping deletion of {cn}")
diff --git a/coldfront/plugins/qumulo/management/commands/qumulo_plugin_setup.py b/coldfront/plugins/qumulo/management/commands/qumulo_plugin_setup.py
new file mode 100644
index 0000000000..7c06042331
--- /dev/null
+++ b/coldfront/plugins/qumulo/management/commands/qumulo_plugin_setup.py
@@ -0,0 +1,19 @@
+from django.core.management import call_command
+from django.core.management.base import BaseCommand
+
+
+class Command(BaseCommand):
+ help = "Run setup script to initialize the Coldfront database"
+
+ def handle(self, *args, **options):
+ print("Running Coldfront Plugin Qumulo setup script")
+ call_base_commands()
+ call_command("add_scheduled_ad_poller")
+ call_command("add_scheduled_daily_allocation_usages")
+ print("Coldfront Plugin Qumulo setup script complete")
+
+
+def call_base_commands():
+ call_command("add_qumulo_resource")
+ call_command("add_qumulo_allocation_attribute_type")
+ call_command("add_allocation_status")
diff --git a/coldfront/plugins/qumulo/management/commands/run_ad_poller.py b/coldfront/plugins/qumulo/management/commands/run_ad_poller.py
new file mode 100644
index 0000000000..771e0747a4
--- /dev/null
+++ b/coldfront/plugins/qumulo/management/commands/run_ad_poller.py
@@ -0,0 +1,20 @@
+from django.core.management.base import BaseCommand
+
+from django_q.tasks import async_chain
+
+from coldfront.plugins.qumulo.tasks import (
+ poll_ad_groups,
+ conditionally_update_storage_allocation_statuses,
+)
+
+
+class Command(BaseCommand):
+ help = (
+ "Run Active Directory poller to update ACL allocations and storage allocations"
+ )
+
+ def handle(self, *args, **options):
+ print("Running AD Poller")
+ async_chain(
+ [(poll_ad_groups), (conditionally_update_storage_allocation_statuses)]
+ )
diff --git a/coldfront/plugins/qumulo/requirements.txt b/coldfront/plugins/qumulo/requirements.txt
new file mode 100644
index 0000000000..d79aedffb2
--- /dev/null
+++ b/coldfront/plugins/qumulo/requirements.txt
@@ -0,0 +1,4 @@
+qumulo-api >= 6.2, < 6.3
+python-dotenv
+ldap3
+deepdiff
\ No newline at end of file
diff --git a/coldfront/plugins/qumulo/runtests_integration.py b/coldfront/plugins/qumulo/runtests_integration.py
new file mode 100644
index 0000000000..1dfb47b5b9
--- /dev/null
+++ b/coldfront/plugins/qumulo/runtests_integration.py
@@ -0,0 +1,15 @@
+#!/usr/bin/env python
+import os
+import sys
+
+import django
+from django.conf import settings
+from django.test.utils import get_runner
+
+if __name__ == "__main__":
+ os.environ["DJANGO_SETTINGS_MODULE"] = "tests.test_settings"
+ django.setup()
+ TestRunner = get_runner(settings)
+ test_runner = TestRunner()
+ failures = test_runner.run_tests(["tests_integration"])
+ sys.exit(bool(failures))
diff --git a/coldfront/plugins/qumulo/settings.py b/coldfront/plugins/qumulo/settings.py
new file mode 100644
index 0000000000..ae22df1ad1
--- /dev/null
+++ b/coldfront/plugins/qumulo/settings.py
@@ -0,0 +1,131 @@
+"""
+Django settings for coldfront.plugins.qumulo project.
+
+Generated by 'django-admin startproject' using Django 3.2.20.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/3.2/topics/settings/
+
+For the full list of settings and their values, see
+https://docs.djangoproject.com/en/3.2/ref/settings/
+"""
+
+from pathlib import Path
+
+# Build paths inside the project like this: BASE_DIR / 'subdir'.
+BASE_DIR = Path(__file__).resolve().parent.parent
+
+
+# Quick-start development settings - unsuitable for production
+# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
+
+# SECURITY WARNING: keep the secret key used in production secret!
+SECRET_KEY = "django-insecure-1^v4f8n86@9qh*xrk6md(ea0(7fag#yi@6xoz75+=*gt1%$os="
+
+# SECURITY WARNING: don't run with debug turned on in production!
+DEBUG = True
+
+ALLOWED_HOSTS = []
+
+
+# Application definition
+
+INSTALLED_APPS = [
+ "coldfront.core.project",
+ "coldfront.core.field_of_science",
+ "coldfront.core.resource",
+ "coldfront.core.allocation",
+ "coldfront.core.user",
+ "django.contrib.admin",
+ "django.contrib.auth",
+ "django.contrib.contenttypes",
+ "django.contrib.sessions",
+ "django.contrib.messages",
+ "django.contrib.staticfiles",
+ "coldfront.plugins.qumulo",
+]
+
+MIDDLEWARE = [
+ "django.middleware.security.SecurityMiddleware",
+ "django.contrib.sessions.middleware.SessionMiddleware",
+ "django.middleware.common.CommonMiddleware",
+ "django.middleware.csrf.CsrfViewMiddleware",
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
+ "django.contrib.messages.middleware.MessageMiddleware",
+ "django.middleware.clickjacking.XFrameOptionsMiddleware",
+]
+
+ROOT_URLCONF = "coldfront.plugins.qumulo.urls"
+
+TEMPLATES = [
+ {
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
+ "DIRS": [],
+ "APP_DIRS": True,
+ "OPTIONS": {
+ "context_processors": [
+ "django.template.context_processors.debug",
+ "django.template.context_processors.request",
+ "django.contrib.auth.context_processors.auth",
+ "django.contrib.messages.context_processors.messages",
+ ],
+ },
+ },
+]
+
+# WSGI_APPLICATION = 'coldfront.plugins.qumulo.wsgi.application'
+
+
+# Database
+# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
+
+DATABASES = {
+ "default": {
+ "ENGINE": "django.db.backends.sqlite3",
+ "NAME": BASE_DIR / "db.sqlite3",
+ }
+}
+
+
+# Password validation
+# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
+
+AUTH_PASSWORD_VALIDATORS = [
+ {
+ "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
+ },
+ {
+ "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
+ },
+ {
+ "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
+ },
+ {
+ "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
+ },
+]
+
+
+# Internationalization
+# https://docs.djangoproject.com/en/3.2/topics/i18n/
+
+LANGUAGE_CODE = "en-us"
+
+TIME_ZONE = "UTC"
+
+USE_I18N = True
+
+USE_L10N = True
+
+USE_TZ = True
+
+
+# Static files (CSS, JavaScript, Images)
+# https://docs.djangoproject.com/en/3.2/howto/static-files/
+
+STATIC_URL = "/static/"
+
+# Default primary key field type
+# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
+
+DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
diff --git a/coldfront/plugins/qumulo/signals.py b/coldfront/plugins/qumulo/signals.py
new file mode 100644
index 0000000000..7adfc71d80
--- /dev/null
+++ b/coldfront/plugins/qumulo/signals.py
@@ -0,0 +1,94 @@
+from django.dispatch import receiver
+
+import logging
+import json
+
+from coldfront.plugins.qumulo.utils.qumulo_api import QumuloAPI
+from coldfront.plugins.qumulo.utils.acl_allocations import AclAllocations
+
+
+from coldfront.core.allocation.models import Allocation
+from coldfront.core.allocation.signals import (
+ allocation_activate,
+ allocation_disable,
+ allocation_change_approved,
+)
+
+
+from django.contrib.auth.models import User
+from django.db.models.signals import post_save
+
+from coldfront.plugins.qumulo.utils.update_user_data import (
+ update_user_with_additional_data,
+)
+
+import sys
+
+
+@receiver(post_save, sender=User)
+def on_allocation_save_retrieve_additional_user_data(
+ sender, instance, created, **kwargs
+):
+ if created and "admin" not in instance.username:
+ _ = update_user_with_additional_data(instance.username)
+
+
+@receiver(allocation_activate)
+def on_allocation_activate(sender, **kwargs):
+ logger = logging.getLogger(__name__)
+ qumulo_api = QumuloAPI()
+
+ allocation_obj = Allocation.objects.get(pk=kwargs["allocation_pk"])
+
+ fs_path = allocation_obj.get_attribute(name="storage_filesystem_path")
+ export_path = allocation_obj.get_attribute(name="storage_export_path")
+ protocols = json.loads(allocation_obj.get_attribute(name="storage_protocols"))
+ name = allocation_obj.get_attribute(name="storage_name")
+ limit_in_bytes = allocation_obj.get_attribute(name="storage_quota") * (2**40)
+
+ try:
+ # Create allocation
+ qumulo_api.create_allocation(
+ protocols=protocols,
+ export_path=export_path,
+ fs_path=fs_path,
+ name=name,
+ limit_in_bytes=limit_in_bytes,
+ )
+
+ qumulo_api.setup_allocation(fs_path)
+
+ except ValueError:
+ logger.warn("Can't create allocation: Some attributes are missing or invalid")
+
+ AclAllocations.set_allocation_acls(allocation_obj, qumulo_api)
+
+ if QumuloAPI.is_allocation_root_path(fs_path):
+ qumulo_api.create_allocation_readme(fs_path)
+
+
+@receiver(allocation_disable)
+def on_allocation_disable(sender, **kwargs):
+ allocation = Allocation.objects.get(pk=kwargs["allocation_pk"])
+
+ AclAllocations.remove_acl_access(allocation)
+
+
+@receiver(allocation_change_approved)
+def on_allocation_change_approved(sender, **kwargs):
+ qumulo_api = QumuloAPI()
+ allocation_obj = Allocation.objects.get(pk=kwargs["allocation_pk"])
+
+ fs_path = allocation_obj.get_attribute(name="storage_filesystem_path")
+ export_path = allocation_obj.get_attribute(name="storage_export_path")
+ protocols = json.loads(allocation_obj.get_attribute(name="storage_protocols"))
+ name = allocation_obj.get_attribute(name="storage_name")
+ limit_in_bytes = allocation_obj.get_attribute(name="storage_quota") * (2**40)
+
+ qumulo_api.update_allocation(
+ protocols=protocols,
+ export_path=export_path,
+ fs_path=fs_path,
+ name=name,
+ limit_in_bytes=limit_in_bytes,
+ )
diff --git a/coldfront/plugins/qumulo/static/allocation.js b/coldfront/plugins/qumulo/static/allocation.js
new file mode 100644
index 0000000000..9986730ab1
--- /dev/null
+++ b/coldfront/plugins/qumulo/static/allocation.js
@@ -0,0 +1,58 @@
+const protocols = Array.from(
+ document.querySelectorAll(
+ "#div_id_protocols div div.form-check input.form-check-input"
+ )
+);
+
+const nfsCheckBox = protocols.find((protocol) => protocol.value === "nfs");
+const allocationName = document.getElementById("id_storage_name");
+
+nfsCheckBox.addEventListener("change", handleExportPathInput);
+allocationName.addEventListener("change", (evt) => {
+ document.getElementById("id_storage_filesystem_path").value =
+ evt.target.value;
+});
+
+if (!nfsCheckBox.checked) {
+ document.getElementById("div_id_storage_export_path").style.visibility =
+ "hidden";
+}
+
+let confirmed = false;
+
+const submitButton = document.getElementById("allocation_form_submit");
+submitButton.addEventListener("click", (event) => {
+ const smb = protocols.find((protocol) => protocol.value === "smb");
+
+ if (!smb.checked && !confirmed) {
+ const modal = $("#smb_warning_modal");
+ modal.modal("show");
+
+ event.preventDefault();
+ }
+});
+
+const dialogSubmitButton = document.getElementById("smb_warning_button_submit");
+dialogSubmitButton.addEventListener("click", (event) => {
+ confirmed = true;
+
+ const modal = $("#smb_warning_modal");
+ modal.modal("hide");
+
+ submitButton.click();
+ confirmed = false;
+});
+
+function handleExportPathInput(event) {
+ const isChecked = event.target.checked;
+
+ if (isChecked) {
+ document.getElementById("id_storage_export_path").value = "";
+ document.getElementById("div_id_storage_export_path").style.visibility =
+ "visible";
+ } else {
+ document.getElementById("div_id_storage_export_path").style.visibility =
+ "hidden";
+ document.getElementById("id_storage_export_path").value = "";
+ }
+}
diff --git a/coldfront/plugins/qumulo/static/allocation_table_view.js b/coldfront/plugins/qumulo/static/allocation_table_view.js
new file mode 100644
index 0000000000..86e26b1c55
--- /dev/null
+++ b/coldfront/plugins/qumulo/static/allocation_table_view.js
@@ -0,0 +1,20 @@
+$("#navbar-main > ul > li.active").removeClass("active");
+ $("#navbar-project-menu").addClass("active");
+ $("#navbar-allocation").addClass("active");
+
+ $(document).on('click', '#form_reset_button', function () {
+ resetForm($('#filter_form'));
+ });
+
+ $(".datepicker").flatpickr();
+
+ function resetForm($form) {
+ $form.find('input:text, input:password, input:file, select, textarea').val('');
+ $form.find('input:radio, input:checkbox').removeAttr('checked').removeAttr('selected');
+ };
+
+ $("#expand_button").click(function () {
+ $('#collapseOne').collapse();
+ icon = $("#plus_minus");
+ icon.toggleClass("fa-plus fa-minus");
+ });
\ No newline at end of file
diff --git a/coldfront/plugins/qumulo/static/multi_select_lookup_input.css b/coldfront/plugins/qumulo/static/multi_select_lookup_input.css
new file mode 100644
index 0000000000..6168fa3c33
--- /dev/null
+++ b/coldfront/plugins/qumulo/static/multi_select_lookup_input.css
@@ -0,0 +1,24 @@
+ul.multi-select-lookup {
+ width: 45%;
+ height: 200px;
+ overflow-y: auto;
+}
+
+li.multi-select-lookup {
+ height: 45px;
+}
+
+li.multi-select-lookup > button.remove-list-item-button {
+ visibility: hidden;
+}
+
+li.multi-select-lookup[selected] {
+ background: blue;
+ color: white
+}
+
+li.multi-select-lookup[selected] > button.remove-list-item-button {
+ visibility: visible;
+ background-color: white;
+ color: blue;
+}
\ No newline at end of file
diff --git a/coldfront/plugins/qumulo/static/multi_select_lookup_input.js b/coldfront/plugins/qumulo/static/multi_select_lookup_input.js
new file mode 100644
index 0000000000..7023814d48
--- /dev/null
+++ b/coldfront/plugins/qumulo/static/multi_select_lookup_input.js
@@ -0,0 +1,192 @@
+class MultiSelectLookupInput {
+ constructor(widgetName, cssUri) {
+ this.widgetName = widgetName;
+ this.outputElement = document.getElementById(`${widgetName}-output`);
+ this.outputListElement = document.getElementById(
+ `${widgetName}-output-list`
+ );
+
+ if (this.outputElement.value === "None") {
+ this.outputElement.value = "";
+ }
+
+ this.injectCSS(cssUri);
+ this.addButtonListener();
+ this.addInitialValues();
+ this.handleErrors();
+ }
+
+ addButtonListener = () => {
+ const addButtonElement = document.getElementById(
+ `${this.widgetName}-add-button`
+ );
+ addButtonElement.addEventListener("click", this.handleAddButtonClick);
+
+ const removeButtonElement = document.getElementById(
+ `${this.widgetName}-remove-button`
+ );
+ removeButtonElement.addEventListener("click", this.handleRemoveButtonClick);
+ };
+
+ addErrorMessage = (invalidUsernames) => {
+ const errorMessage = `The following usernames could not be validated: ${invalidUsernames.join(
+ ", "
+ )}`;
+
+ const pElement = document.getElementById(
+ `${this.widgetName}-error-message`
+ );
+ pElement.style = "display: block";
+
+ const strongElement = document.querySelector(
+ `#${this.widgetName}-error-message > strong`
+ );
+ strongElement.innerText = errorMessage;
+ };
+
+ addInitialValues = () => {
+ const initialValues = this.outputElement.value
+ ? this.outputElement.value
+ .split(",")
+ .map((subValue) => subValue.trim())
+ .filter((subValue) => subValue.length)
+ : [];
+
+ for (const value of initialValues) {
+ this.addOption(value, false);
+ }
+ };
+
+ addOption = (value, updateValue = true) => {
+ const existingMatches = document.querySelector(
+ `#${this.widgetName}-output-list > li[value="${value}"]`
+ );
+
+ if (existingMatches === null) {
+ const liElement = this.createListItemElement(value);
+ this.outputListElement.appendChild(liElement);
+
+ if (updateValue) {
+ this.outputElement.value = this.addValueToOutputList(
+ this.outputElement.value,
+ value
+ );
+ }
+ }
+ };
+
+ addValueToOutputList = (outputListStr, value) => {
+ if (outputListStr.trim() === "") {
+ return value.trim();
+ }
+
+ const outputList = outputListStr.split(",");
+
+ return [...outputList, value.trim()].join(",");
+ };
+
+ createListItemElement = (value) => {
+ const liElement = document.createElement("li");
+ liElement.setAttribute("value", value);
+ liElement.setAttribute(
+ "class",
+ "multi-select-lookup list-group-item d-flex flex-row justify-content-between"
+ );
+
+ liElement.addEventListener("click", (event) =>
+ this.onListItemClick(event, liElement)
+ );
+
+ const pElement = document.createElement("p");
+ const content = document.createTextNode(value);
+ pElement.appendChild(content);
+
+ liElement.appendChild(pElement);
+
+ return liElement;
+ };
+
+ handleAddButtonClick = (event) => {
+ const inputElement = document.getElementById(`${this.widgetName}-textarea`);
+
+ const values = inputElement.value
+ .split(",")
+ .map((value) => value.trim())
+ .filter((value) => value.length);
+
+ for (const value of values) {
+ this.addOption(value, this.widgetName);
+ }
+ inputElement.value = "";
+ };
+
+ handleErrors = () => {
+ const errorContainers = Array.from(
+ document.querySelectorAll(`[id^=error_][id$=_id_${this.widgetName}]`)
+ );
+ const invalidUsernames = errorContainers
+ .filter((container) => container.className.includes("invalid-feedback"))
+ .map((container) => container.innerText);
+
+ if (invalidUsernames.length > 0) {
+ const queryString = invalidUsernames
+ .map(
+ (invalidUsername) =>
+ `ul#${this.widgetName}-output-list > li[value="${invalidUsername}"]`
+ )
+ .join(",");
+ const invalidListItems = document.querySelectorAll(queryString);
+
+ this.removeOptions(invalidListItems);
+
+ this.addErrorMessage(invalidUsernames);
+ }
+ };
+
+ handleRemoveButtonClick = (event) => {
+ const listItemElements = document.querySelectorAll(
+ `#${this.widgetName}-output-list > li[selected]`
+ );
+
+ this.removeOptions(listItemElements);
+ };
+
+ injectCSS = (cssUri) => {
+ const css = document.querySelector(`link[href='${cssUri}']`);
+
+ if (!css) {
+ const file = document.createElement("link");
+ file.setAttribute("rel", "stylesheet");
+ file.setAttribute("type", "text/css");
+ file.setAttribute("href", cssUri);
+ document.head.appendChild(file);
+ }
+ };
+
+ onListItemClick = (event, listItemElement) => {
+ if (listItemElement.getAttribute("selected") !== null) {
+ listItemElement.removeAttribute("selected");
+ } else {
+ listItemElement.setAttribute("selected", "");
+ }
+ };
+
+ removeOptions = (listItemElements) => {
+ for (const listItemElement of listItemElements) {
+ const value = listItemElement.getAttribute("value");
+
+ this.outputElement.value = this.removeValueFromOutputList(
+ this.outputElement.value,
+ value
+ );
+
+ listItemElement.remove();
+ }
+ };
+
+ removeValueFromOutputList = (outputListStr, value) => {
+ const outputList = outputListStr.split(",");
+
+ return outputList.filter((element) => element !== value).join(",");
+ };
+}
diff --git a/coldfront/plugins/qumulo/tasks.py b/coldfront/plugins/qumulo/tasks.py
new file mode 100644
index 0000000000..c692d2b9df
--- /dev/null
+++ b/coldfront/plugins/qumulo/tasks.py
@@ -0,0 +1,165 @@
+from django.db.models import Q
+import logging
+
+from coldfront.core.allocation.models import (
+ Allocation,
+ AllocationStatusChoice,
+ AllocationAttribute,
+ AllocationAttributeType,
+ AllocationAttributeUsage,
+)
+from coldfront.core.resource.models import Resource
+
+from coldfront.plugins.qumulo.utils.qumulo_api import QumuloAPI
+from coldfront.plugins.qumulo.utils.acl_allocations import AclAllocations
+from coldfront.plugins.qumulo.utils.active_directory_api import ActiveDirectoryAPI
+
+from qumulo.lib.request import RequestError
+
+import time
+from datetime import datetime
+
+logger = logging.getLogger(__name__)
+SECONDS_IN_AN_HOUR = 60 * 60
+SECONDS_IN_A_DAY = 24 * SECONDS_IN_AN_HOUR
+
+
+def poll_ad_group(
+ acl_allocation: Allocation,
+ expiration_seconds: int = SECONDS_IN_A_DAY,
+) -> None:
+ qumulo_api = QumuloAPI()
+
+ storage_acl_name = acl_allocation.get_attribute("storage_acl_name")
+ group_dn = ActiveDirectoryAPI.generate_group_dn(storage_acl_name)
+
+ success = False
+
+ try:
+ qumulo_api.rc.ad.distinguished_name_to_ad_account(group_dn)
+ success = True
+ except RequestError:
+ logger.warn(f'Allocation Group "{group_dn}" not found')
+ success = False
+
+ acl_group_name = acl_allocation.get_attribute("storage_acl_name")
+ time_since_creation = time.time() - acl_allocation.created.timestamp()
+
+ if success:
+ acl_allocation.status = AllocationStatusChoice.objects.get(name="Active")
+ logger.warn(f'Allocation Group "{acl_group_name}" found')
+ elif time_since_creation > expiration_seconds:
+ logger.warn(
+ f'Allocation Group "{acl_group_name}" not found after {expiration_seconds/SECONDS_IN_AN_HOUR} hours'
+ )
+ acl_allocation.status = AllocationStatusChoice.objects.get(name="Expired")
+
+ acl_allocation.save()
+
+
+def poll_ad_groups() -> None:
+ resources = Resource.objects.filter(Q(name="rw") | Q(name="ro"))
+ acl_allocations = Allocation.objects.filter(
+ status__name="Pending", resources__in=resources
+ )
+
+ logger.warn(f"Polling {len(acl_allocations)} ACL allocations")
+
+ for acl_allocation in acl_allocations:
+ poll_ad_group(acl_allocation)
+
+
+def conditionally_update_storage_allocation_status(allocation: Allocation) -> None:
+ acl_allocations = AclAllocations.get_access_allocations(allocation)
+
+ for acl_allocation in acl_allocations:
+ if acl_allocation.status.name != "Active":
+ return
+
+ allocation.status = AllocationStatusChoice.objects.get(name="New")
+ allocation.save()
+
+
+def conditionally_update_storage_allocation_statuses() -> None:
+ resource = Resource.objects.get(name="Storage2")
+ allocations = Allocation.objects.filter(status__name="Pending", resources=resource)
+ logger.warn(f"Checking {len(allocations)} qumulo allocations")
+
+ for allocation in allocations:
+ conditionally_update_storage_allocation_status(allocation)
+
+
+def ingest_quotas_with_daily_usage() -> None:
+ logger = logging.getLogger("task_qumulo_daily_quota_usages")
+
+ quota_usages = __get_quota_usages_from_qumulo(logger)
+ __set_daily_quota_usages(quota_usages, logger)
+ __validate_results(quota_usages, logger)
+
+
+def __get_quota_usages_from_qumulo(logger):
+ qumulo_api = QumuloAPI()
+ quota_usages = qumulo_api.get_all_quotas_with_usage()
+ return quota_usages
+
+
+def __set_daily_quota_usages(all_quotas, logger) -> None:
+ # Iterate and populate allocation_attribute_usage records
+ storage_filesystem_path_attribute_type = AllocationAttributeType.objects.get(
+ name="storage_filesystem_path"
+ )
+ for quota in all_quotas["quotas"]:
+ path = quota.get("path")
+
+ allocation = __get_allocation_by_attribute(
+ storage_filesystem_path_attribute_type, path
+ )
+ if allocation is None:
+ if path[-1] != "/":
+ continue
+
+ value = path[:-1]
+ logger.warn(f"Attempting to find allocation without the trailing slash...")
+ allocation = __get_allocation_by_attribute(
+ storage_filesystem_path_attribute_type, value
+ )
+ if allocation is None:
+ continue
+
+ allocation.set_usage("storage_quota", quota.get("capacity_usage"))
+
+
+def __get_allocation_by_attribute(attribute_type, value):
+ try:
+ attribute = AllocationAttribute.objects.get(
+ value=value, allocation_attribute_type=attribute_type
+ )
+ except AllocationAttribute.DoesNotExist:
+ logger.warn(f"Allocation record for {value} path was not found")
+ return None
+
+ logger.warn(f"Allocation record for {value} path was found")
+ return attribute.allocation
+
+
+def __validate_results(quota_usages, logger) -> bool:
+ today = datetime.today()
+ year = today.year
+ month = today.month
+ day = today.day
+
+ daily_usage_ingested = AllocationAttributeUsage.objects.filter(
+ modified__year=year, modified__month=month, modified__day=day
+ ).count()
+ usage_pulled_from_qumulo = len(quota_usages["quotas"])
+
+ logger.info("Usages ingested for today: ", daily_usage_ingested)
+ logger.info("Usages pulled from QUMULO: ", usage_pulled_from_qumulo)
+
+ success = usage_pulled_from_qumulo == daily_usage_ingested
+ if success:
+ logger.warn("Successful ingestion of quota daily usage.")
+ else:
+ logger.warn("Unsuccessful ingestion of quota daily usage. Check the results.")
+
+ return success
diff --git a/coldfront/plugins/qumulo/templates/allocation.html b/coldfront/plugins/qumulo/templates/allocation.html
new file mode 100644
index 0000000000..47e7607e81
--- /dev/null
+++ b/coldfront/plugins/qumulo/templates/allocation.html
@@ -0,0 +1,51 @@
+{% extends "common/base.html" %}
+{% load crispy_forms_tags %}
+{% load common_tags %}
+{% load static %}
+
+{% block title %}
+Create Allocation
+{% endblock %}
+
+{% block content %}
+
+ {% if is_pending == True %}
+
+ This allocation is marked as pending, please do not update until the status changes.
+
+ {% endif %}
+
+
+
+
+
+
+
+
Warning: Most allocations require an SMB share. Submit to confirm.
+
+
+
+
+
+
+{% endblock content %}
+
+{% block javascript %}
+{{ form.media }}
+{% endblock javascript%}
diff --git a/coldfront/plugins/qumulo/templates/allocation_table_view.html b/coldfront/plugins/qumulo/templates/allocation_table_view.html
new file mode 100644
index 0000000000..761bd39791
--- /dev/null
+++ b/coldfront/plugins/qumulo/templates/allocation_table_view.html
@@ -0,0 +1,137 @@
+{% extends "common/base.html" %}
+{% load common_tags %}
+{% load crispy_forms_tags %}
+{% load static %}
+
+
+{% block title %}
+Allocation View
+{% endblock %}
+
+
+{% block content %}
+
Allocations
+
+
+{% if expand_accordion == "show" or allocation_list %}
+
+
+{% endif %}
+
+{% if allocation_list %}
+
Allocation{{allocations_count|pluralize}}: {{allocations_count}}
+
+
+ {% if is_paginated %} Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
+
+ {% endif %}
+
+{% elif expand_accordion == "show"%}
+
+ No search results!
+
+{% else %}
+
+ No allocations to display!
+
+{% endif %}
+
+
+{% endblock %}
diff --git a/coldfront/plugins/qumulo/templates/multi_select_lookup_input.html b/coldfront/plugins/qumulo/templates/multi_select_lookup_input.html
new file mode 100644
index 0000000000..41297c1fbd
--- /dev/null
+++ b/coldfront/plugins/qumulo/templates/multi_select_lookup_input.html
@@ -0,0 +1,32 @@
+{% load static %}
+
+
+
+
+
+
+
+ >>
+
+
+ <<
+
+
+
+
+
+
diff --git a/coldfront/plugins/qumulo/tests/__init__.py b/coldfront/plugins/qumulo/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/coldfront/plugins/qumulo/tests/helper_classes/__init__.py b/coldfront/plugins/qumulo/tests/helper_classes/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/coldfront/plugins/qumulo/tests/helper_classes/allocation.py b/coldfront/plugins/qumulo/tests/helper_classes/allocation.py
new file mode 100644
index 0000000000..b5e55b7c9d
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/helper_classes/allocation.py
@@ -0,0 +1,17 @@
+from ...forms import AllocationForm
+
+
+class BaseTestableAllocationForm(AllocationForm):
+ def __init__(self, *args, **kwargs):
+ self.params_key = "taf_class_params"
+ self.form_is_valid = None
+ super().__init__(*args, **self._filter_params(**kwargs))
+
+ def _filter_params(self, **kwargs):
+ def filter_func(pair):
+ key, value = pair
+ if key == self.params_key:
+ return False
+ return True
+
+ return dict(filter(filter_func, kwargs.items()))
diff --git a/coldfront/plugins/qumulo/tests/helper_classes/filesystem_path.py b/coldfront/plugins/qumulo/tests/helper_classes/filesystem_path.py
new file mode 100644
index 0000000000..ebe170e22e
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/helper_classes/filesystem_path.py
@@ -0,0 +1,35 @@
+from qumulo.lib import request
+from unittest.mock import MagicMock
+
+
+# "Path exists" is a condition where the mocked call to Qumulo's
+# get_file_attr() function returns (None) without raising
+# request.RequestError
+class PathExistsMock(MagicMock):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.side_effect = self._pathExistsMock
+
+ def _pathExistsMock(self, *args, **kwargs):
+ return None
+
+
+# A valid form's filesystem path for a parent allocation is mocked
+# by returning None on the top 2 levels of a path (e.g. /storage2/fs1)
+# and raising request.RequestError at the 3rd level.
+class ValidFormPathMock(MagicMock):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.side_effect = self._prefixExistsSubDoesNot
+
+ def _prefixExistsSubDoesNot(self, *args, **kwargs):
+ if len(args[0].strip("/").split("/")) <= 2:
+ return None
+ raise request.RequestError(
+ 404,
+ "File not found",
+ {
+ "error_class": "fs_no_such_entry_error",
+ "description": "Path does not exist",
+ },
+ )
diff --git a/coldfront/plugins/qumulo/tests/management/__init__.py b/coldfront/plugins/qumulo/tests/management/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/coldfront/plugins/qumulo/tests/management/test_create_rw_resource.py b/coldfront/plugins/qumulo/tests/management/test_create_rw_resource.py
new file mode 100644
index 0000000000..d21a0212a9
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/management/test_create_rw_resource.py
@@ -0,0 +1,35 @@
+from django.test import TestCase
+
+# jprew - TODO - change this command name
+from coldfront.plugins.qumulo.management.commands.add_qumulo_resource import Command
+from coldfront.core.resource.models import Resource, ResourceType
+
+
+class CreateResource(TestCase):
+ def setUp(self):
+ self.storage_obj = ResourceType.objects.get_or_create(name="Storage")
+ self.acl_resource_type, self.created = ResourceType.objects.get_or_create(
+ name="ACL"
+ )
+
+ def test_creates_rw_resource(self):
+ cmd = Command()
+ cmd.handle()
+
+ # Check if the rw Resource was created with correct attributes
+ rw_resource = Resource.objects.get(name="rw")
+ self.assertEqual(rw_resource.description, "RW ACL")
+ self.assertEqual(rw_resource.resource_type.name, "ACL")
+ self.assertTrue(rw_resource.is_available)
+ self.assertFalse(rw_resource.is_public)
+ self.assertTrue(rw_resource.is_allocatable)
+ self.assertFalse(rw_resource.requires_payment)
+
+ # Check if the ro Resource was created with correct attributes
+ ro_resource = Resource.objects.get(name="rw")
+ self.assertEqual(ro_resource.description, "RW ACL")
+ self.assertEqual(ro_resource.resource_type.name, "ACL")
+ self.assertTrue(ro_resource.is_available)
+ self.assertFalse(ro_resource.is_public)
+ self.assertTrue(ro_resource.is_allocatable)
+ self.assertFalse(ro_resource.requires_payment)
diff --git a/coldfront/plugins/qumulo/tests/test_forms.py b/coldfront/plugins/qumulo/tests/test_forms.py
new file mode 100644
index 0000000000..e6cf555f28
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/test_forms.py
@@ -0,0 +1,348 @@
+import os
+
+from django.test import TestCase
+from unittest.mock import patch, MagicMock
+
+from django.contrib.auth.models import Permission
+
+from coldfront.core.project.models import Project, ProjectStatusChoice
+from coldfront.core.user.models import User
+from coldfront.core.field_of_science.models import FieldOfScience
+
+from coldfront.plugins.qumulo.forms import AllocationForm, ProjectCreateForm
+from coldfront.plugins.qumulo.tests.helper_classes.filesystem_path import (
+ PathExistsMock,
+ ValidFormPathMock,
+)
+from coldfront.plugins.qumulo.tests.utils.mock_data import (
+ build_models,
+ build_models_without_project,
+)
+
+
+@patch("coldfront.plugins.qumulo.validators.ActiveDirectoryAPI")
+class AllocationFormTests(TestCase):
+ def setUp(self):
+ build_data = build_models()
+ self.patcher = patch("coldfront.plugins.qumulo.validators.QumuloAPI")
+ self.mock_qumulo_api = self.patcher.start()
+ os.environ["STORAGE2_PATH"] = "/path/to"
+
+ self.user = build_data["user"]
+ self.project1 = build_data["project"]
+
+ self.activeStatus = self.project1.status
+ self.fieldOfScience = self.project1.field_of_science
+ self._setupValidPathQumuloAPI()
+
+ def tearDown(self):
+ self.patcher.stop()
+ return super().tearDown()
+
+ def _setupPathExistsMock(self):
+ self.mock_qumulo_api.return_value.rc.fs.get_file_attr = PathExistsMock()
+
+ def _setupValidPathQumuloAPI(self):
+ self.mock_qumulo_api.return_value.rc.fs.get_file_attr = ValidFormPathMock()
+
+ def test_clean_method_with_valid_data(self, mock_active_directory_api: MagicMock):
+ data = {
+ "project_pk": self.project1.id,
+ "storage_name": "TestAllocation",
+ "storage_quota": 1000,
+ "protocols": ["nfs"],
+ "storage_filesystem_path": "/path/to/filesystem",
+ "storage_ticket": "ITSD-98765",
+ "storage_export_path": "/path/to/export",
+ "rw_users": ["test"],
+ "ro_users": ["test"],
+ "cost_center": "Uncle Pennybags",
+ "department_number": "Time Travel Services",
+ "service_rate": "consumption",
+ }
+ form = AllocationForm(data=data, user_id=self.user.id)
+ self.assertTrue(form.is_valid())
+
+ def test_clean_method_with_invalid_data(self, mock_active_directory_api: MagicMock):
+ data = {
+ "project_pk": self.project1.id,
+ "storage_name": "Test Allocation",
+ "storage_quota": 1000,
+ "protocols": ["nfs"],
+ "storage_filesystem_path": "/path/to/filesystem",
+ "storage_ticket": "ITSD-98765",
+ "storage_export_path": "", # Missing export path for NFS
+ "rw_users": ["test"],
+ "ro_users": ["test"],
+ "cost_center": "Uncle Pennybags",
+ "department_number": "Time Travel Services",
+ "service_rate": "consumption",
+ }
+ form = AllocationForm(data=data, user_id=self.user.id)
+ self.assertFalse(form.is_valid())
+
+ def test_empty_ro_users_form_valid(self, mock_active_directory_api: MagicMock):
+ data = {
+ "project_pk": self.project1.id,
+ "storage_name": "valid-smb-allocation-name",
+ "storage_quota": 1000,
+ "protocols": ["smb"],
+ "ro_users": [],
+ "rw_users": ["test"],
+ "storage_filesystem_path": "/path/to/filesystem",
+ "storage_ticket": "ITSD-98765",
+ "storage_export_path": "",
+ "cost_center": "Uncle Pennybags",
+ "department_number": "Time Travel Services",
+ "service_rate": "consumption",
+ }
+ form = AllocationForm(data=data, user_id=self.user.id)
+ self.assertFalse(form.fields["ro_users"].required)
+ self.assertTrue(form.is_valid())
+
+ def test_storage_ticket_required(self, mock_active_directory_api: MagicMock):
+ data = {
+ "project_pk": self.project1.id,
+ "storage_name": "valid-smb-allocation-name",
+ "storage_quota": 1000,
+ "protocols": ["smb"],
+ "ro_users": [],
+ "rw_users": ["test"],
+ "storage_filesystem_path": "/path/to/filesystem",
+ # "storage_ticket": "ITSD-98765",
+ "storage_export_path": "",
+ "cost_center": "Uncle Pennybags",
+ "department_number": "Time Travel Services",
+ "service_rate": "consumption",
+ }
+ form = AllocationForm(data=data, user_id=self.user.id)
+ self.assertTrue(form.fields["storage_ticket"].required)
+ self.assertFalse(form.is_valid())
+
+ def test_service_rate_valid_options(self, mock_active_directory_api: MagicMock):
+ invalid_data = {
+ "project_pk": self.project1.id,
+ "storage_name": "valid-smb-allocation-name",
+ "storage_quota": 1000,
+ "protocols": ["smb"],
+ "ro_users": [],
+ "rw_users": ["test"],
+ "storage_filesystem_path": "/path/to/filesystem",
+ "storage_ticket": "ITSD-98765",
+ "storage_export_path": "",
+ "cost_center": "Uncle Pennybags",
+ "department_number": "Time Travel Services",
+ "service_rate": "not_a_rate",
+ }
+ invalid_form = AllocationForm(data=invalid_data, user_id=self.user.id)
+ self.assertTrue(invalid_form.fields["service_rate"].required)
+ self.assertFalse(invalid_form.is_valid())
+
+ valid_data = {
+ "project_pk": self.project1.id,
+ "storage_name": "valid-smb-allocation-name",
+ "storage_quota": 1000,
+ "protocols": ["smb"],
+ "ro_users": [],
+ "rw_users": ["test"],
+ "storage_filesystem_path": "/path/to/filesystem",
+ "storage_ticket": "ITSD-98765",
+ "storage_export_path": "",
+ "cost_center": "Uncle Pennybags",
+ "department_number": "Time Travel Services",
+ "service_rate": "consumption",
+ }
+ valid_form = AllocationForm(data=valid_data, user_id=self.user.id)
+ self.assertTrue(valid_form.fields["service_rate"].required)
+ self.assertTrue(valid_form.is_valid())
+
+ def test_empty_technical_contact(self, mock_active_directory_api: MagicMock):
+ data = {
+ "project_pk": self.project1.id,
+ "storage_name": "valid-smb-allocation-name",
+ "storage_quota": 1000,
+ "protocols": ["smb"],
+ "ro_users": [],
+ "rw_users": ["test"],
+ "storage_filesystem_path": "/path/to/filesystem",
+ "storage_ticket": "ITSD-98765",
+ "storage_export_path": "",
+ "cost_center": "Uncle Pennybags",
+ "department_number": "Time Travel Services",
+ "service_rate": "consumption",
+ }
+ form = AllocationForm(data=data, user_id=self.user.id)
+ self.assertFalse(form.fields["technical_contact"].required)
+ self.assertTrue(form.is_valid())
+
+ def test_provided_technical_contact(self, mock_active_directory_api: MagicMock):
+ data = {
+ "project_pk": self.project1.id,
+ "storage_name": "valid-smb-allocation-name",
+ "storage_quota": 1000,
+ "protocols": ["smb"],
+ "ro_users": [],
+ "rw_users": ["test"],
+ "storage_filesystem_path": "/path/to/filesystem",
+ "storage_ticket": "ITSD-98765",
+ "storage_export_path": "",
+ "cost_center": "Uncle Pennybags",
+ "department_number": "Time Travel Services",
+ "service_rate": "consumption",
+ "technical_contact": "captain.crunch",
+ }
+ form = AllocationForm(data=data, user_id=self.user.id)
+ self.assertFalse(form.fields["technical_contact"].required)
+ self.assertTrue(form.is_valid())
+
+ def test_empty_billing_contact(self, mock_active_directory_api: MagicMock):
+ data = {
+ "project_pk": self.project1.id,
+ "storage_name": "valid-smb-allocation-name",
+ "storage_quota": 1000,
+ "protocols": ["smb"],
+ "ro_users": [],
+ "rw_users": ["test"],
+ "storage_filesystem_path": "/path/to/filesystem",
+ "storage_ticket": "ITSD-98765",
+ "storage_export_path": "",
+ "cost_center": "Uncle Pennybags",
+ "department_number": "Time Travel Services",
+ "service_rate": "consumption",
+ }
+ form = AllocationForm(data=data, user_id=self.user.id)
+ self.assertFalse(form.fields["billing_contact"].required)
+ self.assertTrue(form.is_valid())
+
+ def test_provided_billing_contact(self, mock_active_directory_api: MagicMock):
+ data = {
+ "project_pk": self.project1.id,
+ "storage_name": "valid-smb-allocation-name",
+ "storage_quota": 1000,
+ "protocols": ["smb"],
+ "ro_users": [],
+ "rw_users": ["test"],
+ "storage_filesystem_path": "/path/to/filesystem",
+ "storage_ticket": "ITSD-98765",
+ "storage_export_path": "",
+ "cost_center": "Uncle Pennybags",
+ "department_number": "Time Travel Services",
+ "service_rate": "consumption",
+ "billing_contact": "captain.crunch",
+ }
+ form = AllocationForm(data=data, user_id=self.user.id)
+ self.assertFalse(form.fields["billing_contact"].required)
+ self.assertTrue(form.is_valid())
+
+
+class AllocationFormProjectChoiceTests(TestCase):
+ def setUp(self):
+ build_models_without_project()
+ self.patcher = patch("coldfront.plugins.qumulo.validators.QumuloAPI")
+ self.mock_qumulo_api = self.patcher.start()
+
+ self.activeStatus = ProjectStatusChoice.objects.get(name="Active")
+ self.fieldOfScience = FieldOfScience.objects.create(description="test")
+
+ self.user_a = User.objects.create(username="test_a", password="test_a")
+ # user_b is a superuser and should be able to see both projects
+ self.user_b = User.objects.create(
+ username="test_b", password="test_b", is_superuser=True
+ )
+
+ self.project_a = Project.objects.create(
+ title="Project A",
+ pi=self.user_a,
+ status=self.activeStatus,
+ field_of_science=self.fieldOfScience,
+ )
+
+ self.project_b = Project.objects.create(
+ title="Project B",
+ pi=self.user_b,
+ status=self.activeStatus,
+ field_of_science=self.fieldOfScience,
+ )
+
+ self.data_a = {
+ "project_pk": self.project_a.id,
+ "storage_name": "Test Allocation",
+ "storage_quota": 1000,
+ "protocols": ["nfs"],
+ "storage_filesystem_path": "/path/to/filesystem",
+ "storage_ticket": "ITSD-98765",
+ "storage_export_path": "/path/to/export",
+ "rw_users": ["test"],
+ "ro_users": ["test"],
+ "cost_center": "Uncle Pennybags",
+ "department_number": "Time Travel Services",
+ "service_rate": "consumption",
+ }
+ self.form_a = AllocationForm(data=self.data_a, user_id=self.user_a.id)
+
+ self.data_b = {
+ "project_pk": self.project_b.id,
+ "storage_name": "Test Allocation",
+ "storage_quota": 1000,
+ "protocols": ["nfs"],
+ "storage_filesystem_path": "/path/to/filesystem",
+ "storage_ticket": "ITSD-98765",
+ "storage_export_path": "/path/to/export",
+ "rw_users": ["test"],
+ "ro_users": ["test"],
+ "cost_center": "Uncle Pennybags",
+ "department_number": "Time Travel Services",
+ "service_rate": "consumption",
+ }
+ self.form_b = AllocationForm(data=self.data_b, user_id=self.user_b.id)
+
+ def tearDown(self):
+ self.patcher.stop()
+ return super().tearDown()
+
+ def test_project_visibility(self):
+
+ projects_for_a = [entry for entry in self.form_a.get_project_choices()]
+ projects_for_b = [entry for entry in self.form_b.get_project_choices()]
+
+ self.assertEqual(projects_for_a, [(self.project_a.id, self.project_a.title)])
+
+ self.assertEqual(
+ projects_for_b,
+ [
+ (self.project_a.id, self.project_a.title),
+ (self.project_b.id, self.project_b.title),
+ ],
+ )
+
+ def test_project_visibility_perm_check(self):
+
+ perm = Permission.objects.get(codename="can_view_all_projects")
+
+ self.user_a.user_permissions.add(perm)
+
+ projects_for_a = [entry for entry in self.form_a.get_project_choices()]
+
+ self.assertEqual(
+ projects_for_a,
+ [
+ (self.project_a.id, self.project_a.title),
+ (self.project_b.id, self.project_b.title),
+ ],
+ )
+
+
+@patch("coldfront.plugins.qumulo.validators.ActiveDirectoryAPI")
+class ProjectFormTests(TestCase):
+ def setUp(self):
+ self.fieldOfScience = FieldOfScience.objects.create(description="Bummerology")
+
+ def test_form_with_valid_data(self, mock_active_directory_api: MagicMock):
+ valid_data = {
+ "title": "project-sleong",
+ "pi": "sleong",
+ "description": "This is the description for the project",
+ "field_of_science": self.fieldOfScience.id,
+ }
+ form = ProjectCreateForm(data=valid_data, user_id="admin")
+ self.assertTrue(form.is_valid())
diff --git a/coldfront/plugins/qumulo/tests/test_quotas.py b/coldfront/plugins/qumulo/tests/test_quotas.py
new file mode 100644
index 0000000000..81300a417b
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/test_quotas.py
@@ -0,0 +1,336 @@
+from venv import logger
+from django.test import TestCase, Client
+
+from unittest.mock import patch, MagicMock
+
+from coldfront.core.allocation.models import (
+ AllocationAttribute,
+ AllocationAttributeType,
+)
+from coldfront.plugins.qumulo.tasks import ingest_quotas_with_daily_usage
+from coldfront.plugins.qumulo.tests.utils.mock_data import (
+ build_models,
+ create_allocation,
+)
+
+from qumulo.lib.request import RequestError
+
+
+def coldfront_allocations() -> str:
+ return {
+ "/storage2/fs1/sleong/": {"limit": "100000000000000"},
+ "/storage2/fs1/prewitt_test/": {"limit": "1099511627776"},
+ "/storage2/fs1/tychele_test": {"limit": "109951162777600"},
+ "/storage2/fs1/tychele_test/Active/tychele_suballoc_test": {
+ "limit": "109951162777600"
+ },
+ "/storage2/fs1/prewitt_test/Active/prewitt_test_2_a": {
+ "limit": "1099511627776"
+ },
+ "/storage2/fs1/prewitt_test_2": {"limit": "1099511627776"},
+ "/storage2/fs1/jian_test": {"limit": "10995116277760"},
+ "/storage2/fs1/hong.chen_test": {"limit": "5497558138880"},
+ "/storage2/fs1/i2_test": {"limit": "109951162777600"},
+ "/storage2/fs1/swamidass_test": {"limit": "24189255811072"},
+ "/storage2/fs1/prewitt_test_3": {"limit": "5497558138880"},
+ "/storage2/fs1/hong.chen_test/Active/hong.chen_suballocation": {
+ "limit": "5497558138880"
+ },
+ "/storage2/fs1/engineering_test": {"limit": "5497558138880"},
+ "/storage2/fs1/sleong_summer": {"limit": "5497558138880"},
+ "/storage2/fs1/wexler_test": {"limit": "5497558138880"},
+ "/storage2/fs1/alex.holehouse_test": {"limit": "38482906972160"},
+ "/storage2/fs1/wucci": {"limit": "5497558138880"},
+ "/storage2/fs1/amlai": {"limit": "5497558138880"},
+ "/storage2/fs1/jin810_test": {"limit": "109951162777600"},
+ "/storage2/fs1/dinglab_test": {"limit": "109951162777600"},
+ "/storage2/fs1/wucci_test": {"limit": "109951162777600"},
+ "/storage2/fs1/gtac-mgi_test2": {"limit": "5497558138880"},
+ "/storage2/fs1/mweil_test": {"limit": "5497558138880"},
+ "/storage2/fs1/amlai_test2": {"limit": "16492674416640"},
+ "/storage2/fs1/tychele_test2": {"limit": "109951162777600"},
+ }
+
+
+def mock_get_quotas() -> str:
+ return {
+ "quotas": [
+ {
+ "id": "111111111",
+ "path": "/storage2/fs1/not_found_in_coldfront/",
+ "limit": "20000000000000",
+ "capacity_usage": "1",
+ },
+ {
+ "id": "18600003",
+ "path": "/storage2/fs1/sleong/",
+ "limit": "100000000000000",
+ "capacity_usage": "37089736126464",
+ },
+ {
+ "id": "34717218",
+ "path": "/storage2/fs1/prewitt_test/",
+ "limit": "1099511627776",
+ "capacity_usage": "53248",
+ },
+ {
+ "id": "36270003",
+ "path": "/storage2/fs1/tychele_test/",
+ "limit": "109951162777600",
+ "capacity_usage": "57344",
+ },
+ {
+ "id": "36290003",
+ "path": "/storage2/fs1/tychele_test/Active/tychele_suballoc_test/",
+ "limit": "109951162777600",
+ "capacity_usage": "4096",
+ },
+ {
+ "id": "36850003",
+ "path": "/storage2/fs1/prewitt_test/Active/prewitt_test_2_a/",
+ "limit": "1099511627776",
+ "capacity_usage": "4096",
+ },
+ {
+ "id": "36860003",
+ "path": "/storage2/fs1/prewitt_test_2/",
+ "limit": "1099511627776",
+ "capacity_usage": "16384",
+ },
+ {
+ "id": "37000005",
+ "path": "/storage2/fs1/jian_test/",
+ "limit": "10995116277760",
+ "capacity_usage": "16384",
+ },
+ {
+ "id": "38760894",
+ "path": "/storage2/fs1/hong.chen_test/",
+ "limit": "5497558138880",
+ "capacity_usage": "40960",
+ },
+ {
+ "id": "38760895",
+ "path": "/storage2/fs1/i2_test/",
+ "limit": "109951162777600",
+ "capacity_usage": "20480",
+ },
+ {
+ "id": "39720243",
+ "path": "/storage2/fs1/swamidass_test/",
+ "limit": "24189255811072",
+ "capacity_usage": "16384",
+ },
+ {
+ "id": "39720382",
+ "path": "/storage2/fs1/prewitt_test_3/",
+ "limit": "5497558138880",
+ "capacity_usage": "16384",
+ },
+ {
+ "id": "42020003",
+ "path": "/storage2/fs1/hong.chen_test/Active/hong.chen_suballocation/",
+ "limit": "5497558138880",
+ "capacity_usage": "4096",
+ },
+ {
+ "id": "42030003",
+ "path": "/storage2/fs1/engineering_test/",
+ "limit": "5497558138880",
+ "capacity_usage": "307242479616",
+ },
+ {
+ "id": "42030004",
+ "path": "/storage2/fs1/sleong_summer/",
+ "limit": "5497558138880",
+ "capacity_usage": "713363001344",
+ },
+ {
+ "id": "42050003",
+ "path": "/storage2/fs1/wexler_test/",
+ "limit": "5497558138880",
+ "capacity_usage": "16384",
+ },
+ {
+ "id": "42080003",
+ "path": "/storage2/fs1/alex.holehouse_test/",
+ "limit": "38482906972160",
+ "capacity_usage": "16384",
+ },
+ {
+ "id": "42080004",
+ "path": "/storage2/fs1/wucci/",
+ "limit": "5497558138880",
+ "capacity_usage": "16384",
+ },
+ {
+ "id": "42130003",
+ "path": "/storage2/fs1/amlai/",
+ "limit": "5497558138880",
+ "capacity_usage": "4198400",
+ },
+ {
+ "id": "43010004",
+ "path": "/storage2/fs1/jin810_test/",
+ "limit": "109951162777600",
+ "capacity_usage": "16384",
+ },
+ {
+ "id": "43010005",
+ "path": "/storage2/fs1/dinglab_test/",
+ "limit": "109951162777600",
+ "capacity_usage": "16384",
+ },
+ {
+ "id": "43050003",
+ "path": "/storage2/fs1/wucci_test/",
+ "limit": "109951162777600",
+ "capacity_usage": "16384",
+ },
+ {
+ "id": "43070003",
+ "path": "/storage2/fs1/gtac-mgi_test2/",
+ "limit": "5497558138880",
+ "capacity_usage": "1477898227712",
+ },
+ {
+ "id": "52929566",
+ "path": "/storage2/fs1/mweil_test/",
+ "limit": "5497558138880",
+ "capacity_usage": "1436366471168",
+ },
+ {
+ "id": "52929567",
+ "path": "/storage2/fs1/amlai_test2/",
+ "limit": "16492674416640",
+ "capacity_usage": "997732352",
+ },
+ {
+ "id": "52929568",
+ "path": "/storage2/fs1/tychele_test2/",
+ "limit": "109951162777600",
+ "capacity_usage": "18083368955904",
+ },
+ ],
+ "paging": {"next": ""},
+ }
+
+
+class TestIngestAllocationDailyUsages(TestCase):
+ def setUp(self) -> None:
+ self.client = Client()
+ build_data = build_models()
+
+ self.project = build_data["project"]
+ self.user = build_data["user"]
+ self.quotas = mock_get_quotas()
+
+ for index, (path, details) in enumerate(coldfront_allocations().items()):
+
+ form_data = {
+ "storage_filesystem_path": path,
+ "storage_export_path": path,
+ "storage_name": f"for_tester_{index}",
+ "storage_quota": details.get("limit"),
+ "protocols": ["nfs"],
+ "rw_users": [f"user_{index}_rw"],
+ "ro_users": [f"user_{index}_ro"],
+ "storage_ticket": f"ITSD-{index}",
+ "cost_center": "Uncle Pennybags",
+ "department_number": "Time Travel Services",
+ "service_rate": "general",
+ }
+ create_allocation(project=self.project, user=self.user, form_data=form_data)
+
+ self.storage_filesystem_path_attribute_type = (
+ AllocationAttributeType.objects.get(name="storage_filesystem_path")
+ )
+ self.storage_quota_attribute_type = AllocationAttributeType.objects.get(
+ name="storage_quota"
+ )
+
+ return super().setUp()
+
+ def test_after_allocation_create_usage_is_zero(self) -> None:
+
+ # after allocations are created, expect usage to be zero
+ for path in coldfront_allocations():
+ allocation_attribute_usage = None
+ try:
+ storage_filesystem_path_attribute = AllocationAttribute.objects.get(
+ value=path,
+ allocation_attribute_type=self.storage_filesystem_path_attribute_type,
+ )
+ allocation = storage_filesystem_path_attribute.allocation
+ storage_quota_attribute_type = AllocationAttribute.objects.get(
+ allocation=allocation,
+ allocation_attribute_type=self.storage_quota_attribute_type,
+ )
+ allocation_attribute_usage = (
+ storage_quota_attribute_type.allocationattributeusage
+ )
+ except AllocationAttribute.DoesNotExist:
+ # When the storage_path_attribute for path is not found,
+ # the allocation_attribute_usage should not exist.
+ self.assertIsNone(allocation_attribute_usage)
+ continue
+
+ self.assertEqual(allocation_attribute_usage.value, 0)
+ self.assertEqual(allocation_attribute_usage.history.first().value, 0)
+
+ @patch("coldfront.plugins.qumulo.tasks.QumuloAPI")
+ def test_after_getting_daily_usages_from_qumulo_api(
+ self, qumulo_api_mock: MagicMock
+ ) -> None:
+ qumulo_api = MagicMock()
+ qumulo_api.get_all_quotas_with_usage.return_value = mock_get_quotas()
+ qumulo_api_mock.return_value = qumulo_api
+
+ exceptionRaised = False
+ try:
+ ingest_quotas_with_daily_usage()
+ except:
+ exceptionRaised = True
+
+ self.assertFalse(exceptionRaised)
+
+ for qumulo_quota in self.quotas["quotas"]:
+
+ allocation_attribute_usage = None
+ try:
+ try:
+ storage_filesystem_path_attribute = AllocationAttribute.objects.get(
+ value=qumulo_quota.get("path"),
+ allocation_attribute_type=self.storage_filesystem_path_attribute_type,
+ )
+ except AllocationAttribute.DoesNotExist:
+ path = qumulo_quota.get("path")
+ if path[-1] != "/":
+ continue
+
+ storage_filesystem_path_attribute = AllocationAttribute.objects.get(
+ value=path[:-1],
+ allocation_attribute_type=self.storage_filesystem_path_attribute_type,
+ )
+
+ allocation = storage_filesystem_path_attribute.allocation
+ storage_quota_attribute = AllocationAttribute.objects.get(
+ allocation=allocation,
+ allocation_attribute_type=self.storage_quota_attribute_type,
+ )
+
+ allocation_attribute_usage = (
+ storage_quota_attribute.allocationattributeusage
+ )
+ except AllocationAttribute.DoesNotExist:
+ # When the storage_path_attribute for path is not found,
+ # the allocation_attribute_usage should not exist.
+ self.assertIsNone(allocation_attribute_usage)
+ continue
+
+ usage = int(qumulo_quota.get("capacity_usage"))
+ self.assertEqual(allocation_attribute_usage.value, usage)
+ self.assertEqual(
+ allocation_attribute_usage.history.first().value,
+ usage,
+ )
diff --git a/coldfront/plugins/qumulo/tests/test_settings.py b/coldfront/plugins/qumulo/tests/test_settings.py
new file mode 100644
index 0000000000..88c27cff8d
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/test_settings.py
@@ -0,0 +1,4 @@
+SECRET_KEY = "fake-key"
+INSTALLED_APPS = [
+ "tests",
+]
diff --git a/coldfront/plugins/qumulo/tests/test_signals.py b/coldfront/plugins/qumulo/tests/test_signals.py
new file mode 100644
index 0000000000..d9d197afe8
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/test_signals.py
@@ -0,0 +1,133 @@
+from django.test import TestCase, Client
+from unittest.mock import patch, MagicMock
+
+from coldfront.plugins.qumulo.tests.utils.mock_data import (
+ create_allocation,
+ build_models,
+)
+
+from coldfront.core.allocation.signals import (
+ allocation_activate,
+ allocation_disable,
+ allocation_change_approved,
+)
+
+from django.core.management import call_command
+
+
+def mock_get_attribute(name):
+ attribute_dict = {
+ "storage_filesystem_path": "foo",
+ "storage_export_path": "bar",
+ "storage_name": "baz",
+ "storage_quota": 7,
+ "storage_protocols": '["nfs"]',
+ }
+ return attribute_dict[name]
+
+
+@patch("coldfront.plugins.qumulo.signals.QumuloAPI")
+@patch("coldfront.plugins.qumulo.utils.acl_allocations.ActiveDirectoryAPI")
+class TestSignals(TestCase):
+ def setUp(self) -> int:
+ self.client = Client()
+
+ build_data = build_models()
+
+ self.project = build_data["project"]
+ self.user = build_data["user"]
+
+ self.form_data = {
+ "storage_filesystem_path": "foo",
+ "storage_export_path": "bar",
+ "storage_ticket": "ITSD-54321",
+ "storage_name": "baz",
+ "storage_quota": 7,
+ "protocols": ["nfs"],
+ "rw_users": ["test"],
+ "ro_users": ["test1"],
+ "cost_center": "Uncle Pennybags",
+ "department_number": "Time Travel Services",
+ "service_rate": "general",
+ }
+
+ self.client.force_login(self.user)
+
+ self.storage_allocation = create_allocation(
+ self.project, self.user, self.form_data
+ )
+
+ def test_allocation_activate_creates_allocation(
+ self,
+ mock_ACL_ActiveDirectoryApi: MagicMock,
+ mock_QumuloAPI: MagicMock,
+ ):
+ qumulo_instance = mock_QumuloAPI.return_value
+
+ allocation_activate.send(
+ sender=self.__class__, allocation_pk=self.storage_allocation.pk
+ )
+
+ mock_QumuloAPI.assert_called_once()
+ qumulo_instance.create_allocation.assert_called_once_with(
+ protocols=["nfs"],
+ fs_path=mock_get_attribute("storage_filesystem_path"),
+ export_path=mock_get_attribute("storage_export_path"),
+ name=mock_get_attribute("storage_name"),
+ limit_in_bytes=mock_get_attribute("storage_quota") * (2**40),
+ )
+
+ @patch("coldfront.plugins.qumulo.signals.logging.getLogger")
+ def test_allocation_activate_handles_missing_attribute_error(
+ self,
+ mock_getLogger: MagicMock,
+ mock_ACL_ActiveDirectoryApi: MagicMock,
+ mock_QumuloAPI: MagicMock,
+ ):
+ qumulo_instance = mock_QumuloAPI.return_value
+ qumulo_instance.create_allocation = MagicMock(side_effect=ValueError())
+
+ allocation_activate.send(
+ sender=self.__class__, allocation_pk=self.storage_allocation.pk
+ )
+
+ mock_QumuloAPI.assert_called_once()
+ qumulo_instance.create_allocation.assert_called_once()
+
+ mock_getLogger.return_value.warn.assert_called_once_with(
+ "Can't create allocation: Some attributes are missing or invalid"
+ )
+
+ def test_allocation_change_approved_updates_allocation(
+ self,
+ mock_ACL_ActiveDirectoryApi: MagicMock,
+ mock_QumuloAPI: MagicMock,
+ ):
+ qumulo_instance = mock_QumuloAPI.return_value
+
+ allocation_change_approved.send(
+ sender=self.__class__,
+ allocation_pk=self.storage_allocation.pk,
+ allocation_change_pk=1,
+ )
+
+ qumulo_instance.update_allocation.assert_called_once_with(
+ protocols=["nfs"],
+ fs_path=mock_get_attribute("storage_filesystem_path"),
+ export_path=mock_get_attribute("storage_export_path"),
+ name=mock_get_attribute("storage_name"),
+ limit_in_bytes=mock_get_attribute("storage_quota") * (2**40),
+ )
+
+ def test_allocation_disable_removes_acls(
+ self, mock_ACL_ActiveDirectoryApi: MagicMock, mock_QumuloAPI: MagicMock
+ ):
+ qumulo_instance = mock_QumuloAPI.return_value
+
+ with patch(
+ "coldfront.plugins.qumulo.signals.AclAllocations.remove_acl_access"
+ ) as mock_remove_acl_access:
+ allocation_disable.send(
+ sender=self.__class__, allocation_pk=self.storage_allocation.pk
+ )
+ mock_remove_acl_access.assert_called_once_with(self.storage_allocation)
diff --git a/coldfront/plugins/qumulo/tests/test_tasks.py b/coldfront/plugins/qumulo/tests/test_tasks.py
new file mode 100644
index 0000000000..c47d516cad
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/test_tasks.py
@@ -0,0 +1,247 @@
+from django.test import TestCase, Client
+
+from unittest.mock import patch, MagicMock
+
+from coldfront.plugins.qumulo.tests.utils.mock_data import (
+ build_models,
+ create_allocation,
+)
+from coldfront.plugins.qumulo.tasks import (
+ poll_ad_group,
+ poll_ad_groups,
+ conditionally_update_storage_allocation_status,
+ conditionally_update_storage_allocation_statuses,
+)
+from coldfront.plugins.qumulo.utils.acl_allocations import AclAllocations
+
+from coldfront.core.allocation.models import Allocation, AllocationStatusChoice
+from coldfront.core.resource.models import Resource
+
+from qumulo.lib.request import RequestError
+
+import datetime
+from django.utils import timezone
+
+
+@patch("coldfront.plugins.qumulo.tasks.QumuloAPI")
+class TestPollAdGroup(TestCase):
+ def setUp(self) -> None:
+ self.client = Client()
+ build_data = build_models()
+
+ self.project = build_data["project"]
+ self.user = build_data["user"]
+
+ return super().setUp()
+
+ def test_poll_ad_group_set_status_to_active_on_success(
+ self, qumulo_api_mock: MagicMock
+ ) -> None:
+
+ acl_allocation: Allocation = Allocation.objects.create(
+ project=self.project,
+ justification="",
+ quantity=1,
+ status=AllocationStatusChoice.objects.get_or_create(name="Pending")[0],
+ )
+
+ poll_ad_group(acl_allocation=acl_allocation)
+
+ self.assertEqual(acl_allocation.status.name, "Active")
+
+ def test_poll_ad_group_set_status_does_nothing_on_failure(
+ self, qumulo_api_mock: MagicMock
+ ) -> None:
+ acl_allocation: Allocation = Allocation.objects.create(
+ project=self.project,
+ justification="",
+ quantity=1,
+ status=AllocationStatusChoice.objects.get_or_create(name="Pending")[0],
+ )
+
+ get_ad_object_mock: MagicMock = (
+ qumulo_api_mock.return_value.rc.ad.distinguished_name_to_ad_account
+ )
+ get_ad_object_mock.side_effect = [
+ RequestError(status_code=404, status_message="Not found"),
+ ]
+
+ poll_ad_group(acl_allocation=acl_allocation)
+
+ self.assertEqual(acl_allocation.status.name, "Pending")
+
+ def test_poll_ad_group_set_status_to_denied_on_expiration(
+ self, qumulo_api_mock: MagicMock
+ ) -> None:
+ acl_allocation: Allocation = Allocation.objects.create(
+ project=self.project,
+ justification="",
+ quantity=1,
+ status=AllocationStatusChoice.objects.get_or_create(name="Pending")[0],
+ created=timezone.now() - datetime.timedelta(hours=2),
+ )
+
+ get_ad_object_mock: MagicMock = (
+ qumulo_api_mock.return_value.rc.ad.distinguished_name_to_ad_account
+ )
+ get_ad_object_mock.side_effect = [
+ RequestError(status_code=404, status_message="Not found"),
+ ]
+
+ poll_ad_group(
+ acl_allocation=acl_allocation,
+ expiration_seconds=60 * 60,
+ )
+
+ self.assertEqual(acl_allocation.status.name, "Expired")
+
+
+@patch("coldfront.plugins.qumulo.tasks.QumuloAPI")
+class TestPollAdGroups(TestCase):
+ def setUp(self) -> None:
+ self.client = Client()
+ build_data = build_models()
+
+ self.project = build_data["project"]
+ self.user = build_data["user"]
+
+ return super().setUp()
+
+ def test_poll_ad_groups_runs_poll_ad_group_for_each_pending_allocation(
+ self, qumulo_api_mock: MagicMock
+ ) -> None:
+ acl_allocation_a: Allocation = Allocation.objects.create(
+ project=self.project,
+ justification="",
+ quantity=1,
+ status=AllocationStatusChoice.objects.get_or_create(name="Pending")[0],
+ )
+ resource_a = Resource.objects.get(name="rw")
+ acl_allocation_a.resources.add(resource_a)
+
+ acl_allocation_b: Allocation = Allocation.objects.create(
+ project=self.project,
+ justification="",
+ quantity=1,
+ status=AllocationStatusChoice.objects.get_or_create(name="Pending")[0],
+ )
+ resource_b = Resource.objects.get(name="ro")
+ acl_allocation_b.resources.add(resource_b)
+
+ acl_allocation_c: Allocation = Allocation.objects.create(
+ project=self.project,
+ justification="",
+ quantity=1,
+ status=AllocationStatusChoice.objects.get_or_create(name="New")[0],
+ )
+ acl_allocation_c.resources.add(resource_b)
+
+ with patch("coldfront.plugins.qumulo.tasks.poll_ad_group") as poll_ad_group_mock:
+ poll_ad_groups()
+
+ self.assertEqual(poll_ad_group_mock.call_count, 2)
+
+
+class TestUpdateStorageAllocationPendingStatus(TestCase):
+ def setUp(self) -> None:
+ self.client = Client()
+ build_data = build_models()
+
+ self.project = build_data["project"]
+ self.user = build_data["user"]
+ self.form_data = {
+ "storage_filesystem_path": "foo",
+ "storage_export_path": "bar",
+ "storage_ticket": "ITSD-54321",
+ "storage_name": "baz",
+ "storage_quota": 7,
+ "protocols": ["nfs"],
+ "rw_users": ["test"],
+ "ro_users": ["test1"],
+ "cost_center": "Uncle Pennybags",
+ "department_number": "Time Travel Services",
+ "service_rate": "general",
+ }
+
+ return super().setUp()
+
+ def test_conditionally_update_storage_allocation_status_sets_status_to_new_on_success(
+ self,
+ ) -> None:
+ allocation = create_allocation(
+ project=self.project, user=self.user, form_data=self.form_data
+ )
+
+ conditionally_update_storage_allocation_status(allocation)
+
+ got_allocation = Allocation.objects.get(pk=allocation.pk)
+
+ self.assertEqual(got_allocation.status.name, "New")
+
+ def test_conditionally_update_storage_allocation_status_does_nothing_when_acls_are_pending(
+ self,
+ ) -> None:
+ allocation = create_allocation(
+ project=self.project, user=self.user, form_data=self.form_data
+ )
+ acl_allocation = AclAllocations.get_access_allocation(
+ storage_allocation=allocation, resource_name="ro"
+ )
+
+ acl_allocation.status = AllocationStatusChoice.objects.get(name="Pending")
+ acl_allocation.save()
+
+ conditionally_update_storage_allocation_status(allocation)
+
+ got_allocation = Allocation.objects.get(pk=allocation.pk)
+
+ self.assertEqual(got_allocation.status.name, "Pending")
+
+
+class TestStorageAllocationStatuses(TestCase):
+ def setUp(self) -> None:
+ self.client = Client()
+ build_data = build_models()
+
+ self.project = build_data["project"]
+ self.user = build_data["user"]
+ self.form_data = {
+ "storage_filesystem_path": "foo",
+ "storage_export_path": "bar",
+ "storage_ticket": "ITSD-54321",
+ "storage_name": "baz",
+ "storage_quota": 7,
+ "protocols": ["nfs"],
+ "rw_users": ["test"],
+ "ro_users": ["test1"],
+ "cost_center": "Uncle Pennybags",
+ "department_number": "Time Travel Services",
+ "service_rate": "general",
+ }
+
+ return super().setUp()
+
+ def test_conditionally_update_storage_allocation_statuses_checks_all_pending_allocations(
+ self,
+ ):
+ create_allocation(
+ project=self.project, user=self.user, form_data=self.form_data
+ )
+ create_allocation(
+ project=self.project, user=self.user, form_data=self.form_data
+ )
+
+ non_pending_allocation = create_allocation(
+ project=self.project, user=self.user, form_data=self.form_data
+ )
+ non_pending_allocation.status = AllocationStatusChoice.objects.get(name="New")
+ non_pending_allocation.save()
+
+ with patch(
+ "coldfront.plugins.qumulo.tasks.conditionally_update_storage_allocation_status"
+ ) as conditionally_update_storage_allocation_status_mock:
+ conditionally_update_storage_allocation_statuses()
+
+ self.assertEqual(
+ conditionally_update_storage_allocation_status_mock.call_count, 2
+ )
diff --git a/coldfront/plugins/qumulo/tests/utils/__init__.py b/coldfront/plugins/qumulo/tests/utils/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/coldfront/plugins/qumulo/tests/utils/mock_coldfront_models.py b/coldfront/plugins/qumulo/tests/utils/mock_coldfront_models.py
new file mode 100644
index 0000000000..abb071f9b9
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/utils/mock_coldfront_models.py
@@ -0,0 +1,27 @@
+# This file contains a set of mock objects to reference for testing
+# When trying to import these and mock these normally, pythong throws
+# errors, so instead we conditionally load these in unit tests if
+# we want to mock them
+
+
+class Allocation:
+ class objects:
+ def get():
+ print("bar")
+
+
+class AllocationAttribute:
+ class objects:
+ def create():
+ print("bar")
+
+
+class AllocationAttributeType:
+ class objects:
+ def get():
+ print("bar")
+
+
+class Objects:
+ def get():
+ print("bar")
diff --git a/coldfront/plugins/qumulo/tests/utils/mock_data.py b/coldfront/plugins/qumulo/tests/utils/mock_data.py
new file mode 100644
index 0000000000..b2ba501f47
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/utils/mock_data.py
@@ -0,0 +1,203 @@
+from coldfront.core.user.models import User
+from coldfront.core.field_of_science.models import FieldOfScience
+from coldfront.core.project.models import Project, ProjectStatusChoice
+from coldfront.core.resource.models import Resource
+from coldfront.core.allocation.models import (
+ AllocationStatusChoice,
+ AllocationAttributeType,
+ AllocationUserStatusChoice,
+ AllocationUser,
+ Allocation,
+ AllocationAttribute,
+)
+
+from coldfront.plugins.qumulo.utils.acl_allocations import AclAllocations
+from coldfront.plugins.qumulo.management.commands.qumulo_plugin_setup import (
+ call_base_commands,
+)
+
+import json
+
+from django.core.management import call_command
+
+default_form_data = {
+ "storage_filesystem_path": "foo",
+ "storage_export_path": "bar",
+ "storage_ticket": "ITSD-54321",
+ "storage_name": "baz",
+ "storage_quota": 7,
+ "protocols": ["nfs"],
+ "rw_users": ["test"],
+ "ro_users": ["test1"],
+ "cost_center": "Uncle Pennybags",
+ "department_number": "Time Travel Services",
+ "service_rate": "general",
+}
+
+
+def build_models() -> dict["project":Project, "user":User]:
+ build_models_without_project()
+
+ return build_user_plus_project("test", "Project 1")
+
+
+def build_models_without_project() -> None:
+ call_command("import_field_of_science_data")
+ call_command("add_default_project_choices")
+ call_command("add_resource_defaults")
+ call_command("add_allocation_defaults")
+ call_base_commands()
+
+
+def build_user_plus_project(
+ username: str, project_name: str
+) -> dict["project":Project, "user":User]:
+ prev_users = list(User.objects.all())
+ user_id = prev_users[-1].id + 1 if prev_users else 1
+
+ user = User.objects.create(
+ id=user_id, username=username, password="test", email=f"{username}@wustl.edu"
+ )
+
+ activeStatus = ProjectStatusChoice.objects.get(name="Active")
+ fieldOfScience = FieldOfScience.objects.get(description="Other")
+
+ prev_projects = list(Project.objects.all())
+ project_id = prev_projects[-1].id + 1 if prev_projects else 1
+ project = Project.objects.create(
+ id=project_id,
+ title=project_name,
+ pi=user,
+ status=activeStatus,
+ field_of_science=fieldOfScience,
+ )
+
+ return {"project": project, "user": user}
+
+
+def create_allocation(project: Project, user: User, form_data: dict):
+ allocation = Allocation.objects.create(
+ project=project,
+ justification="",
+ quantity=1,
+ status=AllocationStatusChoice.objects.get(name="Pending"),
+ )
+
+ active_status = AllocationUserStatusChoice.objects.get(name="Active")
+ AllocationUser.objects.create(
+ allocation=allocation, user=user, status=active_status
+ )
+
+ resource = Resource.objects.get(name="Storage2")
+ allocation.resources.add(resource)
+
+ set_allocation_attributes(form_data, allocation)
+
+ create_access_privileges(form_data, project, allocation)
+
+ return allocation
+
+
+def create_access_privileges(
+ form_data: dict, project: Project, storage_allocation: Allocation
+):
+ rw_users = {
+ "name": "RW Users",
+ "resource": "rw",
+ "users": form_data["rw_users"],
+ }
+ ro_users = {
+ "name": "RO Users",
+ "resource": "ro",
+ "users": form_data["ro_users"],
+ }
+
+ for value in [rw_users, ro_users]:
+ access_allocation = create_access_allocation(
+ value, project, form_data["storage_name"], storage_allocation
+ )
+
+ for username in value["users"]:
+ AclAllocations.add_user_to_access_allocation(username, access_allocation)
+
+
+def create_access_allocation(
+ access_data: dict,
+ project: Project,
+ storage_name: str,
+ storage_allocation: Allocation,
+):
+ access_allocation = Allocation.objects.create(
+ project=project,
+ justification=access_data["name"],
+ quantity=1,
+ status=AllocationStatusChoice.objects.get(name="Active"),
+ )
+
+ storage_acl_name_attribute = AllocationAttributeType.objects.get(
+ name="storage_acl_name"
+ )
+ AllocationAttribute.objects.create(
+ allocation_attribute_type=storage_acl_name_attribute,
+ allocation=access_allocation,
+ value="storage-{0}-{1}".format(storage_name, access_data["resource"]),
+ )
+
+ storage_allocation_pk_attribute = AllocationAttributeType.objects.get(
+ name="storage_allocation_pk"
+ )
+ AllocationAttribute.objects.create(
+ allocation_attribute_type=storage_allocation_pk_attribute,
+ allocation=access_allocation,
+ value=storage_allocation.pk,
+ )
+
+ resource = Resource.objects.get(name=access_data["resource"])
+ access_allocation.resources.add(resource)
+
+ return access_allocation
+
+
+def set_allocation_attributes(form_data: dict, allocation):
+ allocation_attribute_names = [
+ "storage_name",
+ "storage_quota",
+ "storage_protocols",
+ "storage_filesystem_path",
+ "storage_export_path",
+ "department_number",
+ "cost_center",
+ "service_rate",
+ "storage_ticket",
+ "technical_contact",
+ "billing_contact",
+ ]
+
+ for allocation_attribute_name in allocation_attribute_names:
+
+ key = (
+ allocation_attribute_name
+ if allocation_attribute_name != "storage_protocols"
+ else "protocols"
+ )
+ if key not in form_data.keys():
+ continue
+
+ allocation_attribute_type = AllocationAttributeType.objects.get(
+ name=allocation_attribute_name
+ )
+
+ if allocation_attribute_name == "storage_protocols":
+ protocols = form_data.get("protocols")
+
+ AllocationAttribute.objects.create(
+ allocation_attribute_type=allocation_attribute_type,
+ allocation=allocation,
+ value=json.dumps(protocols),
+ )
+ else:
+ AllocationAttribute.objects.create(
+ allocation_attribute_type=allocation_attribute_type,
+ allocation=allocation,
+ value=form_data.get(allocation_attribute_name),
+ )
diff --git a/coldfront/plugins/qumulo/tests/utils/qumulo_api/__init__.py b/coldfront/plugins/qumulo/tests/utils/qumulo_api/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/coldfront/plugins/qumulo/tests/utils/qumulo_api/test_create_allocation.py b/coldfront/plugins/qumulo/tests/utils/qumulo_api/test_create_allocation.py
new file mode 100644
index 0000000000..2fdc8b9ff8
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/utils/qumulo_api/test_create_allocation.py
@@ -0,0 +1,239 @@
+from django.test import TestCase
+from unittest.mock import call, patch, MagicMock, PropertyMock
+from coldfront.plugins.qumulo.utils.qumulo_api import QumuloAPI
+
+
+@patch("coldfront.plugins.qumulo.utils.qumulo_api.RestClient")
+class CreateAllocation(TestCase):
+ def test_rejects_when_incorrect_protocol(self, mock_RestClient: MagicMock):
+ qumulo_instance = QumuloAPI()
+
+ with self.assertRaises(ValueError):
+ qumulo_instance.create_allocation(
+ protocols=["bad_protocol"],
+ export_path="/foo",
+ fs_path="/bar",
+ name="baz",
+ limit_in_bytes=10**9,
+ )
+
+ def test_rejects_if_some_protocols_are_bad(self, mock_RestClient: MagicMock):
+ qumulo_instance = QumuloAPI()
+
+ with self.assertRaises(ValueError):
+ with patch.object(
+ QumuloAPI, "create_protocol", MagicMock()
+ ) as mock_create_protocol:
+ qumulo_instance.create_allocation(
+ protocols=["nfs", "bad_protocol"],
+ export_path="/foo",
+ fs_path="/bar",
+ name="baz",
+ limit_in_bytes=10**9,
+ )
+ mock_create_protocol.assert_not_called()
+
+ def test_rejects_when_missing_name(self, mock_RestClient: MagicMock):
+ qumulo_instance = QumuloAPI()
+
+ with self.assertRaises(ValueError):
+ qumulo_instance.create_allocation(
+ protocols=["nfs"],
+ export_path="/foo",
+ fs_path="/bar",
+ name=None,
+ limit_in_bytes=10**9,
+ )
+
+ def test_rejects_when_fs_path_not_absolute(self, mock_RestClient: MagicMock):
+ qumulo_instance = QumuloAPI()
+
+ with self.assertRaises(ValueError):
+ qumulo_instance.create_allocation(
+ protocols=["nfs"],
+ export_path="/foo",
+ fs_path="bar",
+ name="bar",
+ limit_in_bytes=10**9,
+ )
+
+ def test_rejects_when_fs_path_none(self, mock_RestClient: MagicMock):
+ qumulo_instance = QumuloAPI()
+
+ with self.assertRaises(ValueError):
+ qumulo_instance.create_allocation(
+ protocols=["nfs"],
+ export_path="/foo",
+ fs_path=None,
+ name="bar",
+ limit_in_bytes=10**9,
+ )
+
+ def test_rejects_nfs_when_export_path_not_abosolute(
+ self, mock_RestClient: MagicMock
+ ):
+ qumulo_instance = QumuloAPI()
+
+ with self.assertRaises(ValueError):
+ qumulo_instance.create_allocation(
+ protocols=["nfs"],
+ export_path="foo",
+ fs_path="/bar",
+ name="bar",
+ limit_in_bytes=10**9,
+ )
+
+ def test_rejects_nfs_when_export_path_none(self, mock_RestClient: MagicMock):
+ qumulo_instance = QumuloAPI()
+
+ with self.assertRaises(ValueError):
+ qumulo_instance.create_allocation(
+ protocols=["nfs"],
+ export_path=None,
+ fs_path="/bar",
+ name="bar",
+ limit_in_bytes=10**9,
+ )
+
+ def test_accepts_when_protocols_is_None(self, mock_RestClient: MagicMock):
+ qumulo_instance = QumuloAPI()
+
+ qumulo_instance = QumuloAPI()
+ qumulo_instance.create_allocation(
+ protocols=None,
+ export_path="/foo",
+ fs_path="/bar",
+ name="bar",
+ limit_in_bytes=10**9,
+ )
+
+ mock_RestClient.assert_called()
+
+ def test_accepts_when_protocols_is_empty(self, mock_RestClient: MagicMock):
+ qumulo_instance = QumuloAPI()
+
+ qumulo_instance = QumuloAPI()
+ qumulo_instance.create_allocation(
+ protocols=[],
+ export_path="/foo",
+ fs_path="/bar",
+ name="bar",
+ limit_in_bytes=10**9,
+ )
+
+ mock_RestClient.assert_called()
+
+ def test_creates_nfs_export(
+ self,
+ mock_RestClient: MagicMock,
+ ):
+ with patch.object(
+ QumuloAPI, "create_protocol", MagicMock()
+ ) as mock_create_protocol:
+ qumulo_instance = QumuloAPI()
+ qumulo_instance.create_allocation(
+ protocols=["nfs"],
+ export_path="/foo",
+ fs_path="/bar",
+ name="bar",
+ limit_in_bytes=10**9,
+ )
+
+ mock_RestClient.assert_called()
+ mock_create_protocol.assert_called_once_with(
+ protocol="nfs", export_path="/foo", fs_path="/bar", name="bar"
+ )
+
+ def test_creates_smb_share(
+ self,
+ mock_RestClient: MagicMock,
+ ):
+ with patch.object(
+ QumuloAPI, "create_protocol", MagicMock()
+ ) as mock_create_protocol:
+ qumulo_instance = QumuloAPI()
+ qumulo_instance.create_allocation(
+ protocols=["smb"],
+ export_path="/foo",
+ fs_path="/bar",
+ name="bar",
+ limit_in_bytes=10**9,
+ )
+
+ mock_RestClient.assert_called()
+ mock_create_protocol.assert_called_once_with(
+ protocol="smb", fs_path="/bar", name="bar", export_path="/foo"
+ )
+
+ def test_creates_multiple_protocols(
+ self,
+ mock_RestClient: MagicMock,
+ ):
+ with patch.object(
+ QumuloAPI, "create_protocol", MagicMock()
+ ) as mock_create_protocol:
+ qumulo_instance = QumuloAPI()
+ qumulo_instance.create_allocation(
+ protocols=["smb", "nfs"],
+ export_path="/foo",
+ fs_path="/bar",
+ name="bar",
+ limit_in_bytes=10**9,
+ )
+
+ mock_RestClient.assert_called()
+ calls = [
+ call(protocol="nfs", fs_path="/bar", name="bar", export_path="/foo"),
+ call(protocol="smb", fs_path="/bar", name="bar", export_path="/foo"),
+ ]
+ mock_create_protocol.assert_has_calls(calls, any_order=True)
+
+ def test_creates_nfs_smb_protocols(
+ self,
+ mock_RestClient: MagicMock,
+ ):
+ with patch.object(
+ QumuloAPI, "create_protocol", MagicMock()
+ ) as mock_create_protocol:
+ qumulo_instance = QumuloAPI()
+ qumulo_instance.create_allocation(
+ protocols=["smb", "nfs"],
+ export_path="/foo",
+ fs_path="/bar",
+ name="bar",
+ limit_in_bytes=10**9,
+ )
+
+ mock_RestClient.assert_called()
+ calls = [
+ call(protocol="nfs", fs_path="/bar", name="bar", export_path="/foo"),
+ call(protocol="smb", fs_path="/bar", name="bar", export_path="/foo"),
+ ]
+ mock_create_protocol.assert_has_calls(calls, any_order=True)
+
+ assert mock_create_protocol.call_count == 2
+
+ def test_calls_create_quota(
+ self,
+ mock_RestClient: MagicMock,
+ ):
+ mock_nfs = PropertyMock(return_value=MagicMock())
+ type(mock_RestClient.return_value).nfs = mock_nfs
+
+ qumulo_instance = QumuloAPI()
+
+ fs_path = "/bar"
+ limit_in_bytes = 10**9
+
+ with patch.object(QumuloAPI, "create_quota", MagicMock()) as mock_create_quota:
+ qumulo_instance.create_allocation(
+ protocols=["nfs"],
+ export_path="/foo",
+ fs_path=fs_path,
+ name="bar",
+ limit_in_bytes=limit_in_bytes,
+ )
+
+ mock_create_quota.assert_called_once_with(
+ fs_path="/bar", limit_in_bytes=limit_in_bytes
+ )
diff --git a/coldfront/plugins/qumulo/tests/utils/qumulo_api/test_create_protocol.py b/coldfront/plugins/qumulo/tests/utils/qumulo_api/test_create_protocol.py
new file mode 100644
index 0000000000..8447f985e4
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/utils/qumulo_api/test_create_protocol.py
@@ -0,0 +1,121 @@
+from django.test import TestCase
+from unittest.mock import patch, MagicMock, PropertyMock
+from coldfront.plugins.qumulo.utils.qumulo_api import QumuloAPI
+
+
+@patch("coldfront.plugins.qumulo.utils.qumulo_api.RestClient")
+class CreateProtocol(TestCase):
+ def test_rejects_when_missing_protocol(self, mock_RestClient: MagicMock):
+ qumulo_instance = QumuloAPI()
+
+ with self.assertRaises(ValueError):
+ qumulo_instance.create_protocol(
+ protocol=None,
+ export_path="/foo",
+ fs_path="/bar",
+ name="baz",
+ )
+
+ def test_rejects_when_incorrect_protocol(self, mock_RestClient: MagicMock):
+ qumulo_instance = QumuloAPI()
+
+ with self.assertRaises(ValueError):
+ qumulo_instance.create_protocol(
+ protocol="bad_protocol",
+ export_path="/foo",
+ fs_path="/bar",
+ name="baz",
+ )
+
+ def test_rejects_when_missing_name(self, mock_RestClient: MagicMock):
+ qumulo_instance = QumuloAPI()
+
+ with self.assertRaises(ValueError):
+ qumulo_instance.create_protocol(
+ protocol="nfs",
+ export_path="/foo",
+ fs_path="/bar",
+ name=None,
+ )
+
+ def test_rejects_when_fs_path_not_absolute(self, mock_RestClient: MagicMock):
+ qumulo_instance = QumuloAPI()
+
+ with self.assertRaises(ValueError):
+ qumulo_instance.create_protocol(
+ protocol="nfs",
+ export_path="/foo",
+ fs_path="bar",
+ name="bar",
+ )
+
+ def test_rejects_when_fs_path_none(self, mock_RestClient: MagicMock):
+ qumulo_instance = QumuloAPI()
+
+ with self.assertRaises(ValueError):
+ qumulo_instance.create_protocol(
+ protocol="nfs",
+ export_path="/foo",
+ fs_path=None,
+ name="bar",
+ )
+
+ def test_rejects_when_nfs_export_path_not_abosolute(
+ self, mock_RestClient: MagicMock
+ ):
+ qumulo_instance = QumuloAPI()
+
+ with self.assertRaises(ValueError):
+ qumulo_instance.create_protocol(
+ protocol="nfs",
+ export_path="foo",
+ fs_path="/bar",
+ name="bar",
+ )
+
+ def test_rejects_when_nfs_export_path_none(self, mock_RestClient: MagicMock):
+ qumulo_instance = QumuloAPI()
+
+ with self.assertRaises(ValueError):
+ qumulo_instance.create_protocol(
+ protocol="nfs",
+ export_path=None,
+ fs_path="/bar",
+ name="bar",
+ )
+
+ def test_creates_nfs_export(
+ self,
+ mock_RestClient: MagicMock,
+ ):
+ mock_nfs = PropertyMock(return_value=MagicMock())
+ type(mock_RestClient.return_value).nfs = mock_nfs
+
+ qumulo_instance = QumuloAPI()
+ qumulo_instance.create_protocol(
+ protocol="nfs",
+ export_path="/foo",
+ fs_path="/bar",
+ name="bar",
+ )
+
+ mock_RestClient.assert_called()
+ mock_nfs.return_value.nfs_add_export.assert_called()
+
+ def test_creates_smb_share(
+ self,
+ mock_RestClient: MagicMock,
+ ):
+ mock_smb = PropertyMock(return_value=MagicMock())
+ type(mock_RestClient.return_value).smb = mock_smb
+
+ qumulo_instance = QumuloAPI()
+ qumulo_instance.create_protocol(
+ protocol="smb",
+ export_path="/foo",
+ fs_path="/bar",
+ name="bar",
+ )
+
+ mock_RestClient.assert_called()
+ mock_smb.return_value.smb_add_share.assert_called()
diff --git a/coldfront/plugins/qumulo/tests/utils/qumulo_api/test_create_quota.py b/coldfront/plugins/qumulo/tests/utils/qumulo_api/test_create_quota.py
new file mode 100644
index 0000000000..e209dfe0a0
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/utils/qumulo_api/test_create_quota.py
@@ -0,0 +1,37 @@
+from django.test import TestCase
+from unittest.mock import patch, MagicMock, PropertyMock
+from coldfront.plugins.qumulo.utils.qumulo_api import QumuloAPI
+
+
+@patch("coldfront.plugins.qumulo.utils.qumulo_api.RestClient")
+class CreateQuota(TestCase):
+ def test_calls_get_file_attr(self, mock_RestClient: MagicMock):
+ mock_quota = PropertyMock(return_value=MagicMock())
+ type(mock_RestClient.return_value).quota = mock_quota
+
+ mock_fs = PropertyMock(return_value=MagicMock())
+ type(mock_RestClient.return_value).fs = mock_fs
+
+ fs_path = "foo"
+
+ qumulo_instance = QumuloAPI()
+ qumulo_instance.create_quota(fs_path=fs_path, limit_in_bytes=10**9)
+
+ mock_fs.return_value.get_file_attr.assert_called_once_with(path=fs_path)
+
+ def test_calls_create_quota(self, mock_RestClient: MagicMock):
+ mock_quota = PropertyMock(return_value=MagicMock())
+ type(mock_RestClient.return_value).quota = mock_quota
+
+ mock_fs = PropertyMock(return_value=MagicMock())
+ type(mock_RestClient.return_value).fs = mock_fs
+
+ fs_path = "foo"
+ id = 1
+ limit_in_bytes = 10**9
+ mock_fs.return_value.get_file_attr.return_value = {"id": id}
+
+ qumulo_instance = QumuloAPI()
+ qumulo_instance.create_quota(fs_path=fs_path, limit_in_bytes=limit_in_bytes)
+
+ mock_quota.return_value.create_quota.assert_called_once_with(id, limit_in_bytes)
diff --git a/coldfront/plugins/qumulo/tests/utils/qumulo_api/test_delete_allocation.py b/coldfront/plugins/qumulo/tests/utils/qumulo_api/test_delete_allocation.py
new file mode 100644
index 0000000000..7f3a38567c
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/utils/qumulo_api/test_delete_allocation.py
@@ -0,0 +1,129 @@
+from django.test import TestCase
+from unittest.mock import call, patch, MagicMock
+from coldfront.plugins.qumulo.utils.qumulo_api import QumuloAPI
+
+
+@patch.object(QumuloAPI, "get_id", MagicMock())
+@patch("coldfront.plugins.qumulo.utils.qumulo_api.RestClient")
+class DeleteAllocation(TestCase):
+ def test_gets_nfs_export_id(self, mock_RestClient: MagicMock):
+ with patch.object(QumuloAPI, "get_id", MagicMock()) as mock_get_id:
+ qumulo_instance = QumuloAPI()
+ qumulo_instance._delete_allocation(
+ fs_path="/foo", name="foo", protocols=["nfs"], export_path="bar"
+ )
+
+ mock_get_id.assert_called_once_with(protocol="nfs", export_path="bar")
+
+ def test_gets_smb_export_id(self, mock_RestClient: MagicMock):
+ with patch.object(QumuloAPI, "get_id", MagicMock()) as mock_get_id:
+ qumulo_instance = QumuloAPI()
+ qumulo_instance._delete_allocation(
+ fs_path="/foo", name="foo", protocols=["smb"], export_path="bar"
+ )
+
+ mock_get_id.assert_called_once_with(protocol="smb", name="foo")
+
+ def test_deletes_nfs_export(self, mock_RestClient: MagicMock):
+ with patch.object(
+ QumuloAPI, "delete_protocol", MagicMock()
+ ) as mock_delete_protocol:
+ qumulo_instance = QumuloAPI()
+ qumulo_instance._delete_allocation(
+ fs_path="/foo", export_path="bar", name="foo", protocols=["nfs"]
+ )
+
+ mock_RestClient.assert_called_once()
+ mock_delete_protocol.assert_called_once_with(
+ export_path="bar", name="foo", protocol="nfs"
+ )
+
+ def test_deletes_smb_share(self, mock_RestClient: MagicMock):
+ with patch.object(
+ QumuloAPI, "delete_protocol", MagicMock()
+ ) as mock_delete_protocol:
+ qumulo_instance = QumuloAPI()
+ qumulo_instance._delete_allocation(
+ fs_path="/foo", export_path="bar", name="foo", protocols=["smb"]
+ )
+
+ mock_RestClient.assert_called_once()
+ mock_delete_protocol.assert_called_once_with(
+ export_path="bar", name="foo", protocol="smb"
+ )
+
+ def test_deletes_multiple_allocations(self, mock_RestClient: MagicMock):
+ with patch.object(
+ QumuloAPI, "delete_protocol", MagicMock()
+ ) as mock_delete_protocol:
+ qumulo_instance = QumuloAPI()
+ qumulo_instance._delete_allocation(
+ fs_path="/foo",
+ export_path="bar",
+ name="foo",
+ protocols=["smb", "nfs"],
+ )
+
+ mock_RestClient.assert_called_once()
+ calls = [
+ call(export_path="bar", name="foo", protocol="nfs"),
+ call(export_path="bar", name="foo", protocol="smb"),
+ ]
+ mock_delete_protocol.assert_has_calls(calls, any_order=True)
+
+ def test_deletes_nfs_smb_allocations(self, mock_RestClient: MagicMock):
+ with patch.object(
+ QumuloAPI, "delete_protocol", MagicMock()
+ ) as mock_delete_protocol:
+ qumulo_instance = QumuloAPI()
+ qumulo_instance._delete_allocation(
+ fs_path="/foo",
+ export_path="bar",
+ name="foo",
+ protocols=["smb", "nfs"],
+ )
+
+ mock_RestClient.assert_called_once()
+ calls = [
+ call(export_path="bar", name="foo", protocol="nfs"),
+ call(export_path="bar", name="foo", protocol="smb"),
+ ]
+ mock_delete_protocol.assert_has_calls(calls, any_order=True)
+ assert mock_delete_protocol.call_count == 2
+
+ def test_rejects_when_incorrect_protocol(self, mock_RestClient: MagicMock):
+ qumulo_instance = QumuloAPI()
+
+ with self.assertRaises(ValueError):
+ qumulo_instance._delete_allocation(
+ fs_path="/foo", protocols=["bad_protocol"]
+ )
+
+ def test_deletes_nfs_smb_with_only_required_params(
+ self, mock_RestClient: MagicMock
+ ):
+ with patch.object(
+ QumuloAPI, "delete_protocol", MagicMock()
+ ) as mock_delete_protocol:
+ qumulo_instance = QumuloAPI()
+ qumulo_instance._delete_allocation(
+ fs_path="/foo", export_path="bar", protocols=["nfs", "smb"]
+ )
+
+ mock_RestClient.assert_called_once()
+ calls = [
+ call(export_path="bar", name=None, protocol="nfs"),
+ call(export_path="bar", name=None, protocol="smb"),
+ ]
+ mock_delete_protocol.assert_has_calls(calls, any_order=True)
+ assert mock_delete_protocol.call_count == 2
+
+ def test_calls_delete_quota(self, mock_RestClient: MagicMock):
+ qumulo_instance = QumuloAPI()
+
+ with patch.object(QumuloAPI, "delete_quota", MagicMock()) as mock_delete_quota:
+ qumulo_instance._delete_allocation(
+ fs_path="/foo", name="foo", protocols=["smb"]
+ )
+
+ mock_delete_quota.assert_called_once_with("/foo")
diff --git a/coldfront/plugins/qumulo/tests/utils/qumulo_api/test_delete_protocol.py b/coldfront/plugins/qumulo/tests/utils/qumulo_api/test_delete_protocol.py
new file mode 100644
index 0000000000..69306e472a
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/utils/qumulo_api/test_delete_protocol.py
@@ -0,0 +1,73 @@
+from django.test import TestCase
+from unittest.mock import patch, MagicMock, PropertyMock
+from coldfront.plugins.qumulo.utils.qumulo_api import QumuloAPI
+
+
+@patch.object(QumuloAPI, "get_id", MagicMock())
+@patch("coldfront.plugins.qumulo.utils.qumulo_api.RestClient")
+class DeleteProtocol(TestCase):
+ def test_gets_nfs_export_id(self, mock_RestClient: MagicMock):
+ with patch.object(QumuloAPI, "get_id", MagicMock()) as mock_get_id:
+ qumulo_instance = QumuloAPI()
+ qumulo_instance.delete_protocol(
+ name="foo", protocol="nfs", export_path="bar"
+ )
+
+ mock_get_id.assert_called_once_with(protocol="nfs", export_path="bar")
+
+ def test_gets_smb_export_id(self, mock_RestClient: MagicMock):
+ with patch.object(QumuloAPI, "get_id", MagicMock()) as mock_get_id:
+ qumulo_instance = QumuloAPI()
+ qumulo_instance.delete_protocol(
+ name="foo", protocol="smb", export_path="bar"
+ )
+
+ mock_get_id.assert_called_once_with(protocol="smb", name="foo")
+
+ def test_deletes_nfs_export(self, mock_RestClient: MagicMock):
+ mock_nfs = PropertyMock(return_value=MagicMock())
+ type(mock_RestClient.return_value).nfs = mock_nfs
+
+ qumulo_instance = QumuloAPI()
+ qumulo_instance.delete_protocol(export_path="bar", name="foo", protocol="nfs")
+
+ mock_RestClient.assert_called_once()
+ mock_nfs.return_value.nfs_delete_export.assert_called_once()
+
+ def test_deletes_smb_share(self, mock_RestClient: MagicMock):
+ mock_smb = PropertyMock(return_value=MagicMock())
+ type(mock_RestClient.return_value).smb = mock_smb
+
+ qumulo_instance = QumuloAPI()
+ qumulo_instance.delete_protocol(export_path="bar", name="foo", protocol="smb")
+
+ mock_RestClient.assert_called_once()
+ mock_smb.return_value.smb_delete_share.assert_called_once()
+
+ def test_rejects_when_incorrect_protocol(self, mock_RestClient: MagicMock):
+ qumulo_instance = QumuloAPI()
+
+ with self.assertRaises(ValueError):
+ qumulo_instance.delete_protocol(protocol="bad_protocol")
+
+ def test_rejects_when_nfs_args_not_defined(self, mock_RestClient: MagicMock):
+ qumulo_instance = QumuloAPI()
+
+ with self.assertRaises(TypeError):
+ qumulo_instance.delete_protocol(protocol="nfs")
+
+ def test_rejects_when_smb_args_not_defined(self, mock_RestClient: MagicMock):
+ qumulo_instance = QumuloAPI()
+
+ with self.assertRaises(TypeError):
+ qumulo_instance.delete_protocol(protocol="smb")
+
+ def test_deletes_nfs_with_only_required_params(self, mock_RestClient: MagicMock):
+ mock_nfs = PropertyMock(return_value=MagicMock())
+ type(mock_RestClient.return_value).nfs = mock_nfs
+
+ qumulo_instance = QumuloAPI()
+ qumulo_instance.delete_protocol(export_path="bar", protocol="nfs")
+
+ mock_RestClient.assert_called_once()
+ mock_nfs.return_value.nfs_delete_export.assert_called_once()
diff --git a/coldfront/plugins/qumulo/tests/utils/qumulo_api/test_delete_quote.py b/coldfront/plugins/qumulo/tests/utils/qumulo_api/test_delete_quote.py
new file mode 100644
index 0000000000..31fbfa114d
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/utils/qumulo_api/test_delete_quote.py
@@ -0,0 +1,36 @@
+from django.test import TestCase
+from unittest.mock import patch, MagicMock, PropertyMock
+from coldfront.plugins.qumulo.utils.qumulo_api import QumuloAPI
+
+
+@patch("coldfront.plugins.qumulo.utils.qumulo_api.RestClient")
+class DeleteQuota(TestCase):
+ def test_calls_get_file_attr(self, mock_RestClient: MagicMock):
+ mock_quota = PropertyMock(return_value=MagicMock())
+ type(mock_RestClient.return_value).quota = mock_quota
+
+ mock_fs = PropertyMock(return_value=MagicMock())
+ type(mock_RestClient.return_value).fs = mock_fs
+
+ fs_path = "/foo"
+
+ qumulo_instance = QumuloAPI()
+ qumulo_instance.delete_quota(fs_path)
+
+ mock_fs.return_value.get_file_attr.assert_called_once_with(path=fs_path)
+
+ def test_calls_delete_quota(self, mock_RestClient: MagicMock):
+ mock_quota = PropertyMock(return_value=MagicMock())
+ type(mock_RestClient.return_value).quota = mock_quota
+
+ mock_fs = PropertyMock(return_value=MagicMock())
+ type(mock_RestClient.return_value).fs = mock_fs
+
+ fs_path = "foo"
+ id = 1
+ mock_fs.return_value.get_file_attr.return_value = {"id": id}
+
+ qumulo_instance = QumuloAPI()
+ qumulo_instance.delete_quota(fs_path=fs_path)
+
+ mock_quota.return_value.delete_quota.assert_called_once_with(id)
diff --git a/coldfront/plugins/qumulo/tests/utils/qumulo_api/test_get_id.py b/coldfront/plugins/qumulo/tests/utils/qumulo_api/test_get_id.py
new file mode 100644
index 0000000000..3d943c5559
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/utils/qumulo_api/test_get_id.py
@@ -0,0 +1,47 @@
+from django.test import TestCase
+from unittest.mock import patch, MagicMock
+from coldfront.plugins.qumulo.utils.qumulo_api import QumuloAPI
+
+
+@patch("coldfront.plugins.qumulo.utils.qumulo_api.RestClient")
+class GetId(TestCase):
+ def test_calls_nfs_endpoint_with_url_encode(self, mock_RestClient: MagicMock):
+ mock_request: MagicMock = mock_RestClient.return_value.request
+ qumulo_instance = QumuloAPI()
+
+ qumulo_instance.get_id(protocol="nfs", export_path="/test")
+
+ mock_request.assert_called_once_with(
+ method="GET", uri="/v2/nfs/exports/%2Ftest"
+ )
+
+ def test_nfs_returns_id(self, mock_RestClient: MagicMock):
+ mock_request: MagicMock = mock_RestClient.return_value.request
+ qumulo_instance = QumuloAPI()
+ mock_request.return_value = {"id": "856", "export_path": "/test-project"}
+
+ id = qumulo_instance.get_id(protocol="nfs", export_path="/test")
+ self.assertEqual(id, "856")
+
+ def test_calls_smb_endpoint_with_url_encode(self, mock_RestClient: MagicMock):
+ mock_request: MagicMock = mock_RestClient.return_value.request
+ qumulo_instance = QumuloAPI()
+ qumulo_instance.get_id(protocol="smb", name="/test")
+
+ mock_request.assert_called_once_with(method="GET", uri="/v2/smb/shares/%2Ftest")
+
+ def test_smb_returns_id(self, mock_RestClient: MagicMock):
+ mock_request: MagicMock = mock_RestClient.return_value.request
+ qumulo_instance = QumuloAPI()
+ mock_request.return_value = {"id": "856"}
+
+ id = qumulo_instance.get_id(protocol="smb", name="test")
+ self.assertEqual(id, "856")
+
+ def test_rejects_bad_protocol(self, mock_RestClient: MagicMock):
+ mock_request: MagicMock = mock_RestClient.return_value.request
+ qumulo_instance = QumuloAPI()
+ mock_request.return_value = {"id": "856"}
+
+ with self.assertRaises(ValueError):
+ qumulo_instance.get_id(protocol="foo", name="test")
diff --git a/coldfront/plugins/qumulo/tests/utils/qumulo_api/test_setup_allocation.py b/coldfront/plugins/qumulo/tests/utils/qumulo_api/test_setup_allocation.py
new file mode 100644
index 0000000000..6cfda2a3f6
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/utils/qumulo_api/test_setup_allocation.py
@@ -0,0 +1,58 @@
+from django.test import TestCase
+from unittest.mock import patch, MagicMock
+
+from coldfront.plugins.qumulo.utils.qumulo_api import QumuloAPI
+from coldfront.plugins.qumulo.utils.aces_manager import AcesManager
+
+from deepdiff import DeepDiff
+
+
+@patch("coldfront.plugins.qumulo.utils.qumulo_api.RestClient")
+class SetupAllocation(TestCase):
+ def test_calls_create_directory_if_root_path(self, mock_RestClient: MagicMock):
+ mock_create_directory = MagicMock()
+ mock_RestClient.return_value.fs.create_directory = mock_create_directory
+
+ fs_path = "/storage2/fs1/foo"
+
+ qumulo_instance = QumuloAPI()
+
+ with patch("coldfront.plugins.qumulo.utils.qumulo_api.open") as mock_open:
+ qumulo_instance.setup_allocation(fs_path)
+
+ mock_create_directory.assert_called_once_with(
+ dir_path=fs_path, name="Active"
+ )
+
+ def test_does_nothing_if_not_root_path(self, mock_RestClient: MagicMock):
+ mock_create_directory = MagicMock()
+ mock_RestClient.return_value.fs.create_directory = mock_create_directory
+
+ fs_path = "/storage2/fs1/foo/bar"
+
+ qumulo_instance = QumuloAPI()
+
+ qumulo_instance.setup_allocation(fs_path)
+
+ mock_create_directory.assert_not_called()
+
+ def test_creates_default_acls_if_root_path(self, mock_RestClient: MagicMock):
+ mock_set_acl_v2 = MagicMock()
+ mock_RestClient.return_value.fs.set_acl_v2 = mock_set_acl_v2
+
+ fs_path = "/storage2/fs1/foo"
+ expected_acl = AcesManager().get_base_acl()
+ expected_acl["aces"] = AcesManager.default_aces
+
+ qumulo_instance = QumuloAPI()
+
+ with patch("coldfront.plugins.qumulo.utils.qumulo_api.open") as mock_open:
+ qumulo_instance.setup_allocation(fs_path)
+
+ mock_set_acl_v2.assert_called_once()
+
+ call_args = mock_set_acl_v2.call_args
+ self.assertEqual(call_args.kwargs["path"], fs_path)
+
+ diff = DeepDiff(call_args.kwargs["acl"], expected_acl, ignore_order=True)
+ self.assertFalse(diff)
diff --git a/coldfront/plugins/qumulo/tests/utils/qumulo_api/test_update_allocation.py b/coldfront/plugins/qumulo/tests/utils/qumulo_api/test_update_allocation.py
new file mode 100644
index 0000000000..2c3c065a95
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/utils/qumulo_api/test_update_allocation.py
@@ -0,0 +1,244 @@
+from django.test import TestCase
+from unittest.mock import call, patch, MagicMock
+from coldfront.plugins.qumulo.utils.qumulo_api import QumuloAPI
+from qumulo.lib.request import RequestError
+
+
+@patch("coldfront.plugins.qumulo.utils.qumulo_api.RestClient")
+class UpdateAllocation(TestCase):
+ def test_rejects_when_missing_protocol(self, mock_RestClient: MagicMock):
+ qumulo_instance = QumuloAPI()
+
+ with self.assertRaises(ValueError):
+ qumulo_instance.update_allocation(
+ protocols=None,
+ export_path="/foo",
+ fs_path="/bar",
+ name="bar",
+ limit_in_bytes=10**9,
+ )
+
+ def test_rejects_when_protocols_is_empty(self, mock_RestClient: MagicMock):
+ qumulo_instance = QumuloAPI()
+
+ with self.assertRaises(ValueError):
+ qumulo_instance.update_allocation(
+ protocols=[],
+ export_path="/foo",
+ fs_path="/bar",
+ name="bar",
+ limit_in_bytes=10**9,
+ )
+
+ def test_rejects_when_incorrect_protocol(self, mock_RestClient: MagicMock):
+ qumulo_instance = QumuloAPI()
+
+ with self.assertRaises(ValueError):
+ qumulo_instance.update_allocation(
+ protocols=["bad_protocol"],
+ export_path="/foo",
+ fs_path="/bar",
+ name="bar",
+ limit_in_bytes=10**9,
+ )
+
+ def test_rejects_if_some_protocols_are_bad(self, mock_RestClient: MagicMock):
+ qumulo_instance = QumuloAPI()
+
+ with self.assertRaises(ValueError):
+ qumulo_instance.update_allocation(
+ protocols=["nfs", "bad_protocol"],
+ export_path="/foo",
+ fs_path="/bar",
+ name="bar",
+ limit_in_bytes=10**9,
+ )
+
+ def test_adds_nfs_export(self, mock_RestClient: MagicMock):
+ qumulo_instance = QumuloAPI()
+
+ with patch.object(
+ QumuloAPI, "create_protocol", MagicMock()
+ ) as mock_create_protocol:
+ qumulo_instance.update_allocation(
+ protocols=["nfs"],
+ export_path="/foo",
+ fs_path="/bar",
+ name="bar",
+ limit_in_bytes=10**9,
+ )
+
+ mock_create_protocol.assert_called_once_with(
+ protocol="nfs", export_path="/foo", fs_path="/bar", name="bar"
+ )
+
+ def test_adds_smb_share(self, mock_RestClient: MagicMock):
+ qumulo_instance = QumuloAPI()
+ with patch.object(
+ QumuloAPI, "create_protocol", MagicMock()
+ ) as mock_create_protocol:
+ qumulo_instance.update_allocation(
+ protocols=["smb"],
+ export_path="/foo",
+ fs_path="/bar",
+ name="bar",
+ limit_in_bytes=10**9,
+ )
+
+ mock_create_protocol.assert_called_once_with(
+ protocol="smb", export_path="/foo", fs_path="/bar", name="bar"
+ )
+
+ def test_adds_nfs_smb(self, mock_RestClient: MagicMock):
+ qumulo_instance = QumuloAPI()
+
+ with patch.object(
+ QumuloAPI, "create_protocol", MagicMock()
+ ) as mock_create_protocol:
+ with patch.object(
+ QumuloAPI, "delete_protocol", MagicMock()
+ ) as mock_delete_protocol:
+ qumulo_instance.update_allocation(
+ protocols=["nfs", "smb"],
+ export_path="/foo",
+ fs_path="/bar",
+ name="bar",
+ limit_in_bytes=10**9,
+ )
+
+ create_calls = [
+ call(
+ protocol="nfs", fs_path="/bar", name="bar", export_path="/foo"
+ ),
+ call(
+ protocol="smb", fs_path="/bar", name="bar", export_path="/foo"
+ ),
+ ]
+ mock_create_protocol.assert_has_calls(create_calls, any_order=True)
+ assert mock_create_protocol.call_count == 2
+
+ def test_catches_request_error_on_create(self, mock_RestClient: MagicMock):
+ def create_side_effect(protocol, *args, **kwargs):
+ if protocol in ["nfs", "smb"]:
+ raise RequestError(409, "Already exists")
+
+ qumulo_instance = QumuloAPI()
+
+ with patch.object(
+ QumuloAPI, "create_protocol", MagicMock()
+ ) as mock_create_protocol:
+ with patch.object(
+ QumuloAPI, "delete_protocol", MagicMock()
+ ) as mock_delete_protocol:
+ mock_create_protocol.side_effect = create_side_effect
+ try:
+ qumulo_instance.update_allocation(
+ protocols=["nfs", "smb"],
+ export_path="/foo",
+ fs_path="/bar",
+ name="bar",
+ limit_in_bytes=10**9,
+ )
+ except:
+ self.fail("Did not catch errors!")
+
+ def test_does_not_catch_create_unknown_error(self, mock_RestClient: MagicMock):
+ def create_side_effect(protocol, **kwargs):
+ if protocol in ["nfs", "smb"]:
+ raise Exception()
+
+ qumulo_instance = QumuloAPI()
+
+ with patch.object(
+ QumuloAPI, "create_protocol", MagicMock()
+ ) as mock_create_protocol:
+ with patch.object(
+ QumuloAPI, "delete_protocol", MagicMock()
+ ) as mock_delete_protocol:
+ mock_create_protocol.side_effect = create_side_effect
+
+ with self.assertRaises(Exception):
+ qumulo_instance.update_allocation(
+ protocols=["nfs", "smb"],
+ export_path="/foo",
+ fs_path="/bar",
+ name="bar",
+ limit_in_bytes=10**9,
+ )
+
+ def test_does_not_catch_delete_unknown_error(self, mock_RestClient: MagicMock):
+ def create_side_effect(protocol, **kwargs):
+ if protocol in ["smb"]:
+ raise RequestError("pool", "url", "msg")
+
+ def delete_side_effect(protocol, *args, **kwargs):
+ if protocol == "nfs":
+ raise Exception()
+
+ qumulo_instance = QumuloAPI()
+
+ with patch.object(
+ QumuloAPI, "create_protocol", MagicMock()
+ ) as mock_create_protocol:
+ with patch.object(
+ QumuloAPI, "delete_protocol", MagicMock()
+ ) as mock_delete_protocol:
+ mock_create_protocol.side_effect = create_side_effect
+ mock_delete_protocol.side_effect = delete_side_effect
+
+ with self.assertRaises(Exception):
+ qumulo_instance.update_allocation(
+ protocols=["smb"],
+ export_path="/foo",
+ fs_path="/bar",
+ name="bar",
+ limit_in_bytes=10**9,
+ )
+
+ def test_catches_defined_nfs_type_errors(self, mock_RestClient: MagicMock):
+ def delete_side_effect(protocol, *args, **kwargs):
+ if protocol == "nfs":
+ raise TypeError("Export path is not defined.")
+
+ qumulo_instance = QumuloAPI()
+
+ with patch.object(
+ QumuloAPI, "create_protocol", MagicMock()
+ ) as mock_create_protocol:
+ with patch.object(
+ QumuloAPI, "delete_protocol", MagicMock()
+ ) as mock_delete_protocol:
+ mock_delete_protocol.side_effect = delete_side_effect
+ try:
+ qumulo_instance.update_allocation(
+ protocols=["smb"],
+ export_path="/foo",
+ fs_path="/bar",
+ name="bar",
+ limit_in_bytes=10**9,
+ )
+ except TypeError as e:
+ self.fail("Type Error not catched.")
+
+ def test_does_not_catch_not_defined_type_errors(self, mock_RestClient: MagicMock):
+ def delete_side_effect(protocol, *args, **kwargs):
+ if protocol == "nfs":
+ raise TypeError()
+
+ qumulo_instance = QumuloAPI()
+
+ with patch.object(
+ QumuloAPI, "create_protocol", MagicMock()
+ ) as mock_create_protocol:
+ with patch.object(
+ QumuloAPI, "delete_protocol", MagicMock()
+ ) as mock_delete_protocol:
+ mock_delete_protocol.side_effect = delete_side_effect
+ with self.assertRaises(TypeError):
+ qumulo_instance.update_allocation(
+ protocols=["smb"],
+ export_path="/foo",
+ fs_path="/bar",
+ name="bar",
+ limit_in_bytes=10**9,
+ )
diff --git a/coldfront/plugins/qumulo/tests/utils/qumulo_api/test_update_quota.py b/coldfront/plugins/qumulo/tests/utils/qumulo_api/test_update_quota.py
new file mode 100644
index 0000000000..4a5682c031
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/utils/qumulo_api/test_update_quota.py
@@ -0,0 +1,37 @@
+from django.test import TestCase
+from unittest.mock import patch, MagicMock, PropertyMock
+from coldfront.plugins.qumulo.utils.qumulo_api import QumuloAPI
+
+
+@patch("coldfront.plugins.qumulo.utils.qumulo_api.RestClient")
+class CreateQuota(TestCase):
+ def test_calls_get_file_attr(self, mock_RestClient: MagicMock):
+ mock_quota = PropertyMock(return_value=MagicMock())
+ type(mock_RestClient.return_value).quota = mock_quota
+
+ mock_fs = PropertyMock(return_value=MagicMock())
+ type(mock_RestClient.return_value).fs = mock_fs
+
+ fs_path = "foo"
+
+ qumulo_instance = QumuloAPI()
+ qumulo_instance.update_quota(fs_path=fs_path, limit_in_bytes=10**5)
+
+ mock_fs.return_value.get_file_attr.assert_called_once_with(path=fs_path)
+
+ def test_calls_update_quota(self, mock_RestClient: MagicMock):
+ mock_quota = PropertyMock(return_value=MagicMock())
+ type(mock_RestClient.return_value).quota = mock_quota
+
+ mock_fs = PropertyMock(return_value=MagicMock())
+ type(mock_RestClient.return_value).fs = mock_fs
+
+ fs_path = "foo"
+ id = 1
+ limit_in_bytes = 10**5
+ mock_fs.return_value.get_file_attr.return_value = {"id": id}
+
+ qumulo_instance = QumuloAPI()
+ qumulo_instance.update_quota(fs_path=fs_path, limit_in_bytes=limit_in_bytes)
+
+ mock_quota.return_value.update_quota.assert_called_once_with(id, limit_in_bytes)
diff --git a/coldfront/plugins/qumulo/tests/utils/test_acl_allocations.py b/coldfront/plugins/qumulo/tests/utils/test_acl_allocations.py
new file mode 100644
index 0000000000..207bed3366
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/utils/test_acl_allocations.py
@@ -0,0 +1,608 @@
+from django.test import TestCase, Client
+from unittest.mock import MagicMock, patch, call
+
+from coldfront.plugins.qumulo.tests.utils.mock_data import (
+ build_models,
+ create_allocation,
+)
+from coldfront.plugins.qumulo.utils.acl_allocations import AclAllocations
+from coldfront.plugins.qumulo.utils.aces_manager import AcesManager
+
+from coldfront.core.allocation.models import (
+ Allocation,
+ AllocationAttribute,
+ AllocationStatusChoice,
+)
+from ldap3.core.exceptions import LDAPException
+
+from deepdiff import DeepDiff
+
+import os
+from dotenv import load_dotenv
+
+load_dotenv(override=True)
+
+
+class TestAclAllocations(TestCase):
+ def setUp(self):
+ self.client = Client()
+
+ build_data = build_models()
+
+ self.project = build_data["project"]
+ self.user = build_data["user"]
+
+ self.form_data = {
+ "storage_filesystem_path": "foo",
+ "storage_export_path": "bar",
+ "storage_ticket": "ITSD-54321",
+ "storage_name": "baz",
+ "storage_quota": 7,
+ "protocols": ["nfs"],
+ "rw_users": ["test"],
+ "ro_users": ["test1"],
+ "cost_center": "Uncle Pennybags",
+ "department_number": "Time Travel Services",
+ "service_rate": "general",
+ }
+
+ self.client.force_login(self.user)
+
+ @patch("coldfront.plugins.qumulo.utils.acl_allocations.ActiveDirectoryAPI")
+ @patch(
+ "coldfront.plugins.qumulo.utils.acl_allocations.AclAllocations.create_acl_allocation"
+ )
+ def test_create_acl_allocations_calls_create_acl_allocation(
+ self,
+ mock_create_acl_allocation: MagicMock,
+ mock_active_directory_api: MagicMock,
+ ):
+ mock_active_directory_instance = MagicMock()
+ mock_active_directory_api.return_value = mock_active_directory_instance
+
+ acl_allocations = AclAllocations(project_pk=self.project)
+ ro_users, rw_users = ["ro_test"], ["rw_test"]
+ acl_allocations.create_acl_allocations(ro_users=ro_users, rw_users=rw_users)
+
+ self.assertEqual(mock_create_acl_allocation.call_count, 2)
+ mock_create_acl_allocation.assert_any_call(
+ acl_type="ro",
+ users=ro_users,
+ active_directory_api=mock_active_directory_instance,
+ )
+ mock_create_acl_allocation.assert_any_call(
+ acl_type="rw",
+ users=rw_users,
+ active_directory_api=mock_active_directory_instance,
+ )
+ mock_active_directory_api.assert_called_once()
+
+ @patch("coldfront.plugins.qumulo.utils.acl_allocations.ActiveDirectoryAPI")
+ @patch(
+ "coldfront.plugins.qumulo.utils.acl_allocations.AclAllocations.create_ad_group_and_add_users"
+ )
+ @patch(
+ "coldfront.plugins.qumulo.utils.acl_allocations.AclAllocations.set_allocation_attributes"
+ )
+ @patch(
+ "coldfront.plugins.qumulo.utils.acl_allocations.AclAllocations.add_allocation_users"
+ )
+ def test_create_acl_allocation_creates_acl_allocation(
+ self,
+ mock_add_allocation_users: MagicMock,
+ mock_set_allocation_attributes: MagicMock,
+ mock_create_ad_group_and_add_users: MagicMock,
+ mock_active_directory_api: MagicMock,
+ ):
+ mock_add_allocation_users.return_value = MagicMock()
+ mock_set_allocation_attributes.return_value = MagicMock()
+ mock_create_ad_group_and_add_users.return_value = MagicMock()
+ mock_active_directory_instance = MagicMock()
+ mock_active_directory_api.return_value = mock_active_directory_instance
+
+ acl_allocations = AclAllocations(project_pk=self.project)
+ ro_users = ["ro_test"]
+ acl_allocations.create_acl_allocation(
+ acl_type="ro",
+ users=ro_users,
+ active_directory_api=mock_active_directory_instance,
+ )
+
+ all_allocation_objects = Allocation.objects.all()
+
+ self.assertEqual(len(all_allocation_objects), 1)
+
+ first_allocation = all_allocation_objects[0]
+ self.assertEqual(first_allocation.project.id, 1)
+ self.assertEqual(first_allocation.status.name, "Active")
+ self.assertIsNotNone(first_allocation.resources.get(name="ro"))
+
+ mock_add_allocation_users.assert_called_once_with(
+ allocation=first_allocation, wustlkeys=ro_users
+ )
+
+ mock_set_allocation_attributes.assert_called_once_with(
+ allocation=first_allocation, acl_type="ro", wustlkey="ro_test"
+ )
+
+ mock_create_ad_group_and_add_users.assert_called_once_with(
+ wustlkeys=ro_users,
+ allocation=first_allocation,
+ active_directory_api=mock_active_directory_instance,
+ )
+
+ @patch("coldfront.plugins.qumulo.utils.acl_allocations.ActiveDirectoryAPI")
+ @patch(
+ "coldfront.plugins.qumulo.utils.acl_allocations.AclAllocations.create_ad_group_and_add_users",
+ side_effect=LDAPException,
+ )
+ @patch(
+ "coldfront.plugins.qumulo.utils.acl_allocations.AclAllocations.set_allocation_attributes"
+ )
+ @patch(
+ "coldfront.plugins.qumulo.utils.acl_allocations.AclAllocations.add_allocation_users"
+ )
+ def test_create_acl_allocation_catches_LDAPexception_and_deletes_allocation(
+ self,
+ mock_add_allocation_users: MagicMock,
+ mock_set_allocation_attributes: MagicMock,
+ mock_create_ad_group_and_add_users: MagicMock,
+ mock_active_directory_api: MagicMock,
+ ):
+ mock_add_allocation_users.return_value = MagicMock()
+ mock_set_allocation_attributes.return_value = MagicMock()
+ mock_active_directory_instance = MagicMock()
+ mock_active_directory_api.return_value = mock_active_directory_instance
+
+ acl_allocations = AclAllocations(project_pk=self.project)
+ ro_users = ["ro_test"]
+
+ acl_allocations.create_acl_allocation(
+ acl_type="ro",
+ users=ro_users,
+ active_directory_api=mock_active_directory_instance,
+ )
+
+ self.assertEqual(len(Allocation.objects.all()), 0)
+
+ def test_set_allocation_attributes_sets_allocation_attributes(self):
+ test_allocation = Allocation.objects.create(
+ project=self.project,
+ justification="",
+ quantity=1,
+ status=AllocationStatusChoice.objects.get(name="Active"),
+ )
+
+ acl_allocations = AclAllocations(project_pk=self.project)
+ acl_allocations.set_allocation_attributes(
+ allocation=test_allocation, acl_type="ro", wustlkey="test"
+ )
+
+ all_allocation_attributes = AllocationAttribute.objects.all()
+
+ self.assertEqual(len(all_allocation_attributes), 1)
+
+ @patch("coldfront.plugins.qumulo.utils.acl_allocations.ActiveDirectoryAPI")
+ def test_create_ad_group_and_add_users_creates_ad_group_and_adds_users(
+ self, mock_active_directory_api
+ ):
+ mock_active_directory_instance = MagicMock()
+ mock_active_directory_api.return_value = mock_active_directory_instance
+
+ test_allocation = Allocation.objects.create(
+ project=self.project,
+ justification="",
+ quantity=1,
+ status=AllocationStatusChoice.objects.get(name="Active"),
+ )
+
+ AclAllocations.create_ad_group_and_add_users(
+ wustlkeys=["ro_user"],
+ allocation=test_allocation,
+ active_directory_api=mock_active_directory_instance,
+ )
+
+ mock_active_directory_instance.add_user_to_ad_group.assert_called()
+
+ @patch("coldfront.plugins.qumulo.utils.acl_allocations.ActiveDirectoryAPI")
+ def test_create_ad_group_and_add_users_creates_ad_group_and_adds_users_without_ad_argument(
+ self,
+ mock_active_directory_api: MagicMock,
+ ):
+ mock_active_directory_instance = MagicMock()
+ mock_active_directory_api.return_value = mock_active_directory_instance
+
+ test_allocation = Allocation.objects.create(
+ project=self.project,
+ justification="",
+ quantity=1,
+ status=AllocationStatusChoice.objects.get(name="Active"),
+ )
+
+ AclAllocations.create_ad_group_and_add_users(
+ wustlkeys=["ro_user"],
+ allocation=test_allocation,
+ )
+
+ mock_active_directory_instance.add_user_to_ad_group.assert_called()
+
+ def test_remove_access_sets_only_base_acls(self):
+ test_allocation = create_allocation(self.project, self.user, self.form_data)
+ acl_allocations = AclAllocations.get_access_allocations(test_allocation)
+
+ calls = []
+
+ mock_acl_data = {
+ "control": ["PRESENT"],
+ "posix_special_permissions": [],
+ "aces": AcesManager.default_aces.copy(),
+ }
+
+ for acl_allocation in acl_allocations:
+ calls.append(
+ call(
+ acl=mock_acl_data,
+ path=test_allocation.get_attribute("storage_filesystem_path"),
+ )
+ )
+
+ with patch(
+ "coldfront.plugins.qumulo.utils.acl_allocations.QumuloAPI"
+ ) as mock_qumulo_api:
+ mock_return_data = {
+ "control": ["PRESENT"],
+ "posix_special_permissions": [],
+ "aces": AcesManager.default_aces.copy(),
+ }
+
+ group_name_base = f"storage-{self.form_data['storage_name']}"
+ extra_aces = AcesManager.get_allocation_aces(
+ f"{group_name_base}-rw", f"{group_name_base}-ro"
+ )
+ mock_return_data["aces"].extend(extra_aces)
+
+ mock_qumulo_api.return_value.rc.fs.get_acl_v2.return_value = (
+ mock_return_data
+ )
+
+ AclAllocations.remove_acl_access(allocation=test_allocation)
+
+ mock_qumulo_api.return_value.rc.fs.set_acl_v2.assert_has_calls(calls)
+
+ def test_remove_access_sets_allocation_status(self):
+ test_allocation = create_allocation(self.project, self.user, self.form_data)
+ acl_allocations = AclAllocations.get_access_allocations(test_allocation)
+
+ with patch(
+ "coldfront.plugins.qumulo.utils.acl_allocations.QumuloAPI"
+ ) as mock_qumulo_api:
+ mock_return_data = {
+ "control": ["PRESENT"],
+ "posix_special_permissions": [],
+ "aces": AcesManager.default_aces.copy(),
+ }
+
+ group_name_base = f"storage-{self.form_data['storage_name']}"
+ extra_aces = AcesManager.get_allocation_aces(
+ f"{group_name_base}-rw", f"{group_name_base}-ro"
+ )
+ mock_return_data["aces"].extend(extra_aces)
+
+ mock_qumulo_api.return_value.rc.fs.get_acl_v2.return_value = (
+ mock_return_data
+ )
+
+ AclAllocations.remove_acl_access(allocation=test_allocation)
+
+ acl_allocations = AclAllocations.get_access_allocations(test_allocation)
+
+ for acl_allocation in acl_allocations:
+ self.assertEqual(acl_allocation.status.name, "Revoked")
+
+ def test_set_allocation_acls_sets_base_acl(self):
+ mock_qumulo_api = MagicMock()
+ mock_set_acl_v2 = MagicMock()
+ mock_get_acl_v2 = MagicMock()
+ mock_qumulo_api.rc.fs.set_acl_v2 = mock_set_acl_v2
+ mock_qumulo_api.rc.fs.get_acl_v2 = mock_get_acl_v2
+
+ form_data = self.form_data.copy()
+ form_data["storage_filesystem_path"] = f"{os.environ.get('STORAGE2_PATH')}/foo"
+
+ group_name_base = f"storage-{form_data['storage_name']}"
+ expected_aces = AcesManager.get_allocation_aces(
+ f"{group_name_base}-rw", f"{group_name_base}-ro"
+ )
+
+ allocation = create_allocation(self.project, self.user, form_data)
+
+ mock_get_acl_v2.return_value = AcesManager.get_base_acl()
+
+ with patch(
+ "coldfront.plugins.qumulo.utils.acl_allocations.AclAllocations.set_traverse_acl"
+ ) as mock_set_traverse_acl:
+ AclAllocations.set_allocation_acls(allocation, mock_qumulo_api)
+
+ mock_set_acl_v2.assert_called_once()
+ call_args = mock_set_acl_v2.call_args
+
+ self.assertEqual(
+ call_args.kwargs["path"],
+ f"{form_data['storage_filesystem_path']}/Active",
+ )
+
+ diff = DeepDiff(
+ call_args.kwargs["acl"]["aces"], expected_aces, ignore_order=True
+ )
+ self.assertFalse(diff)
+
+ def test_set_allocation_acls_preserves_existing_acls(self):
+ mock_qumulo_api = MagicMock()
+ mock_set_acl_v2 = MagicMock()
+ mock_get_acl_v2 = MagicMock()
+ mock_qumulo_api.rc.fs.set_acl_v2 = mock_set_acl_v2
+ mock_qumulo_api.rc.fs.get_acl_v2 = mock_get_acl_v2
+
+ form_data = self.form_data.copy()
+ form_data["storage_filesystem_path"] = f"{os.environ.get('STORAGE2_PATH')}/foo"
+
+ group_name_base = f"storage-{form_data['storage_name']}"
+
+ expected_new_aces = AcesManager.get_allocation_aces(
+ f"{group_name_base}-rw", f"{group_name_base}-ro"
+ )
+ expected_acl = AcesManager.get_base_acl()
+ expected_acl["aces"] = expected_new_aces
+ expected_acl["aces"].extend(AcesManager.default_aces.copy())
+
+ original_acl = AcesManager.get_base_acl()
+ original_acl["aces"] = AcesManager.default_aces.copy()
+
+ mock_get_acl_v2.return_value = original_acl
+ allocation = create_allocation(self.project, self.user, form_data)
+
+ with patch(
+ "coldfront.plugins.qumulo.utils.acl_allocations.AclAllocations.set_traverse_acl"
+ ) as mock_set_traverse_acl:
+ AclAllocations.set_allocation_acls(allocation, mock_qumulo_api)
+
+ mock_set_acl_v2.assert_called_once()
+ call_args = mock_set_acl_v2.call_args
+
+ self.assertEqual(
+ call_args.kwargs["path"],
+ f"{form_data['storage_filesystem_path']}/Active",
+ )
+
+ diff = DeepDiff(call_args.kwargs["acl"], expected_acl, ignore_order=True)
+ self.assertFalse(diff)
+
+ def test_set_allocation_acls_call_set_traverse_acl_on_base_path(self):
+ mock_qumulo_api = MagicMock()
+ mock_set_acl_v2 = MagicMock()
+ mock_get_acl_v2 = MagicMock()
+ mock_qumulo_api.rc.fs.set_acl_v2 = mock_set_acl_v2
+ mock_qumulo_api.rc.fs.get_acl_v2 = mock_get_acl_v2
+
+ form_data = self.form_data.copy()
+ form_data["storage_filesystem_path"] = f"{os.environ.get('STORAGE2_PATH')}/foo"
+
+ allocation = create_allocation(self.project, self.user, form_data)
+
+ mock_get_acl_v2.return_value = AcesManager.get_base_acl()
+
+ with patch(
+ "coldfront.plugins.qumulo.utils.acl_allocations.AclAllocations.set_traverse_acl"
+ ) as mock_set_traverse_acl:
+ AclAllocations.set_allocation_acls(allocation, mock_qumulo_api)
+
+ mock_set_acl_v2.assert_called_once()
+
+ group_name_base = f"storage-{form_data['storage_name']}"
+ mock_set_traverse_acl.assert_called_once_with(
+ fs_path=form_data["storage_filesystem_path"],
+ rw_groupname=f"{group_name_base}-rw",
+ ro_groupname=f"{group_name_base}-ro",
+ qumulo_api=mock_qumulo_api,
+ is_base_allocation=True,
+ )
+
+ def test_set_allocation_acls_sets_sub_acl(self):
+ mock_qumulo_api = MagicMock()
+ mock_set_acl_v2 = MagicMock()
+ mock_get_acl_v2 = MagicMock()
+ mock_qumulo_api.rc.fs.set_acl_v2 = mock_set_acl_v2
+ mock_qumulo_api.rc.fs.get_acl_v2 = mock_get_acl_v2
+
+ form_data = self.form_data.copy()
+ form_data["storage_filesystem_path"] = (
+ f"{os.environ.get('STORAGE2_PATH')}/bar/Active/foo"
+ )
+
+ group_name_base = f"storage-{form_data['storage_name']}"
+
+ expected_aces = AcesManager.get_allocation_aces(
+ f"{group_name_base}-rw", f"{group_name_base}-ro"
+ )
+
+ allocation = create_allocation(self.project, self.user, form_data)
+
+ mock_get_acl_v2.return_value = AcesManager.get_base_acl()
+
+ with patch(
+ "coldfront.plugins.qumulo.utils.acl_allocations.AclAllocations.set_traverse_acl"
+ ) as mock_set_traverse_acl:
+ AclAllocations.set_allocation_acls(allocation, mock_qumulo_api)
+
+ mock_set_acl_v2.assert_called_once()
+ call_args = mock_set_acl_v2.call_args
+
+ self.assertEqual(
+ call_args.kwargs["path"],
+ f"{form_data['storage_filesystem_path']}",
+ )
+
+ diff = DeepDiff(
+ call_args.kwargs["acl"]["aces"], expected_aces, ignore_order=True
+ )
+ self.assertFalse(diff)
+
+ def test_set_allocation_acls_preserves_existing_sub_acls(self):
+ mock_qumulo_api = MagicMock()
+ mock_set_acl_v2 = MagicMock()
+ mock_get_acl_v2 = MagicMock()
+ mock_qumulo_api.rc.fs.set_acl_v2 = mock_set_acl_v2
+ mock_qumulo_api.rc.fs.get_acl_v2 = mock_get_acl_v2
+
+ form_data = self.form_data.copy()
+ form_data["storage_filesystem_path"] = (
+ f"{os.environ.get('STORAGE2_PATH')}/foo/Active/bar"
+ )
+
+ group_name_base = f"storage-{form_data['storage_name']}"
+
+ expected_new_aces = AcesManager.get_allocation_aces(
+ f"{group_name_base}-rw", f"{group_name_base}-ro"
+ )
+ expected_acl = AcesManager.get_base_acl()
+ expected_acl["aces"] = expected_new_aces
+
+ existing_aces = AcesManager.default_aces.copy()
+ existing_aces.extend(AcesManager.get_allocation_aces("test", "test1"))
+
+ expected_acl["aces"].extend(existing_aces.copy())
+
+ original_acl = AcesManager.get_base_acl()
+ original_acl["aces"] = existing_aces.copy()
+
+ mock_get_acl_v2.return_value = original_acl
+ allocation = create_allocation(self.project, self.user, form_data)
+
+ with patch(
+ "coldfront.plugins.qumulo.utils.acl_allocations.AclAllocations.set_traverse_acl"
+ ) as mock_set_traverse_acl:
+ AclAllocations.set_allocation_acls(allocation, mock_qumulo_api)
+
+ mock_set_acl_v2.assert_called_once()
+ call_args = mock_set_acl_v2.call_args
+
+ self.assertEqual(
+ call_args.kwargs["path"],
+ f"{form_data['storage_filesystem_path']}",
+ )
+
+ diff = DeepDiff(call_args.kwargs["acl"], expected_acl, ignore_order=True)
+ self.assertFalse(diff)
+
+ def test_set_traverse_acl_adds_traverse_priviledges_to_path(self):
+ mock_qumulo_api = MagicMock()
+ mock_set_acl_v2 = MagicMock()
+ mock_qumulo_api.rc.fs.set_acl_v2 = mock_set_acl_v2
+
+ fs_path = f"{os.environ.get('STORAGE2_PATH')}/foo"
+ rw_groupname = "rw_group"
+ ro_groupname = "ro_group"
+
+ expected_acl = AcesManager.get_base_acl()
+ expected_acl["aces"] = AcesManager.get_traverse_aces(
+ rw_groupname, ro_groupname, True
+ )
+
+ mock_qumulo_api.rc.fs.get_acl_v2.return_value = AcesManager.get_base_acl()
+ AclAllocations.set_traverse_acl(
+ fs_path=fs_path,
+ rw_groupname=rw_groupname,
+ ro_groupname=ro_groupname,
+ qumulo_api=mock_qumulo_api,
+ is_base_allocation=True,
+ )
+
+ mock_set_acl_v2.assert_called_once()
+
+ call_args = mock_set_acl_v2.call_args
+ self.assertEqual(call_args.kwargs["path"], fs_path)
+
+ diff = DeepDiff(call_args.kwargs["acl"], expected_acl, ignore_order=True)
+ self.assertFalse(diff)
+
+ def test_set_traverse_acl_adds_traverse_privileges_to_path_with_existing_acls(
+ self,
+ ):
+ mock_qumulo_api = MagicMock()
+ mock_set_acl_v2 = MagicMock()
+ mock_qumulo_api.rc.fs.set_acl_v2 = mock_set_acl_v2
+
+ fs_path = f"{os.environ.get('STORAGE2_PATH')}/foo"
+ rw_groupname = "rw_group"
+ ro_groupname = "ro_group"
+
+ expected_acl = AcesManager.get_base_acl()
+ expected_acl["aces"] = AcesManager.get_traverse_aces(
+ rw_groupname, ro_groupname, True
+ )
+ expected_acl["aces"].extend(AcesManager.default_aces.copy())
+
+ original_acl = AcesManager.get_base_acl()
+ original_acl["aces"] = AcesManager.default_aces.copy()
+
+ mock_qumulo_api.rc.fs.get_acl_v2.return_value = original_acl
+
+ AclAllocations.set_traverse_acl(
+ fs_path=fs_path,
+ rw_groupname=rw_groupname,
+ ro_groupname=ro_groupname,
+ qumulo_api=mock_qumulo_api,
+ is_base_allocation=True,
+ )
+
+ mock_set_acl_v2.assert_called_once()
+
+ call_args = mock_set_acl_v2.call_args
+ self.assertEqual(call_args.kwargs["path"], fs_path)
+
+ diff = DeepDiff(call_args.kwargs["acl"], expected_acl, ignore_order=True)
+ self.assertFalse(diff)
+
+ def test_set_traverse_aces_sets_parent_directories_for_sub_allocation(self):
+ mock_qumulo_api = MagicMock()
+ mock_set_acl_v2 = MagicMock()
+ mock_qumulo_api.rc.fs.set_acl_v2 = mock_set_acl_v2
+
+ fs_path = f"{os.environ.get('STORAGE2_PATH')}/foo/Active/bar"
+ rw_groupname = "rw_group"
+ ro_groupname = "ro_group"
+
+ expected_acl = AcesManager.get_base_acl()
+ expected_acl["aces"] = AcesManager.get_traverse_aces(
+ rw_groupname, ro_groupname, is_base_allocation=False
+ )
+
+ mock_qumulo_api.rc.fs.get_acl_v2.return_value = AcesManager.get_base_acl()
+ AclAllocations.set_traverse_acl(
+ fs_path=fs_path,
+ rw_groupname=rw_groupname,
+ ro_groupname=ro_groupname,
+ qumulo_api=mock_qumulo_api,
+ is_base_allocation=False,
+ )
+
+ mock_set_acl_v2.assert_called()
+
+ call_args_list = mock_set_acl_v2.call_args_list
+ self.assertEqual(len(call_args_list), 2)
+
+ self.assertEqual(
+ call_args_list[0].kwargs["path"],
+ f"{os.environ.get('STORAGE2_PATH')}/foo/Active",
+ )
+ diff = DeepDiff(
+ call_args_list[0].kwargs["acl"], expected_acl, ignore_order=True
+ )
+
+ self.assertEqual(
+ call_args_list[1].kwargs["path"], f"{os.environ.get('STORAGE2_PATH')}/foo"
+ )
+ diff = DeepDiff(
+ call_args_list[1].kwargs["acl"], expected_acl, ignore_order=True
+ )
+ self.assertFalse(diff)
diff --git a/coldfront/plugins/qumulo/tests/utils/test_active_directory_api.py b/coldfront/plugins/qumulo/tests/utils/test_active_directory_api.py
new file mode 100644
index 0000000000..d9ce92384c
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/utils/test_active_directory_api.py
@@ -0,0 +1,237 @@
+from django.test import TestCase
+from unittest.mock import patch, call, MagicMock
+from coldfront.plugins.qumulo.utils.active_directory_api import ActiveDirectoryAPI
+from ldap3 import MODIFY_DELETE
+
+import os
+from dotenv import load_dotenv
+
+load_dotenv(override=True)
+
+
+class TestActiveDirectoryAPI(TestCase):
+ @patch("coldfront.plugins.qumulo.utils.active_directory_api.Connection")
+ def setUp(self, mock_connection):
+ self.mock_connection = mock_connection.return_value
+ self.ad_api = ActiveDirectoryAPI()
+ self.mock_connection.response = []
+
+ def test_get_user_returns_user(self):
+ self.mock_connection.response = [
+ {"dn": "user_dn", "attributes": {"other_attr": "value"}}
+ ]
+ wustlkey = "test_wustlkey"
+ expected_filter = f"(&(objectclass=person)(sAMAccountName={wustlkey}))"
+
+ self.ad_api.get_user(wustlkey)
+
+ self.mock_connection.search.assert_called_once_with(
+ "dc=accounts,dc=ad,dc=wustl,dc=edu",
+ expected_filter,
+ attributes=["sAMAccountName", "mail", "givenName", "sn"],
+ )
+
+ def test_get_user_by_email_returns_user(self):
+
+ self.mock_connection.response = [
+ {"dn": "user_dn", "attributes": {"other_attr": "value"}}
+ ]
+ email = "wustlkey@wustl.edu"
+ expected_filter = f"(&(objectclass=person)(mail={email}))"
+
+ self.ad_api.get_user_by_email(email)
+
+ self.mock_connection.search.assert_called_once_with(
+ "dc=accounts,dc=ad,dc=wustl,dc=edu",
+ expected_filter,
+ attributes=["sAMAccountName", "mail", "givenName", "sn"],
+ )
+
+ def test_get_user_returns_value_error_on_empty_result(self):
+ with self.assertRaises(ValueError) as context:
+ self.ad_api.get_user(wustlkey=None)
+
+ exception = context.exception
+ self.assertEqual(str(exception), ("wustlkey must be defined"))
+
+ def test_get_user_returns_value_error_on_invalid_wustlkey(self):
+ with patch(
+ "coldfront.plugins.qumulo.utils.active_directory_api.Connection.search",
+ return_value=[],
+ ):
+ with self.assertRaises(ValueError) as context:
+ self.ad_api.get_user(wustlkey="test")
+
+ exception = context.exception
+ self.assertEqual(str(exception), ("Invalid wustlkey"))
+
+ def test_create_ad_group_creates_group(self):
+ group_name = "test_group_name"
+ expected_groups_ou = os.environ.get("AD_GROUPS_OU")
+ expected_new_group_dn = f"cn={group_name},{expected_groups_ou}"
+
+ self.ad_api.create_ad_group(group_name)
+
+ self.mock_connection.add.assert_called_once_with(
+ expected_new_group_dn, "group", attributes={"sAMAccountName": group_name}
+ )
+
+ @patch(
+ "coldfront.plugins.qumulo.utils.active_directory_api.ActiveDirectoryAPI.get_group_dn"
+ )
+ @patch(
+ "coldfront.plugins.qumulo.utils.active_directory_api.ActiveDirectoryAPI.get_user"
+ )
+ @patch(
+ "coldfront.plugins.qumulo.utils.active_directory_api.ad_add_members_to_groups"
+ )
+ def test_add_user_to_ad_group_adds_wustlkey_to_group(
+ self, mock_ad_add_members_to_groups, mock_get_user, mock_get_group_dn
+ ):
+ wustlkey = "test_wustlkey"
+ user = mock_get_user.return_value
+ user_dn = user["dn"]
+
+ group_name = "test_group_name"
+ expected_groups_ou = "OU=QA,OU=RIS,OU=Groups,DC=accounts,DC=ad,DC=wustl,DC=edu"
+ expected_group_dn = f"cn={group_name},{expected_groups_ou}"
+ mock_get_group_dn.return_value = expected_group_dn
+
+ self.ad_api.add_user_to_ad_group(wustlkey, group_name)
+
+ mock_get_user.assert_called_once_with(wustlkey)
+ mock_ad_add_members_to_groups.assert_called_once_with(
+ self.mock_connection, user_dn, expected_group_dn
+ )
+
+ def test_get_group_dn_searches_for_group_dn(self):
+ groups_OU = os.environ.get("AD_GROUPS_OU")
+
+ group_name = "some_group_name"
+
+ self.mock_connection.response = [
+ {"dn": "group_dn", "attributes": {"other_attr": "value"}}
+ ]
+
+ expected_filter = f"(&(objectclass=group)(sAMAccountName={group_name}))"
+
+ self.ad_api.get_group_dn(group_name)
+
+ self.mock_connection.search.assert_called_once_with(groups_OU, expected_filter)
+
+ def test_get_group_dn_returns_value_error_on_empty_result(self):
+ group_name = "some_group_name"
+
+ self.mock_connection.response = []
+
+ with self.assertRaises(ValueError) as context:
+ self.ad_api.get_group_dn(group_name)
+
+ exception = context.exception
+ self.assertEqual(str(exception), ("Invalid group_name"))
+
+ def test_get_group_dn_returns_group_dn(self):
+ group_name = "some_group_name"
+ group_dn = "some_group_dn"
+
+ self.mock_connection.response = [
+ {"dn": group_dn, "attributes": {"other_attr": "value"}}
+ ]
+
+ return_group_dn = self.ad_api.get_group_dn(group_name)
+
+ self.assertEqual(return_group_dn, group_dn)
+
+ def test_delete_ad_group_gets_group_dn(self):
+ groups_OU = os.environ.get("AD_GROUPS_OU")
+ group_name = "some_group_name"
+
+ self.mock_connection.response = [
+ {"dn": "group_dn", "attributes": {"other_attr": "value"}}
+ ]
+
+ expected_filter = f"(&(objectclass=group)(sAMAccountName={group_name}))"
+
+ self.ad_api.delete_ad_group(group_name)
+
+ self.mock_connection.search.assert_called_once_with(groups_OU, expected_filter)
+
+ def test_delete_ad_group_calls_delete(self):
+ group_name = "some_group_name"
+ group_dn = "some_group_dn"
+
+ self.mock_connection.response = [
+ {"dn": group_dn, "attributes": {"other_attr": "value"}}
+ ]
+
+ self.ad_api.delete_ad_group(group_name)
+
+ self.mock_connection.delete.assert_called_once_with(group_dn)
+
+ def test_remove_user_from_group_gets_user_dn(self):
+ self.mock_connection.response = [
+ {"dn": "user_dn", "attributes": {"other_attr": "value"}}
+ ]
+
+ user_name = "test_wustlkey"
+ expected_filter = f"(&(objectclass=person)(sAMAccountName={user_name}))"
+
+ self.ad_api.remove_user_from_group(user_name=user_name, group_name="bar")
+
+ self.mock_connection.search.assert_has_calls(
+ [
+ call(
+ "dc=accounts,dc=ad,dc=wustl,dc=edu",
+ expected_filter,
+ attributes=["sAMAccountName", "mail", "givenName", "sn"],
+ )
+ ]
+ )
+
+ def test_remove_user_from_group_gets_group_dn(self):
+ groups_OU = os.environ.get("AD_GROUPS_OU")
+ group_name = "some_group_name"
+
+ self.mock_connection.response = [
+ {"dn": "group_dn", "attributes": {"other_attr": "value"}}
+ ]
+
+ expected_filter = f"(&(objectclass=group)(sAMAccountName={group_name}))"
+
+ self.ad_api.remove_user_from_group("user_name", group_name)
+
+ self.mock_connection.search.assert_has_calls([call(groups_OU, expected_filter)])
+
+ @patch("coldfront.plugins.qumulo.utils.active_directory_api.Connection")
+ def test_remove_user_from_group_calls_modify(self, mock_connection):
+ group_name = "some_group_name"
+ user_name = "some_user_name"
+ user_dn = "user_dn_foo"
+ group_dn = "group_dn_bar"
+
+ with patch.object(
+ ActiveDirectoryAPI,
+ "get_user",
+ MagicMock(),
+ ) as mock_get_user:
+ with patch.object(
+ ActiveDirectoryAPI,
+ "get_group_dn",
+ MagicMock(),
+ ) as mock_get_group_dn:
+ mock_get_user.return_value = {
+ "dn": user_dn,
+ "attributes": {"other_attr": "value"},
+ }
+
+ mock_get_group_dn.return_value = group_dn
+
+ self.mock_connection = mock_connection.return_value
+ ad_api = ActiveDirectoryAPI()
+ self.mock_connection.response = []
+
+ ad_api.remove_user_from_group(user_name, group_name)
+
+ self.mock_connection.modify.assert_called_once_with(
+ group_dn, {"member": [(MODIFY_DELETE, [user_dn])]}
+ )
diff --git a/coldfront/plugins/qumulo/tests/utils/test_qumulo_plugin_setup.py b/coldfront/plugins/qumulo/tests/utils/test_qumulo_plugin_setup.py
new file mode 100644
index 0000000000..fc92648a55
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/utils/test_qumulo_plugin_setup.py
@@ -0,0 +1,22 @@
+from django.test import TestCase
+from coldfront.core.allocation.models import AllocationStatusChoice
+from coldfront.plugins.qumulo.management.commands.add_allocation_status import (
+ Command,
+)
+
+
+class TestQumuloPluginSetup(TestCase):
+ def setUp(self):
+ cmd = Command()
+ cmd.handle()
+
+ def test_pending_status_exists(self):
+ gotObject = None
+ existsCallIsSafe = True
+ try:
+ gotObject = AllocationStatusChoice.objects.get(name="Pending")
+ except Exception:
+ existsCallIsSafe = False
+ self.assertTrue(existsCallIsSafe)
+ if existsCallIsSafe is True:
+ self.assertNotEqual(gotObject, None)
diff --git a/coldfront/plugins/qumulo/tests/utils/test_update_user_data.py b/coldfront/plugins/qumulo/tests/utils/test_update_user_data.py
new file mode 100644
index 0000000000..617bf6fdf3
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/utils/test_update_user_data.py
@@ -0,0 +1,49 @@
+from django.test import TestCase
+from unittest.mock import patch, call, MagicMock
+
+from ldap3 import MODIFY_DELETE
+
+import os
+from dotenv import load_dotenv
+
+from django.contrib.auth.models import User
+
+from coldfront.plugins.qumulo.utils.update_user_data import (
+ update_user_with_additional_data,
+)
+
+load_dotenv()
+
+
+class TestUpdateUserData(TestCase):
+
+ def test_update_user_with_additional_data_saves_user(self):
+ wustlkey = "test_wustlkey"
+ with patch(
+ "coldfront.plugins.qumulo.utils.update_user_data.ActiveDirectoryAPI"
+ ) as mock_init:
+ mock_instance = MagicMock()
+ mock_init.return_value = mock_instance
+
+ username = "test_wustlkey"
+ email = "test@wustl.edu"
+ given_name = "Test"
+ surname = "Key"
+ mock_instance.get_user.return_value = {
+ "dn": "foo",
+ "attributes": {
+ "sAMAccountName": username,
+ "mail": email,
+ "givenName": given_name,
+ "sn": surname,
+ },
+ }
+
+ update_user_with_additional_data(wustlkey, test_override=True)
+
+ saved_user = User.objects.get(username=username)
+
+ assert saved_user.username == username
+ assert saved_user.email == email
+ assert saved_user.last_name == surname
+ assert saved_user.first_name == given_name
diff --git a/coldfront/plugins/qumulo/tests/utils/test_validators.py b/coldfront/plugins/qumulo/tests/utils/test_validators.py
new file mode 100644
index 0000000000..2d41580766
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/utils/test_validators.py
@@ -0,0 +1,25 @@
+from django.test import TestCase
+from django.core.exceptions import ValidationError
+from coldfront.plugins.qumulo.validators import validate_leading_forward_slash
+
+
+class TestValidateLeadingForwardSlash(TestCase):
+ def test_no_error_with_valid_input(self):
+ try:
+ validate_leading_forward_slash("/foo")
+ except:
+ self.fail(
+ "validate_leading_forward_slash raised ExceptionType unexpectedly!"
+ )
+
+ def test_raises_error_with_invalid_input(self):
+ with self.assertRaises(ValidationError):
+ validate_leading_forward_slash("foo")
+
+ def test_no_error_on_empty_input(self):
+ try:
+ validate_leading_forward_slash("")
+ except:
+ self.fail(
+ "validate_leading_forward_slash raised ExceptionType unexpectedly!"
+ )
diff --git a/coldfront/plugins/qumulo/tests/validators/__init__.py b/coldfront/plugins/qumulo/tests/validators/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/coldfront/plugins/qumulo/tests/validators/test_validate_ad_users.py b/coldfront/plugins/qumulo/tests/validators/test_validate_ad_users.py
new file mode 100644
index 0000000000..160f4495a3
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/validators/test_validate_ad_users.py
@@ -0,0 +1,98 @@
+from django.test import TestCase
+from unittest.mock import patch, MagicMock, call
+
+from django.core.exceptions import ValidationError
+from coldfront.plugins.qumulo.validators import (
+ validate_ad_users,
+ validate_single_ad_user,
+)
+
+
+class TestValidateAdUsers(TestCase):
+ def setUp(self):
+ self.patcher = patch("coldfront.plugins.qumulo.validators.ActiveDirectoryAPI")
+ self.mock_active_directory = self.patcher.start()
+
+ self.mock_get_user = MagicMock()
+ self.mock_active_directory.return_value.get_user = self.mock_get_user
+
+ return super().setUp()
+
+ def tearDown(self):
+ self.patcher.stop()
+
+ return super().tearDown()
+
+ def test_passes_emptylist(self):
+ try:
+ validate_ad_users([])
+ except ValidationError:
+ self.fail("Failed to validate empty list")
+
+ def test_validates_a_single_user(self):
+ self.mock_get_user.return_value = {
+ "dn": "user_dn",
+ "attributes": {"other_attr": "value"},
+ }
+ user = "userkey"
+
+ try:
+ validate_ad_users([user])
+ except ValidationError:
+ self.fail("Failed to validate a single user")
+
+ try:
+ validate_single_ad_user(user)
+ except ValidationError:
+ self.fail("Failed to validate a single user")
+
+ def test_fails_for_a_user(self):
+ self.mock_get_user.side_effect = ValueError("foo")
+ user = "userkey"
+
+ with self.assertRaises(ValidationError) as context_manager:
+ validate_ad_users([user])
+
+ self.assertEquals(context_manager.exception.error_list[0].message, user)
+ self.assertEquals(context_manager.exception.error_list[0].code, "invalid")
+
+ self.mock_get_user.side_effect = ValueError("foo")
+ user = "userkey"
+
+ with self.assertRaises(ValidationError) as context_manager:
+ validate_single_ad_user(user)
+
+ self.assertEquals(context_manager.exception.message, "This WUSTL Key could not be validated")
+ self.assertEquals(context_manager.exception.code, "invalid")
+
+ def test_validates_multiple_users(self):
+ self.mock_get_user.return_value = {
+ "dn": "user_dn",
+ "attributes": {"other_attr": "value"},
+ }
+ users = ["userkey", "userkey2", "userkey3"]
+
+ try:
+ validate_ad_users(users)
+ except ValidationError:
+ self.fail("Failed to validate multiple users")
+ calls = list(map(lambda user: call(user), users))
+ self.mock_get_user.assert_has_calls(calls)
+
+ def test_notify_multiple_failed_users(self):
+ def mock_side_effect(user):
+ if user == "userkey2":
+ return {
+ "dn": "user_dn",
+ "attributes": {"other_attr": "value"},
+ }
+ raise ValueError("Invalid user:".format(user))
+
+ self.mock_get_user.side_effect = mock_side_effect
+ users = ["userkey", "userkey2", "userkey3"]
+
+ with self.assertRaises(ValidationError) as context_manager:
+ validate_ad_users(users)
+
+ self.assertIn(users[0], context_manager.exception)
+ self.assertIn(users[2], context_manager.exception)
diff --git a/coldfront/plugins/qumulo/tests/validators/test_validate_filesystem_path_unique.py b/coldfront/plugins/qumulo/tests/validators/test_validate_filesystem_path_unique.py
new file mode 100644
index 0000000000..66ce7281f0
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/validators/test_validate_filesystem_path_unique.py
@@ -0,0 +1,157 @@
+from django.test import TestCase
+from unittest.mock import patch, MagicMock
+
+from django.core.exceptions import ValidationError
+
+from coldfront.core.allocation.models import AllocationStatusChoice
+
+from coldfront.plugins.qumulo.tests.helper_classes.filesystem_path import (
+ ValidFormPathMock,
+)
+from coldfront.plugins.qumulo.validators import validate_filesystem_path_unique
+from coldfront.plugins.qumulo.tests.utils.mock_data import (
+ build_models,
+ build_user_plus_project,
+ create_allocation,
+)
+
+existing_path_mocked_response = {
+ "path": "/storage2-dev/fs1/bobs_your_uncle/",
+ "name": "bobs_your_uncle",
+ "num_links": 2,
+ "type": "FS_FILE_TYPE_DIRECTORY",
+ "major_minor_numbers": {"major": 0, "minor": 0},
+ "symlink_target_type": "FS_FILE_TYPE_UNKNOWN",
+ "file_number": "2010014",
+ "id": "2010014",
+ "mode": "0777",
+ "owner": "500",
+ "owner_details": {"id_type": "LOCAL_USER", "id_value": "admin"},
+ "group": "513",
+ "group_details": {"id_type": "LOCAL_GROUP", "id_value": "Users"},
+ "blocks": "1",
+ "datablocks": "0",
+ "metablocks": "1",
+ "size": "0",
+ "access_time": "2024-05-17T13:23:40.283585722Z",
+ "modification_time": "2024-05-17T13:23:40.283585722Z",
+ "change_time": "2024-05-17T16:42:13.383433668Z",
+ "creation_time": "2024-05-17T13:23:40.283585722Z",
+ "child_count": 0,
+ "extended_attributes": {
+ "read_only": False,
+ "hidden": False,
+ "system": False,
+ "archive": False,
+ "temporary": False,
+ "compressed": False,
+ "not_content_indexed": False,
+ "sparse_file": False,
+ "offline": False,
+ },
+ "directory_entry_hash_policy": "FS_DIRECTORY_HASH_VERSION_FOLDED",
+ "data_revision": None,
+ "user_metadata_revision": "0",
+}
+
+
+class TestValidateFilesystemPathUnique(TestCase):
+ def setUp(self):
+ build_models()
+ self.patcher = patch("coldfront.plugins.qumulo.validators.QumuloAPI")
+ self.mock_qumulo_api = self.patcher.start()
+ self.mock_get_file_attr = None
+
+ return super().setUp()
+
+ def tearDown(self):
+ self.patcher.stop()
+ return super().tearDown()
+
+ def test_existing_path_raises_validation_error_on_qumulo_conflict(self):
+ self.mock_qumulo_api.return_value.rc.fs.get_file_attr = MagicMock(
+ return_value=existing_path_mocked_response
+ )
+ with self.assertRaises(ValidationError):
+ validate_filesystem_path_unique("/new/existing/file/path")
+
+ def test_unique_path_passes_validation(self):
+ self.mock_qumulo_api.return_value.rc.fs.get_file_attr = ValidFormPathMock()
+ try:
+ validate_filesystem_path_unique("/new/nonexistent/file/path")
+ except ValidationError:
+ self.fail()
+
+ def test_raises_error_on_coldfront_conflict(self):
+ self.mock_qumulo_api.return_value.rc.fs.get_file_attr = ValidFormPathMock()
+
+ user_project_data = build_user_plus_project("foo", "bar")
+
+ path = "storage2-dev/fs1/foo/"
+
+ form_data = {
+ "storage_filesystem_path": path,
+ "storage_export_path": "foo",
+ "storage_name": "for_tester_foo",
+ "storage_quota": 10,
+ "protocols": ["nfs"],
+ "rw_users": [user_project_data["user"].username],
+ "ro_users": [],
+ "storage_ticket": "ITSD-54321",
+ "cost_center": "Uncle Pennybags",
+ "department_number": "Time Travel Services",
+ "service_rate": "general",
+ }
+ create_allocation(
+ user_project_data["project"], user_project_data["user"], form_data
+ )
+
+ with self.assertRaises(ValidationError):
+ validate_filesystem_path_unique(path)
+
+ def test_only_raises_coldfront_error_for_select_statuses(self):
+ self.mock_qumulo_api.return_value.rc.fs.get_file_attr = ValidFormPathMock()
+
+ user_project_data = build_user_plus_project("foo", "bar")
+
+ path = "storage2-dev/fs1/foo/"
+
+ form_data = {
+ "storage_filesystem_path": path,
+ "storage_export_path": "foo",
+ "storage_name": "for_tester_foo",
+ "storage_quota": 10,
+ "protocols": ["nfs"],
+ "rw_users": [user_project_data["user"].username],
+ "ro_users": [],
+ "storage_ticket": "ITSD-54321",
+ "cost_center": "Uncle Pennybags",
+ "department_number": "Time Travel Services",
+ "service_rate": "general",
+ }
+ existing_allocation = create_allocation(
+ user_project_data["project"], user_project_data["user"], form_data
+ )
+
+ reserved_status_names = ["Active", "Pending", "New"]
+ reserved_statuses = AllocationStatusChoice.objects.filter(
+ name__in=reserved_status_names
+ )
+ for status in reserved_statuses:
+ existing_allocation.status = status
+ existing_allocation.save()
+
+ with self.assertRaises(ValidationError):
+ validate_filesystem_path_unique(path)
+
+ other_statuses = AllocationStatusChoice.objects.exclude(
+ name__in=reserved_status_names
+ )
+ for status in other_statuses:
+ existing_allocation.status = status
+ existing_allocation.save()
+
+ try:
+ validate_filesystem_path_unique(path)
+ except ValidationError:
+ self.fail()
diff --git a/coldfront/plugins/qumulo/tests/validators/test_validate_parent_directory.py b/coldfront/plugins/qumulo/tests/validators/test_validate_parent_directory.py
new file mode 100644
index 0000000000..c016826e79
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/validators/test_validate_parent_directory.py
@@ -0,0 +1,61 @@
+from django.test import TestCase
+from unittest.mock import patch, MagicMock
+
+from django.core.exceptions import ValidationError
+
+from coldfront.plugins.qumulo.validators import validate_parent_directory
+
+mock_response = {
+ "control": ["PRESENT"],
+ "posix_special_permissions": ["STICKY_BIT"],
+ "aces": [
+ {
+ "type": "ALLOWED",
+ "flags": ["OBJECT_INHERIT"],
+ "trustee": {
+ "domain": "LOCAL",
+ "auth_id": "string",
+ "uid": 0,
+ "gid": 0,
+ "sid": "string",
+ "name": "string",
+ },
+ "rights": ["READ"],
+ }
+ ],
+}
+
+
+class TestValidateParentDirectory(TestCase):
+ def setUp(self):
+ self.patcher = patch("coldfront.plugins.qumulo.validators.QumuloAPI")
+ self.mock_qumulo_api = self.patcher.start()
+
+ self.mock_get_file_attr = MagicMock()
+ self.mock_get_file_attr.return_value = mock_response
+ self.mock_qumulo_api.return_value.rc.fs.get_file_attr = self.mock_get_file_attr
+
+ return super().setUp()
+
+ def tearDown(self):
+ self.patcher.stop()
+
+ return super().tearDown()
+
+ def test_returns_valid_for_root(self):
+ try:
+ validate_parent_directory("/test-dir")
+ except Exception:
+ self.fail()
+
+ def test_returns_invalid_for_child_with_bad_parent(self):
+ self.mock_get_file_attr.side_effect = Exception()
+
+ with self.assertRaises(ValidationError):
+ validate_parent_directory("/test/test-dir/other")
+
+ def test_returns_valid_for_child_with_good_parent(self):
+ try:
+ validate_parent_directory("/test/test-dir/more")
+ except Exception:
+ self.fail()
diff --git a/coldfront/plugins/qumulo/tests/validators/test_validate_storage_name.py b/coldfront/plugins/qumulo/tests/validators/test_validate_storage_name.py
new file mode 100644
index 0000000000..ebcd019d09
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/validators/test_validate_storage_name.py
@@ -0,0 +1,80 @@
+from django.test import TestCase
+
+from coldfront.plugins.qumulo.validators import validate_storage_name
+from coldfront.plugins.qumulo.tests.utils.mock_data import (
+ build_models,
+ create_allocation,
+ default_form_data,
+)
+
+from django.core.exceptions import ValidationError
+
+import os
+
+
+class TestValidateStorageName(TestCase):
+ def setUp(self):
+ self.build_data = build_models()
+
+ def test_passes_unique_name(self):
+ try:
+ validate_storage_name("test-name")
+ except Exception:
+ self.fail()
+
+ def test_fails_duplicate_name(self):
+ test_name = "foo"
+
+ form_data = default_form_data.copy()
+ form_data["storage_name"] = test_name
+
+ create_allocation(
+ self.build_data["project"], self.build_data["user"], form_data
+ )
+
+ with self.assertRaises(ValidationError):
+ validate_storage_name(test_name)
+
+ def test_accepts_valid_characters(self):
+ try:
+ validate_storage_name("test-name_123.test")
+ except Exception:
+ self.fail()
+
+ def test_rejects_invalid_characters(self):
+ invalid_chars = [
+ " ",
+ "!",
+ "@",
+ "#",
+ "$",
+ "%",
+ "^",
+ "&",
+ "*",
+ "(",
+ ")",
+ "+",
+ "=",
+ "[",
+ "]",
+ "{",
+ "}",
+ ";",
+ ":",
+ "'",
+ '"',
+ "<",
+ ">",
+ ",",
+ "?",
+ "/",
+ "\\",
+ "|",
+ "`",
+ "~",
+ ]
+
+ for char in invalid_chars:
+ with self.assertRaises(ValidationError):
+ validate_storage_name(f"test-name{char}123.test")
diff --git a/coldfront/plugins/qumulo/tests/validators/test_validate_storage_root.py b/coldfront/plugins/qumulo/tests/validators/test_validate_storage_root.py
new file mode 100644
index 0000000000..9ba43f1a37
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/validators/test_validate_storage_root.py
@@ -0,0 +1,20 @@
+from django.test import TestCase
+
+from coldfront.plugins.qumulo.validators import validate_storage_root
+from django.core.exceptions import ValidationError
+
+import os
+
+
+class TestValidateStorageRoot(TestCase):
+ def test_passes_starts_with_storage_root(self):
+ storage_root = os.environ.get("STORAGE2_PATH").strip("/")
+
+ try:
+ validate_storage_root(f"/{storage_root}/test-dir")
+ except Exception:
+ self.fail()
+
+ def test_fails_does_not_start_with_storage_root(self):
+ with self.assertRaises(ValidationError):
+ validate_storage_root(f"/test-dir")
diff --git a/coldfront/plugins/qumulo/tests/validators/test_validates_ldap_usernames_and_groups.py b/coldfront/plugins/qumulo/tests/validators/test_validates_ldap_usernames_and_groups.py
new file mode 100644
index 0000000000..a2e96b8a35
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/validators/test_validates_ldap_usernames_and_groups.py
@@ -0,0 +1,69 @@
+from django.test import TestCase
+
+from django.core.exceptions import ValidationError
+from coldfront.plugins.qumulo.validators import validate_ldap_usernames_and_groups
+
+
+class TestValidatesLdapUsernamesAndGroups(TestCase):
+
+ def setUp(self):
+ self.expect_error_message = "The ddname \"%(name)\" must not include '(', ')', '@', '/', or end with a period."
+ self.translate_table = str.maketrans(
+ {
+ "+": r"\+",
+ ";": r"\;",
+ ",": r"\,",
+ "\\": r"\\",
+ '"': r"\"",
+ "<": r"\<",
+ ">": r"\>",
+ "#": r"\#",
+ }
+ )
+ return super().setUp()
+
+ def test_validates_when_name_is_none(self):
+ name = None
+ is_valid = validate_ldap_usernames_and_groups(name)
+ self.assertIsNone(is_valid)
+
+ def test_validates_when_name_is_blank(self):
+ blank_names = ["", " ", " "]
+ for name in blank_names:
+ is_valid = validate_ldap_usernames_and_groups(name)
+ self.assertIsNone(is_valid)
+
+ def test_validates_when_name_ends_with_period(self):
+ name = "name-ends-with."
+ self.assertRaises(ValidationError, validate_ldap_usernames_and_groups, name)
+ self.assertRaisesMessage(ValidationError, self.expect_error_message)
+
+ def test_validates_when_name_has_disallowed_characters(self):
+ for invalid_token in ["(", ")", "@", "/"]:
+ name = f"name{invalid_token}here"
+ self.assertRaises(ValidationError, validate_ldap_usernames_and_groups, name)
+ self.assertRaisesMessage(ValidationError, self.expect_error_message)
+
+ def test_validates_when_name_escapes_special_characters(self):
+ name = 'name+;,\"test"
#'
+ escaped_name = name.translate(self.translate_table)
+ is_valid = validate_ldap_usernames_and_groups(escaped_name)
+ self.assertTrue(is_valid)
+
+ def test_validates_when_name_does_not_escape_special_characters(self):
+ unescaped_name = 'name+;,"test"#'
+ self.assertRaises(
+ ValidationError, validate_ldap_usernames_and_groups, unescaped_name
+ )
+ self.assertRaisesMessage(ValidationError, self.expect_error_message)
+
+ def test_validates_when_is_valid(self):
+ for name in [
+ "name.last",
+ "name_last",
+ "n.k-last",
+ "Lab XYZ Allocation 1",
+ "Lab XYZ Allocation 2",
+ ]:
+ is_valid = validate_ldap_usernames_and_groups(name)
+ self.assertTrue(is_valid)
diff --git a/coldfront/plugins/qumulo/tests/views/__init__.py b/coldfront/plugins/qumulo/tests/views/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/coldfront/plugins/qumulo/tests/views/test_allocation_table_view.py b/coldfront/plugins/qumulo/tests/views/test_allocation_table_view.py
new file mode 100644
index 0000000000..037d6e05b5
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/views/test_allocation_table_view.py
@@ -0,0 +1,178 @@
+from django.test import TestCase, RequestFactory
+from unittest.mock import patch, MagicMock
+
+from coldfront.core.allocation.models import Allocation
+
+from coldfront.plugins.qumulo.tests.utils.mock_data import (
+ build_models,
+ create_allocation,
+ build_user_plus_project,
+)
+from coldfront.plugins.qumulo.views.allocation_table_view import (
+ AllocationTableView,
+)
+
+
+class AllocationTableViewTests(TestCase):
+ def setUp(self):
+ build_data = build_models()
+
+ self.project = build_data["project"]
+ self.user = build_data["user"]
+
+ self.form_data = {
+ "storage_filesystem_path": "foo",
+ "storage_export_path": "bar",
+ "storage_ticket": "ITSD-54321",
+ "storage_name": "baz",
+ "storage_quota": 7,
+ "protocols": ["nfs"],
+ "rw_users": ["test"],
+ "ro_users": ["test1"],
+ "cost_center": "Uncle Pennybags",
+ "department_number": "Time Travel Services",
+ "service_rate": "general",
+ }
+
+ self.allocation = create_allocation(self.project, self.user, self.form_data)
+
+ def test_get_queryset(self):
+ request = RequestFactory().get(
+ "src/coldfront.plugins.qumulo/views/allocation_table_view.py"
+ )
+ view = AllocationTableView()
+ view.request = request
+
+ qs = view.get_queryset()
+
+ self.assertEqual(len(qs), 1)
+ self.assertEqual(qs[0].id, self.allocation.id)
+
+ def test_search_and_filtering(self):
+
+ # call build_models again to get a different set of projects/users
+ other_models = build_user_plus_project(
+ username="jack.frost", project_name="Ice Age Project"
+ )
+
+ other_project = other_models["project"]
+ other_user = other_models["user"]
+
+ other_form_data = {
+ "storage_filesystem_path": "xyz",
+ "storage_export_path": "abc",
+ "storage_ticket": "ITSD-78910",
+ "storage_name": "general_store",
+ "storage_quota": 8,
+ "protocols": ["nfs"],
+ "rw_users": ["test2"],
+ "ro_users": ["test3"],
+ "cost_center": "Scrooge McDuck",
+ "department_number": "Whale-watching",
+ "service_rate": "consumption",
+ }
+
+ other_allocation = create_allocation(other_project, other_user, other_form_data)
+
+ query_params = {"department_number": "Whale-watching"}
+ request = RequestFactory().get(
+ "src/coldfront.plugins.qumulo/views/allocation_table_view.py",
+ data=query_params,
+ )
+ view = AllocationTableView()
+ view.request = request
+
+ qs = view.get_queryset()
+
+ self.assertEqual(len(qs), 1)
+ self.assertEqual(qs[0].id, other_allocation.id)
+
+ def test_result_pagination(self):
+ # call build_models again to get a different set of projects/users
+ other_models = build_user_plus_project(
+ username="jack.frost", project_name="Ice Age Project"
+ )
+ # create project
+ other_project = other_models["project"]
+ other_user = other_models["user"]
+
+ other_form_data = {
+ "storage_filesystem_path": "xyz",
+ "storage_export_path": "abc",
+ "storage_ticket": "ITSD-78910",
+ "storage_name": "general_store",
+ "storage_quota": 8,
+ "protocols": ["nfs"],
+ "rw_users": ["test2"],
+ "ro_users": ["test3"],
+ "cost_center": "Scrooge McDuck",
+ "department_number": "Whale-watching",
+ "service_rate": "consumption",
+ }
+
+ other_allocations = []
+
+ # create 4 more allocations to test that will match
+ # the query
+ for i in range(4):
+ other_allocations.append(
+ create_allocation(other_project, other_user, other_form_data)
+ )
+
+ query_params = {"department_number": "Whale-watching"}
+ request = RequestFactory().get(
+ "src/coldfront.plugins.qumulo/views/allocation_table_view.py",
+ data=query_params,
+ )
+ view = AllocationTableView()
+ view.request = request
+
+ qs = view.get_queryset()
+
+ self.assertEqual(len(qs), 4)
+
+ result_ids = [x.id for x in qs]
+ underlying_ids = [x.id for x in other_allocations]
+ self.assertEqual(result_ids, underlying_ids)
+
+ # test pagination
+ first_page_by_two = view._handle_pagination(qs, 1, 2)
+ second_page_by_two = view._handle_pagination(qs, 2, 2)
+ third_page_by_two = view._handle_pagination(qs, 3, 2)
+
+ first_page_by_three = view._handle_pagination(qs, 1, 3)
+ second_page_by_three = view._handle_pagination(qs, 2, 3)
+ third_page_by_three = view._handle_pagination(qs, 3, 3)
+
+ # by default, the returned results should be ordered by ID
+
+ first_page_by_two_ids = [alloc.id for alloc in first_page_by_two.object_list]
+ second_page_by_two_ids = [alloc.id for alloc in second_page_by_two.object_list]
+
+ first_page_by_three_ids = [
+ alloc.id for alloc in first_page_by_three.object_list
+ ]
+ second_page_by_three_ids = [
+ alloc.id for alloc in second_page_by_three.object_list
+ ]
+
+ expected_first_page_by_two_ids = [alloc.id for alloc in other_allocations[:2]]
+ expected_second_page_by_two_ids = [alloc.id for alloc in other_allocations[2:4]]
+
+ expected_first_page_by_three_ids = [alloc.id for alloc in other_allocations[:3]]
+ expected_second_page_by_three_ids = [
+ alloc.id for alloc in other_allocations[3:6]
+ ]
+
+ self.assertEqual(first_page_by_two_ids, expected_first_page_by_two_ids)
+ self.assertEqual(second_page_by_two_ids, expected_second_page_by_two_ids)
+
+ self.assertEqual(first_page_by_three_ids, expected_first_page_by_three_ids)
+ self.assertEqual(second_page_by_three_ids, expected_second_page_by_three_ids)
+
+ # NOTE: if you "go past the end", you get the last valid page
+ # instead
+ self.assertEqual(third_page_by_two.object_list, second_page_by_two.object_list)
+ self.assertEqual(
+ third_page_by_three.object_list, second_page_by_three.object_list
+ )
diff --git a/coldfront/plugins/qumulo/tests/views/test_allocation_view.py b/coldfront/plugins/qumulo/tests/views/test_allocation_view.py
new file mode 100644
index 0000000000..6208916b18
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/views/test_allocation_view.py
@@ -0,0 +1,59 @@
+from django.test import TestCase
+from unittest.mock import patch, MagicMock
+
+from coldfront.core.allocation.models import Allocation
+
+from coldfront.plugins.qumulo.tests.utils.mock_data import build_models
+from coldfront.plugins.qumulo.views.allocation_view import AllocationView
+
+
+@patch("coldfront.plugins.qumulo.views.allocation_view.AclAllocations")
+@patch("coldfront.plugins.qumulo.validators.ActiveDirectoryAPI")
+class AllocationViewTests(TestCase):
+ def setUp(self):
+ build_data = build_models()
+
+ self.project = build_data["project"]
+ self.user = build_data["user"]
+
+ self.client.force_login(self.user)
+
+ self.form_data = {
+ "project_pk": self.project.id,
+ "storage_filesystem_path": "foo",
+ "storage_export_path": "bar",
+ "storage_ticket": "ITSD-54321",
+ "storage_name": "baz",
+ "storage_quota": 7,
+ "protocols": ["nfs"],
+ "rw_users": ["test"],
+ "ro_users": ["test1"],
+ "cost_center": "Uncle Pennybags",
+ "department_number": "Time Travel Services",
+ "service_rate": "consumption",
+ }
+
+ def test_create_new_allocation_create_allocation(
+ self,
+ mock_AclAllocations: MagicMock,
+ mock_ActiveDirectoryAPI: MagicMock,
+ ):
+ AllocationView.create_new_allocation(self.form_data, self.user)
+
+ # verifying that a new Allocation object was created
+ self.assertEqual(Allocation.objects.count(), 3)
+
+ # Accessing the created Allocation object
+ allocation = Allocation.objects.first()
+
+ # verifying that Allocation attributes were set correctly
+ self.assertEqual(allocation.project, self.project)
+
+ def test_new_allocation_status_is_pending(
+ self,
+ mock_AclAllocations: MagicMock,
+ mock_ActiveDirectoryAPI: MagicMock,
+ ):
+ AllocationView.create_new_allocation(self.form_data, self.user)
+ allocation = Allocation.objects.first()
+ self.assertEqual(allocation.status.name, "Pending")
diff --git a/coldfront/plugins/qumulo/tests/views/test_create_project_view.py b/coldfront/plugins/qumulo/tests/views/test_create_project_view.py
new file mode 100644
index 0000000000..dff88a0499
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/views/test_create_project_view.py
@@ -0,0 +1,34 @@
+from coldfront.core.field_of_science.models import FieldOfScience
+
+from coldfront.plugins.qumulo.forms import ProjectCreateForm
+from coldfront.plugins.qumulo.tests.utils.mock_data import build_models
+from coldfront.plugins.qumulo.views.project_views import PluginProjectCreateView
+
+from django.test import TestCase
+from django.urls.exceptions import NoReverseMatch
+from unittest.mock import patch, MagicMock
+
+@patch("coldfront.plugins.qumulo.validators.ActiveDirectoryAPI")
+class ProjectCreateViewTests(TestCase):
+ def setUp(self):
+ self.testPI = 'sleong'
+ build_data = build_models()
+ self.fieldOfScience = FieldOfScience.objects.create(
+ description='Bummerology'
+ )
+
+ def test_created_project_has_pi(self, mock_ActiveDirectoryAPI: MagicMock):
+ valid_data = {
+ 'title': 'project-sleong',
+ 'pi': self.testPI,
+ 'description': 'This is the description for the project',
+ 'field_of_science': self.fieldOfScience.id
+ }
+ form = ProjectCreateForm(data=valid_data, user_id='admin')
+ form.is_valid()
+ view = PluginProjectCreateView()
+ try:
+ view.form_valid(form)
+ except NoReverseMatch:
+ pass
+ self.assertEqual(view.project.pi.username, self.testPI)
diff --git a/coldfront/plugins/qumulo/tests/views/test_update_allocation_view.py b/coldfront/plugins/qumulo/tests/views/test_update_allocation_view.py
new file mode 100644
index 0000000000..57c226305a
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests/views/test_update_allocation_view.py
@@ -0,0 +1,404 @@
+from django.test import TestCase, Client
+from unittest.mock import patch, call, MagicMock
+
+from coldfront.core.allocation.models import AllocationUser
+
+from coldfront.plugins.qumulo.views.update_allocation_view import UpdateAllocationView
+from coldfront.plugins.qumulo.tests.utils.mock_data import (
+ create_allocation,
+ build_models,
+)
+from coldfront.plugins.qumulo.utils.acl_allocations import AclAllocations
+
+from coldfront.core.allocation.models import (
+ AllocationChangeRequest,
+ AllocationAttribute,
+ AllocationAttributeChangeRequest,
+ AllocationChangeStatusChoice,
+ AllocationAttributeType,
+)
+
+
+@patch("coldfront.plugins.qumulo.views.update_allocation_view.ActiveDirectoryAPI")
+class UpdateAllocationViewTests(TestCase):
+ def setUp(self):
+ self.client = Client()
+
+ build_data = build_models()
+
+ self.project = build_data["project"]
+ self.user = build_data["user"]
+
+ self.client.force_login(self.user)
+
+ self.form_data = {
+ "storage_filesystem_path": "foo",
+ "storage_export_path": "bar",
+ "storage_ticket": "ITSD-54321",
+ "storage_name": "baz",
+ "storage_quota": 7,
+ "protocols": ["nfs"],
+ "rw_users": ["test"],
+ "ro_users": [],
+ "cost_center": "Uncle Pennybags",
+ "department_number": "Time Travel Services",
+ "service_rate": "consumption",
+ "technical_contact": "it.guru",
+ "billing_contact": "finance.guru",
+ }
+
+ self.storage_allocation = create_allocation(
+ self.project, self.user, self.form_data
+ )
+
+ def test_get_access_users_returns_one_user(
+ self, mock_ActiveDirectoryAPI: MagicMock
+ ):
+ form_data = {
+ "storage_filesystem_path": "foo",
+ "storage_export_path": "bar",
+ "storage_ticket": "ITSD-54321",
+ "storage_name": "baz",
+ "storage_quota": 7,
+ "protocols": ["nfs"],
+ "rw_users": ["test"],
+ "ro_users": [],
+ "cost_center": "Uncle Pennybags",
+ "department_number": "Time Travel Services",
+ "service_rate": "consumption",
+ }
+
+ storage_allocation = create_allocation(self.project, self.user, form_data)
+
+ access_users = UpdateAllocationView.get_access_users("rw", storage_allocation)
+
+ self.assertCountEqual(access_users, form_data["rw_users"])
+
+ def test_get_access_users_returns_multiple_users(
+ self, mock_ActiveDirectoryAPI: MagicMock
+ ):
+ form_data = {
+ "storage_filesystem_path": "foo",
+ "storage_export_path": "bar",
+ "storage_ticket": "ITSD-54321",
+ "storage_name": "baz",
+ "storage_quota": 7,
+ "protocols": ["nfs"],
+ "rw_users": ["test", "foo", "bar"],
+ "ro_users": [],
+ "cost_center": "Uncle Pennybags",
+ "department_number": "Time Travel Services",
+ "service_rate": "consumption",
+ }
+
+ storage_allocation = create_allocation(self.project, self.user, form_data)
+
+ access_users = UpdateAllocationView.get_access_users("rw", storage_allocation)
+
+ self.assertCountEqual(access_users, form_data["rw_users"])
+
+ def test_get_access_users_returns_no_users(
+ self, mock_ActiveDirectoryAPI: MagicMock
+ ):
+ form_data = {
+ "storage_filesystem_path": "foo",
+ "storage_export_path": "bar",
+ "storage_ticket": "ITSD-54321",
+ "storage_name": "baz",
+ "storage_quota": 7,
+ "protocols": ["nfs"],
+ "rw_users": ["test", "foo", "bar"],
+ "ro_users": [],
+ "cost_center": "Uncle Pennybags",
+ "department_number": "Time Travel Services",
+ "service_rate": "consumption",
+ }
+
+ storage_allocation = create_allocation(self.project, self.user, form_data)
+
+ access_users = UpdateAllocationView.get_access_users("ro", storage_allocation)
+
+ self.assertCountEqual(access_users, form_data["ro_users"])
+
+ def test_set_access_users_ignores_unchanged(
+ self, mock_ActiveDirectoryAPI: MagicMock
+ ):
+
+ form_data = {
+ "storage_filesystem_path": "foo",
+ "storage_export_path": "bar",
+ "storage_ticket": "ITSD-54321",
+ "storage_name": "baz",
+ "storage_quota": 7,
+ "protocols": ["nfs"],
+ "rw_users": ["test", "foo", "bar"],
+ "ro_users": [],
+ "cost_center": "Uncle Pennybags",
+ "department_number": "Time Travel Services",
+ "service_rate": "consumption",
+ }
+
+ storage_allocation = create_allocation(self.project, self.user, form_data)
+
+ with patch(
+ "coldfront.plugins.qumulo.views.update_allocation_view.AclAllocations.add_user_to_access_allocation",
+ ) as mock_add_user_to_access_allocation:
+ UpdateAllocationView.set_access_users(
+ "rw", form_data["rw_users"], storage_allocation
+ )
+
+ mock_add_user_to_access_allocation.assert_not_called()
+
+ def test_set_access_users_adds_new_user(self, mock_ActiveDirectoryAPI: MagicMock):
+ form_data = {
+ "storage_filesystem_path": "foo",
+ "storage_export_path": "bar",
+ "storage_ticket": "ITSD-54321",
+ "storage_name": "baz",
+ "storage_quota": 7,
+ "protocols": ["nfs"],
+ "rw_users": ["test", "foo", "bar"],
+ "ro_users": [],
+ "cost_center": "Uncle Pennybags",
+ "department_number": "Time Travel Services",
+ "service_rate": "consumption",
+ }
+
+ storage_allocation = create_allocation(self.project, self.user, form_data)
+
+ new_rw_users: list = form_data["rw_users"].copy()
+ new_rw_users.append("baz")
+
+ with patch(
+ "coldfront.plugins.qumulo.views.update_allocation_view.AclAllocations.add_user_to_access_allocation",
+ ) as mock_add_user_to_access_allocation:
+ UpdateAllocationView.set_access_users(
+ "rw", new_rw_users, storage_allocation
+ )
+
+ access_allocation = AclAllocations.get_access_allocation(
+ storage_allocation, "rw"
+ )
+
+ mock_add_user_to_access_allocation.assert_called_once_with(
+ "baz", access_allocation
+ )
+
+ def test_set_access_users_adds_new_users(self, mock_ActiveDirectoryAPI: MagicMock):
+ form_data = {
+ "storage_filesystem_path": "foo",
+ "storage_export_path": "bar",
+ "storage_ticket": "ITSD-54321",
+ "storage_name": "baz",
+ "storage_quota": 7,
+ "protocols": ["nfs"],
+ "rw_users": ["test", "foo", "bar"],
+ "ro_users": [],
+ "cost_center": "Uncle Pennybags",
+ "department_number": "Time Travel Services",
+ "service_rate": "consumption",
+ }
+
+ with patch(
+ "coldfront.plugins.qumulo.signals.update_user_with_additional_data"
+ ) as mock_update:
+ storage_allocation = create_allocation(self.project, self.user, form_data)
+ update_calls = [
+ call("foo"),
+ call("bar"),
+ ]
+ mock_update.assert_has_calls(update_calls)
+ assert mock_update.call_count == 2
+
+ extra_users = ["baz", "bah"]
+ new_rw_users: list = form_data["rw_users"].copy() + extra_users
+
+ with patch(
+ "coldfront.plugins.qumulo.views.update_allocation_view.AclAllocations.add_user_to_access_allocation",
+ ) as mock_add_user_to_access_allocation:
+ UpdateAllocationView.set_access_users(
+ "rw", new_rw_users, storage_allocation
+ )
+
+ access_allocation = AclAllocations.get_access_allocation(
+ storage_allocation, "rw"
+ )
+
+ calls = []
+ for user in extra_users:
+ calls.append(call(user, access_allocation))
+
+ mock_add_user_to_access_allocation.assert_has_calls(calls)
+
+ def test_set_access_users_removes_user(self, mock_ActiveDirectoryAPI: MagicMock):
+ form_data = {
+ "storage_filesystem_path": "foo",
+ "storage_export_path": "bar",
+ "storage_ticket": "ITSD-54321",
+ "storage_name": "baz",
+ "storage_quota": 7,
+ "protocols": ["nfs"],
+ "rw_users": ["test", "foo", "bar"],
+ "ro_users": [],
+ "cost_center": "Uncle Pennybags",
+ "department_number": "Time Travel Services",
+ "service_rate": "consumption",
+ }
+
+ storage_allocation = create_allocation(self.project, self.user, form_data)
+
+ new_rw_users: list = form_data["rw_users"].copy()
+ new_rw_users.remove("test")
+
+ UpdateAllocationView.set_access_users("rw", new_rw_users, storage_allocation)
+
+ access_allocation = AclAllocations.get_access_allocation(
+ storage_allocation, "rw"
+ )
+ access_allocation_users = AllocationUser.objects.filter(
+ allocation=access_allocation
+ )
+ access_usernames = [
+ allocation_user.user.username for allocation_user in access_allocation_users
+ ]
+
+ self.assertNotIn("test", access_usernames)
+
+ def test_set_access_users_adds_user_to_ad(self, mock_ActiveDirectoryAPI: MagicMock):
+ mock_active_directory_api = mock_ActiveDirectoryAPI.return_value
+
+ form_data = {
+ "storage_filesystem_path": "foo",
+ "storage_export_path": "bar",
+ "storage_ticket": "ITSD-54321",
+ "storage_name": "baz",
+ "storage_quota": 7,
+ "protocols": ["nfs"],
+ "rw_users": ["test", "foo", "bar"],
+ "ro_users": [],
+ "cost_center": "Uncle Pennybags",
+ "department_number": "Time Travel Services",
+ "service_rate": "consumption",
+ }
+
+ storage_allocation = create_allocation(self.project, self.user, form_data)
+
+ new_rw_users: list = form_data["rw_users"].copy()
+ new_rw_users.append("baz")
+
+ UpdateAllocationView.set_access_users("rw", new_rw_users, storage_allocation)
+
+ access_allocation = AclAllocations.get_access_allocation(
+ storage_allocation, "rw"
+ )
+
+ mock_active_directory_api.add_user_to_ad_group.assert_called_once_with(
+ "baz", access_allocation.get_attribute("storage_acl_name")
+ )
+
+ def test_set_access_users_removes_user(self, mock_ActiveDirectoryAPI: MagicMock):
+ mock_active_directory_api = mock_ActiveDirectoryAPI.return_value
+
+ form_data = {
+ "storage_filesystem_path": "foo",
+ "storage_export_path": "bar",
+ "storage_ticket": "ITSD-54321",
+ "storage_name": "baz",
+ "storage_quota": 7,
+ "protocols": ["nfs"],
+ "rw_users": ["test", "foo", "bar"],
+ "ro_users": [],
+ "cost_center": "Uncle Pennybags",
+ "department_number": "Time Travel Services",
+ "service_rate": "consumption",
+ }
+
+ storage_allocation = create_allocation(self.project, self.user, form_data)
+
+ new_rw_users: list = form_data["rw_users"].copy()
+ new_rw_users.remove("test")
+
+ UpdateAllocationView.set_access_users("rw", new_rw_users, storage_allocation)
+
+ access_allocation = AclAllocations.get_access_allocation(
+ storage_allocation, "rw"
+ )
+
+ mock_active_directory_api.remove_user_from_group.assert_called_once_with(
+ "test", access_allocation.get_attribute("storage_acl_name")
+ )
+
+ def test_attribute_change_request_creation(
+ self, mock_ActiveDirectoryAPI: MagicMock
+ ):
+ # allocation and allocation attributes already created
+
+ # need to create an allocation change request
+
+ # an allocation attribute, and an allocation change request
+
+ allocation_change_request = AllocationChangeRequest.objects.create(
+ allocation=self.storage_allocation,
+ status=AllocationChangeStatusChoice.objects.get(name="Pending"),
+ justification="updating",
+ notes="updating",
+ end_date_extension=10,
+ )
+
+ # NOTE - "storage_protocols" will have special handling
+ attributes_to_check = [
+ "cost_center",
+ "department_number",
+ "technical_contact",
+ "billing_contact",
+ "service_rate",
+ "storage_ticket",
+ "storage_quota",
+ ]
+
+ original_values = [
+ AllocationAttribute.objects.get(
+ allocation_attribute_type=AllocationAttributeType.objects.get(
+ name=attr_name
+ ),
+ allocation=self.storage_allocation,
+ ).value
+ for attr_name in attributes_to_check
+ ]
+
+ for attr, val in zip(attributes_to_check, original_values):
+ # without mutation, confirm that *no* allocation change requests are created
+ UpdateAllocationView._handle_attribute_change(
+ allocation=self.storage_allocation,
+ allocation_change_request=allocation_change_request,
+ attribute_name=attr,
+ form_value=val,
+ )
+ self.assertEqual(len(AllocationAttributeChangeRequest.objects.all()), 0)
+
+ # now, try mutating the values
+
+ for attr, val in zip(attributes_to_check, original_values):
+ if attr == "storage_quota":
+ new_val = str(int(val) + 10)
+ else:
+ new_val = val + "MUTATE"
+
+ UpdateAllocationView._handle_attribute_change(
+ allocation=self.storage_allocation,
+ allocation_change_request=allocation_change_request,
+ attribute_name=attr,
+ form_value=new_val,
+ )
+
+ change_request = AllocationAttributeChangeRequest.objects.get(
+ allocation_attribute=AllocationAttribute.objects.get(
+ allocation_attribute_type=AllocationAttributeType.objects.get(
+ name=attr
+ ),
+ allocation=self.storage_allocation,
+ ),
+ allocation_change_request=allocation_change_request,
+ )
+
+ self.assertEqual(change_request.new_value, new_val)
diff --git a/coldfront/plugins/qumulo/tests_integration/__init__.py b/coldfront/plugins/qumulo/tests_integration/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/coldfront/plugins/qumulo/tests_integration/test_settings.py b/coldfront/plugins/qumulo/tests_integration/test_settings.py
new file mode 100644
index 0000000000..db5bf2adff
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests_integration/test_settings.py
@@ -0,0 +1,4 @@
+SECRET_KEY = "fake-key"
+INSTALLED_APPS = [
+ "tests_integration",
+]
diff --git a/coldfront/plugins/qumulo/tests_integration/utils/__init__.py b/coldfront/plugins/qumulo/tests_integration/utils/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/coldfront/plugins/qumulo/tests_integration/utils/test_acl_allocations.py b/coldfront/plugins/qumulo/tests_integration/utils/test_acl_allocations.py
new file mode 100644
index 0000000000..00264f04fb
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests_integration/utils/test_acl_allocations.py
@@ -0,0 +1,91 @@
+from django.test import TestCase, tag
+
+from coldfront.plugins.qumulo.utils.acl_allocations import AclAllocations
+
+from coldfront.core.user.models import User
+from coldfront.core.project.models import Project
+from coldfront.core.allocation.models import (
+ Allocation,
+ AllocationAttributeType,
+ AllocationAttribute,
+)
+
+from coldfront.plugins.qumulo.utils.qumulo_api import QumuloAPI
+from coldfront.plugins.qumulo.utils.active_directory_api import ActiveDirectoryAPI
+from coldfront.plugins.qumulo.tests.utils.mock_data import build_models
+
+
+class TestAclAllocations(TestCase):
+ def setUp(self) -> None:
+ model_data = build_models()
+
+ self.user: User = model_data["user"]
+ self.project: Project = model_data["project"]
+
+ self.ad_api = ActiveDirectoryAPI()
+ self.test_wustlkey = "test"
+ self.user_in_group_filter = (
+ lambda group_name: f"(&(objectClass=user)(sAMAccountName={self.test_wustlkey})(memberof=CN={group_name},OU=QA,OU=RIS,OU=Groups,DC=accounts,DC=ad,DC=wustl,DC=edu))"
+ )
+
+ return super().setUp()
+
+ @tag('integration')
+ def test_create_acl_allocation(self):
+ acl_type = "ro"
+ test_users = ["test"]
+
+ acl_allocations = AclAllocations(project_pk=self.project)
+ acl_allocations.create_acl_allocation(acl_type=acl_type, users=test_users)
+
+ all_allocation_objects = Allocation.objects.all()
+ allocation = all_allocation_objects[0]
+
+ group_name = "storage-test-ro"
+
+ self.ad_api.conn.search(
+ "dc=accounts,dc=ad,dc=wustl,dc=edu",
+ f"(cn={group_name})",
+ )
+ group_dn = self.ad_api.conn.response[0]["dn"]
+ response_group_dn = self.ad_api.get_group_dn(group_name)
+
+ self.assertEqual(response_group_dn, group_dn)
+
+ self.ad_api.delete_ad_group(group_name)
+
+ Allocation.delete(allocation)
+ self.assertEqual(len(Allocation.objects.all()), 0)
+
+
+def clear_acl(path: str, qumulo_api: QumuloAPI):
+ acl = {"control": ["PRESENT"], "posix_special_permissions": [], "aces": []}
+
+ return qumulo_api.rc.fs.set_acl_v2(acl=acl, path=path)
+
+
+def set_allocation_attributes(form_data: dict, allocation):
+ allocation_attribute_names = [
+ "storage_name",
+ "storage_quota",
+ "storage_protocols",
+ "storage_filesystem_path",
+ "storage_export_path",
+ "cost_center",
+ "department_number",
+ "storage_ticket",
+ "technical_contact",
+ "billing_contact",
+ "service_rate",
+ ]
+
+ for allocation_attribute_name in allocation_attribute_names:
+ allocation_attribute_type = AllocationAttributeType.objects.get(
+ name=allocation_attribute_name
+ )
+
+ AllocationAttribute.objects.create(
+ allocation_attribute_type=allocation_attribute_type,
+ allocation=allocation,
+ value=form_data.get(allocation_attribute_name),
+ )
diff --git a/coldfront/plugins/qumulo/tests_integration/utils/test_active_directory_api.py b/coldfront/plugins/qumulo/tests_integration/utils/test_active_directory_api.py
new file mode 100644
index 0000000000..88b8af3480
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests_integration/utils/test_active_directory_api.py
@@ -0,0 +1,116 @@
+from django.test import TestCase, tag
+from coldfront.plugins.qumulo.utils.active_directory_api import ActiveDirectoryAPI
+
+
+class TestActiveDirectoryAPI(TestCase):
+ def setUp(self) -> None:
+ self.ad_api = ActiveDirectoryAPI()
+ self.test_wustlkey = "harterj"
+ self.user_in_group_filter = (
+ lambda group_name: f"(&(objectClass=user)(sAMAccountName={self.test_wustlkey})(memberof=CN={group_name},OU=QA,OU=RIS,OU=Groups,DC=accounts,DC=ad,DC=wustl,DC=edu))"
+ )
+
+ return super().setUp()
+
+ def tearDown(self) -> None:
+ self.ad_api.conn.search(
+ "dc=accounts,dc=ad,dc=wustl,dc=edu", "(cn=storage-delme-test*)"
+ )
+
+ test_groups = self.ad_api.conn.response
+ for group in test_groups:
+ self.ad_api.conn.delete(group["dn"])
+
+ @tag('integration')
+ def test_init_creates_connection(self):
+ self.assertTrue(self.ad_api.conn.bind())
+
+ @tag('integration')
+ def test_get_user(self):
+ user = self.ad_api.get_user(self.test_wustlkey)
+
+ self.assertIn("Harter", user["dn"])
+
+ @tag('integration')
+ def test_create_ad_group(self):
+ group_name = "storage-delme-test-create_ad_group"
+
+ self.ad_api.create_ad_group(group_name=group_name)
+
+ group_dn = self.ad_api.get_group_dn(group_name)
+ self.assertGreater(len(group_dn), 0)
+
+ @tag('integration')
+ def test_add_user_to_ad_group(self):
+ group_name = "storage-delme-test-add_user_to_ad_group"
+
+ self.ad_api.create_ad_group(group_name=group_name)
+ self.ad_api.add_user_to_ad_group(
+ wustlkey=self.test_wustlkey, group_name=group_name
+ )
+
+ search_filter = self.user_in_group_filter(group_name)
+
+ user_in_group = self.ad_api.conn.search(
+ search_base="dc=accounts,dc=ad,dc=wustl,dc=edu",
+ search_filter=search_filter,
+ )
+
+ self.assertTrue(user_in_group)
+
+ @tag('integration')
+ def test_get_group_dn(self):
+ group_name = "storage-delme-test-get_group_dn"
+
+ self.ad_api.create_ad_group(group_name=group_name)
+
+ self.ad_api.conn.search(
+ "dc=accounts,dc=ad,dc=wustl,dc=edu",
+ f"(cn={group_name})",
+ )
+ group_dn = self.ad_api.conn.response[0]["dn"]
+ response_group_dn = self.ad_api.get_group_dn(group_name)
+
+ self.assertEqual(response_group_dn, group_dn)
+
+ @tag('integration')
+ def test_delete_ad_group(self):
+ group_name = "storage-delme-test-delete_ad_group"
+
+ self.ad_api.create_ad_group(group_name=group_name)
+ self.ad_api.conn.search(
+ "dc=accounts,dc=ad,dc=wustl,dc=edu",
+ f"(cn={group_name})",
+ )
+ self.assertEqual(len(self.ad_api.conn.response), 1)
+
+ self.ad_api.delete_ad_group(group_name)
+
+ self.ad_api.conn.search(
+ "dc=accounts,dc=ad,dc=wustl,dc=edu",
+ f"(cn={group_name})",
+ )
+ self.assertEqual(len(self.ad_api.conn.response), 0)
+
+ @tag('integration')
+ def test_remove_user_from_group(self):
+ group_name = "storage-delme-test-remove_user_from_group"
+
+ self.ad_api.create_ad_group(group_name=group_name)
+ self.ad_api.add_user_to_ad_group(
+ wustlkey=self.test_wustlkey, group_name=group_name
+ )
+
+ user_in_group = self.ad_api.conn.search(
+ search_base="dc=accounts,dc=ad,dc=wustl,dc=edu",
+ search_filter=self.user_in_group_filter(group_name),
+ )
+ self.assertTrue(user_in_group)
+
+ self.ad_api.remove_user_from_group(self.test_wustlkey, group_name)
+ user_in_group = self.ad_api.conn.search(
+ search_base="dc=accounts,dc=ad,dc=wustl,dc=edu",
+ search_filter=self.user_in_group_filter(group_name),
+ )
+
+ self.assertFalse(user_in_group)
diff --git a/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/__init__.py b/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_create_allocation.py b/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_create_allocation.py
new file mode 100644
index 0000000000..67c0eaed15
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_create_allocation.py
@@ -0,0 +1,25 @@
+from django.test import TestCase, tag
+from coldfront.plugins.qumulo.utils.qumulo_api import QumuloAPI
+
+
+class TestCreateAllocation(TestCase):
+ @tag('integration')
+ def test_creates_nfs_export(self):
+ qumulo_api = QumuloAPI()
+
+ export_path = "/test/integration-project"
+ fs_path = "/test/integration-project"
+ name = "integration-test"
+
+ qumulo_api.create_allocation(
+ protocols=["nfs"],
+ export_path=export_path,
+ fs_path=fs_path,
+ name=name,
+ limit_in_bytes=10**6,
+ )
+
+ qumulo_api.delete_quota(fs_path)
+
+ export_id = qumulo_api.get_id(protocol="nfs", export_path=export_path)
+ qumulo_api.delete_nfs_export(export_id)
diff --git a/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_create_quota.py b/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_create_quota.py
new file mode 100644
index 0000000000..d40c44749f
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_create_quota.py
@@ -0,0 +1,43 @@
+from django.test import TestCase, tag
+from coldfront.plugins.qumulo.utils.qumulo_api import QumuloAPI
+from qumulo.commands.nfs import parse_nfs_export_restrictions
+
+
+class TestCreateQuota(TestCase):
+ @tag('integration')
+ def test_creates_quota(self):
+ qumulo_api = QumuloAPI()
+
+ export_path = "/test-project"
+ fs_path = "/test-other"
+ nfs_restrictions = [
+ {
+ "host_restrictions": [],
+ "user_mapping": "NFS_MAP_NONE",
+ "require_privileged_port": False,
+ "read_only": False,
+ }
+ ]
+
+ created_export = qumulo_api.rc.nfs.nfs_add_export(
+ export_path=export_path,
+ fs_path=fs_path,
+ description=export_path,
+ restrictions=parse_nfs_export_restrictions(nfs_restrictions),
+ allow_fs_path_create=True,
+ tenant_id=1,
+ )
+
+ limit_in_bytes = 1024
+
+ try:
+ created_quota = qumulo_api.create_quota(fs_path, limit_in_bytes)
+ except:
+ self.fail("Unexpected failure creating quota")
+
+ qumulo_api.delete_quota(fs_path)
+
+ export_id = qumulo_api.get_id(protocol="nfs", export_path=export_path)
+ qumulo_api.delete_nfs_export(export_id)
+
+ self.assertEquals(created_quota["limit"], str(limit_in_bytes))
diff --git a/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_delete_nfs_export.py b/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_delete_nfs_export.py
new file mode 100644
index 0000000000..7986fa5181
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_delete_nfs_export.py
@@ -0,0 +1,24 @@
+from django.test import TestCase, tag
+from coldfront.plugins.qumulo.utils.qumulo_api import QumuloAPI
+from coldfront.plugins.qumulo.tests_integration.utils.test_qumulo_api.utils import (
+ create_test_export,
+)
+
+
+class TestDeleteNFSExport(TestCase):
+ @tag('integration')
+ def test_deletes_nfs_export(self):
+ qumulo_api = QumuloAPI()
+
+ export_fs_path = "/test-delete"
+
+ create_test_export(qumulo_api, export_fs_path=export_fs_path)
+ export_id = qumulo_api.get_id(protocol="nfs", export_path=export_fs_path)
+
+ qumulo_api.delete_quota(export_fs_path)
+ qumulo_api.delete_nfs_export(export_id)
+
+ exports = qumulo_api.list_nfs_exports()
+
+ for entry in exports["entries"]:
+ self.assertNotEqual(export_id, entry["id"])
diff --git a/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_delete_quota.py b/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_delete_quota.py
new file mode 100644
index 0000000000..7e7ce36d70
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_delete_quota.py
@@ -0,0 +1,25 @@
+from django.test import TestCase, tag
+from coldfront.plugins.qumulo.utils.qumulo_api import QumuloAPI
+from coldfront.plugins.qumulo.tests_integration.utils.test_qumulo_api.utils import (
+ create_test_export,
+)
+
+
+class TestDeleteQuota(TestCase):
+ @tag('integration')
+ def test_deletes_a_quota(self):
+ qumulo_api = QumuloAPI()
+ export_fs_path = "/test/test-project"
+ create_test_export(qumulo_api, export_fs_path)
+
+ file_attr = qumulo_api.get_file_attributes(export_fs_path)
+
+ qumulo_api.delete_quota(export_fs_path)
+
+ get_quotas_res = qumulo_api.rc.quota.get_all_quotas()
+ export_id = qumulo_api.get_id(protocol="nfs", export_path=export_fs_path)
+ qumulo_api.delete_nfs_export(export_id)
+
+ for page in get_quotas_res:
+ for quota in page["quotas"]:
+ self.assertNotEqual(quota["id"], file_attr["id"])
diff --git a/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_get_file_attributes.py b/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_get_file_attributes.py
new file mode 100644
index 0000000000..d370976c81
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_get_file_attributes.py
@@ -0,0 +1,27 @@
+from django.test import TestCase, tag
+from coldfront.plugins.qumulo.utils.qumulo_api import QumuloAPI
+from coldfront.plugins.qumulo.tests_integration.utils.test_qumulo_api.utils import (
+ create_test_export,
+)
+
+
+class TestGetFileAttributes(TestCase):
+ @tag('integration')
+ def test_gets_file_attributes(self):
+ qumulo_api = QumuloAPI()
+ export_fs_path = "/test/test-get-file-attr"
+ create_test_export(qumulo_api, export_fs_path)
+
+ try:
+ file_attributes = qumulo_api.get_file_attributes(export_fs_path)
+ except:
+ self.fail("Error getting file attribute")
+
+ qumulo_api.delete_quota(export_fs_path)
+
+ export_id = qumulo_api.get_id(protocol="nfs", export_path=export_fs_path)
+ qumulo_api.delete_nfs_export(export_id)
+
+ self.assertIn("id", file_attributes.keys())
+ self.assertIn("path", file_attributes.keys())
+ self.assertEqual(file_attributes["path"], export_fs_path + "/")
diff --git a/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_get_id.py b/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_get_id.py
new file mode 100644
index 0000000000..418288af88
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_get_id.py
@@ -0,0 +1,38 @@
+from django.test import TestCase, tag
+from coldfront.plugins.qumulo.utils.qumulo_api import QumuloAPI
+from qumulo.commands.nfs import parse_nfs_export_restrictions
+
+
+class TestGetId(TestCase):
+ @tag('integration')
+ def test_gets_id(self):
+ qumulo_api = QumuloAPI()
+ export_path = "/test-project"
+ fs_path = "/test-other"
+
+ nfs_restrictions = [
+ {
+ "host_restrictions": [],
+ "user_mapping": "NFS_MAP_NONE",
+ "require_privileged_port": False,
+ "read_only": False,
+ }
+ ]
+
+ created_export = qumulo_api.rc.nfs.nfs_add_export(
+ export_path=export_path,
+ fs_path=fs_path,
+ description=export_path,
+ restrictions=parse_nfs_export_restrictions(nfs_restrictions),
+ allow_fs_path_create=True,
+ tenant_id=1,
+ )
+
+ try:
+ id_response = qumulo_api.get_id(protocol="nfs", export_path=export_path)
+ except:
+ self.fail("Unexpected Failure Getting Id")
+
+ self.assertEqual(created_export["id"], id_response)
+
+ qumulo_api.delete_nfs_export(id_response)
diff --git a/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_list_nfs_exports.py b/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_list_nfs_exports.py
new file mode 100644
index 0000000000..65ed226fdd
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_list_nfs_exports.py
@@ -0,0 +1,13 @@
+from django.test import TestCase, tag
+from coldfront.plugins.qumulo.utils.qumulo_api import QumuloAPI
+
+
+class TestListNFSExports(TestCase):
+ @tag('integration')
+ def test_lists_all_exports(self):
+ qumulo_api = QumuloAPI()
+
+ nfs_exports = qumulo_api.list_nfs_exports()
+
+ self.assertTrue("entries" in nfs_exports)
+ self.assertIsInstance(nfs_exports["entries"], list)
diff --git a/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_list_qumulo_quota_usages.py b/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_list_qumulo_quota_usages.py
new file mode 100644
index 0000000000..5647721d0e
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_list_qumulo_quota_usages.py
@@ -0,0 +1,20 @@
+from django.test import TestCase, tag
+from coldfront.plugins.qumulo.utils.qumulo_api import QumuloAPI
+from coldfront.plugins.qumulo.tests_integration.utils.test_qumulo_api.utils import (
+ print_all_quotas_with_usage,
+)
+
+
+class TestGetAllQuotasWithStatus(TestCase):
+ @tag('integration')
+ def test_print_all_quotas_with_usage(self):
+ qumulo_api = QumuloAPI()
+ print_all_quotas_with_usage(qumulo_api)
+
+ @tag('integration')
+ def test_get_all_quotas_with_usage(self):
+ qumulo_api = QumuloAPI()
+ all_quotas = qumulo_api.get_all_quotas_with_usage()
+
+ self.assertTrue("quotas" in all_quotas)
+ self.assertIsInstance(all_quotas["quotas"], list)
diff --git a/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_qumulo_api_init.py b/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_qumulo_api_init.py
new file mode 100644
index 0000000000..c0c1be03c6
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_qumulo_api_init.py
@@ -0,0 +1,12 @@
+from django.test import TestCase, tag
+from coldfront.plugins.qumulo.utils.qumulo_api import QumuloAPI
+import os
+
+
+class TestQumuloApiInit(TestCase):
+ @tag('integration')
+ def test_logs_in_without_throwing_error(self):
+ try:
+ qumulo_api = QumuloAPI()
+ except:
+ self.fail("Login failed!")
diff --git a/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_update_allocation.py b/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_update_allocation.py
new file mode 100644
index 0000000000..5785156994
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_update_allocation.py
@@ -0,0 +1,36 @@
+from django.test import TestCase, tag
+from coldfront.plugins.qumulo.utils.qumulo_api import QumuloAPI
+from coldfront.plugins.qumulo.tests_integration.utils.test_qumulo_api.utils import (
+ create_test_export,
+)
+
+
+class TestUpdateAllocation(TestCase):
+ @tag('integration')
+ def test_update_allocation_logs_error(self):
+ qumulo_api = QumuloAPI()
+ export_fs_path = "/test-project/update-allocation"
+ name = "random"
+ create_test_export(qumulo_api, export_fs_path)
+
+ file_attr = qumulo_api.get_file_attributes(export_fs_path)
+ new_limit_in_bytes = 10**6
+ qumulo_api.update_allocation(
+ protocols=["nfs"],
+ export_path=export_fs_path,
+ fs_path=export_fs_path,
+ name=name,
+ limit_in_bytes=new_limit_in_bytes,
+ )
+
+ quota = qumulo_api.rc.quota.get_quota(file_attr["id"])
+
+ export_id = qumulo_api.get_id(protocol="nfs", export_path=export_fs_path)
+
+ qumulo_api._delete_allocation(
+ protocols=["nfs"], fs_path=export_fs_path, export_path=export_fs_path
+ )
+
+ self.assertIsNotNone(export_id)
+
+ self.assertEquals(quota["limit"], str(new_limit_in_bytes))
diff --git a/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_update_nfs_export.py b/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_update_nfs_export.py
new file mode 100644
index 0000000000..29193dccd2
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_update_nfs_export.py
@@ -0,0 +1,59 @@
+from django.test import TestCase, tag
+from coldfront.plugins.qumulo.utils.qumulo_api import QumuloAPI
+from coldfront.plugins.qumulo.tests_integration.utils.test_qumulo_api.utils import (
+ create_test_export,
+)
+
+
+class TestUpdateNFSExport(TestCase):
+ @tag('integration')
+ def test_updates_an_export_description(self):
+ qumulo_api = QumuloAPI()
+ description = "test-test_update_project-active"
+ export_fs_path = "/test/test-update-description"
+ create_test_export(
+ qumulo_api,
+ export_fs_path=export_fs_path,
+ description=description,
+ )
+ export_id = qumulo_api.get_id(protocol="nfs", export_path=export_fs_path)
+
+ update_description = "updated_description"
+
+ try:
+ update_response = qumulo_api.update_nfs_export(
+ export_id, description=update_description
+ )
+ except:
+ self.fail("Error updating nfs export")
+
+ qumulo_api.delete_quota(export_fs_path)
+ qumulo_api.delete_nfs_export(export_id)
+
+ self.assertEqual(update_response["description"], update_description)
+
+ @tag('integration')
+ def test_updates_paths(self):
+ qumulo_api = QumuloAPI()
+
+ export_fs_path = "/test/test-update-project"
+ create_test_export(qumulo_api, export_fs_path)
+ export_id = qumulo_api.get_id(protocol="nfs", export_path=export_fs_path)
+
+ export_path = "/test/test_move"
+ fs_path = "/test/test_foo"
+
+ try:
+ update_response = qumulo_api.update_nfs_export(
+ export_id=export_id,
+ export_path=export_path,
+ fs_path=fs_path,
+ )
+ except:
+ self.fail("Error updating nfs export")
+
+ qumulo_api.delete_quota(export_fs_path)
+ qumulo_api.delete_nfs_export(export_id)
+
+ self.assertEqual(update_response["export_path"], export_path)
+ self.assertEqual(update_response["fs_path"], fs_path)
diff --git a/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_update_quota.py b/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_update_quota.py
new file mode 100644
index 0000000000..5cb85d36a6
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/test_update_quota.py
@@ -0,0 +1,28 @@
+from django.test import TestCase, tag
+from coldfront.plugins.qumulo.utils.qumulo_api import QumuloAPI
+from coldfront.plugins.qumulo.tests_integration.utils.test_qumulo_api.utils import (
+ create_test_export,
+)
+
+
+class TestDeleteQuota(TestCase):
+ @tag('integration')
+ def test_deletes_a_quota(self):
+ qumulo_api = QumuloAPI()
+ export_fs_path = "/test-project"
+ create_test_export(qumulo_api, export_fs_path)
+
+ file_attr = qumulo_api.get_file_attributes(export_fs_path)
+
+ new_limit_in_bytes = 8192
+ try:
+ qumulo_api.update_quota(export_fs_path, new_limit_in_bytes)
+ except:
+ self.fail("Unexpected failure updating quota")
+ quota = qumulo_api.rc.quota.get_quota(file_attr["id"])
+
+ qumulo_api.delete_quota(export_fs_path)
+ export_id = qumulo_api.get_id(protocol="nfs", export_path=export_fs_path)
+ qumulo_api.delete_nfs_export(export_id)
+
+ self.assertEquals(quota["limit"], str(new_limit_in_bytes))
diff --git a/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/utils.py b/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/utils.py
new file mode 100644
index 0000000000..4cb04669e1
--- /dev/null
+++ b/coldfront/plugins/qumulo/tests_integration/utils/test_qumulo_api/utils.py
@@ -0,0 +1,22 @@
+from coldfront.plugins.qumulo.utils.qumulo_api import QumuloAPI
+
+
+def create_test_export(
+ qumulo_api: QumuloAPI,
+ export_fs_path="/test-project",
+ description="test-test_project-active",
+):
+ return qumulo_api.create_allocation(
+ protocols=["nfs"],
+ export_path=export_fs_path,
+ fs_path=export_fs_path,
+ name=description,
+ limit_in_bytes=10**6,
+ )
+
+
+def print_all_quotas_with_usage(qumulo_api: QumuloAPI)-> None:
+ response_quotas = qumulo_api.rc.quota.get_all_quotas_with_status(page_size=None)
+ for all_quotas in response_quotas:
+ for quota in all_quotas['quotas']:
+ print("%(path)s - id: %(id)s - %(capacity_usage)s bytes used of %(limit)s" % quota)
diff --git a/coldfront/plugins/qumulo/urls.py b/coldfront/plugins/qumulo/urls.py
new file mode 100644
index 0000000000..b3987bce40
--- /dev/null
+++ b/coldfront/plugins/qumulo/urls.py
@@ -0,0 +1,14 @@
+from django.urls import path
+
+from coldfront.plugins.qumulo.views import allocation_view, update_allocation_view, allocation_table_view
+
+app_name = "qumulo"
+urlpatterns = [
+ path("allocation", allocation_view.AllocationView.as_view(), name="allocation"),
+ path(
+ "allocation//",
+ update_allocation_view.UpdateAllocationView.as_view(),
+ name="updateAllocation",
+ ),
+ path("allocation-table-list", allocation_table_view.AllocationTableView.as_view(), name="allocation-table-list")
+]
diff --git a/coldfront/plugins/qumulo/utils/__init__.py b/coldfront/plugins/qumulo/utils/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/coldfront/plugins/qumulo/utils/aces_manager.py b/coldfront/plugins/qumulo/utils/aces_manager.py
new file mode 100644
index 0000000000..7032d84b17
--- /dev/null
+++ b/coldfront/plugins/qumulo/utils/aces_manager.py
@@ -0,0 +1,269 @@
+class AcesManager(object):
+ @staticmethod
+ def get_base_acl():
+ return {
+ "control": ["PRESENT"],
+ "posix_special_permissions": [],
+ "aces": [],
+ }
+
+ default_aces = [
+ {
+ "flags": ["CONTAINER_INHERIT"],
+ "type": "ALLOWED",
+ "trustee": {"name": "File Owner"},
+ "rights": [
+ "READ",
+ "ADD_FILE",
+ "ADD_SUBDIR",
+ "SYNCHRONIZE",
+ "READ_ACL",
+ "READ_ATTR",
+ "READ_EA",
+ "DELETE_CHILD",
+ "CHANGE_OWNER",
+ "EXECUTE",
+ "WRITE_ACL",
+ "WRITE_ATTR",
+ "WRITE_EA",
+ ],
+ },
+ {
+ "flags": ["OBJECT_INHERIT"],
+ "type": "ALLOWED",
+ "trustee": {"name": "File Owner"},
+ "rights": [
+ "READ",
+ "ADD_FILE",
+ "ADD_SUBDIR",
+ "SYNCHRONIZE",
+ "READ_ACL",
+ "READ_ATTR",
+ "READ_EA",
+ "CHANGE_OWNER",
+ "WRITE_ACL",
+ "WRITE_ATTR",
+ "WRITE_EA",
+ ],
+ },
+ {
+ "flags": ["CONTAINER_INHERIT"],
+ "type": "ALLOWED",
+ "trustee": {"name": "ris-it-admin", "domain": "ACTIVE_DIRECTORY"},
+ "rights": [
+ "READ",
+ "ADD_FILE",
+ "ADD_SUBDIR",
+ "SYNCHRONIZE",
+ "READ_ACL",
+ "READ_ATTR",
+ "READ_EA",
+ "EXECUTE",
+ "DELETE_CHILD",
+ "WRITE_ATTR",
+ "WRITE_EA",
+ ],
+ },
+ {
+ "flags": ["OBJECT_INHERIT"],
+ "type": "ALLOWED",
+ "trustee": {"name": "ris-it-admin", "domain": "ACTIVE_DIRECTORY"},
+ "rights": [
+ "READ",
+ "ADD_FILE",
+ "ADD_SUBDIR",
+ "SYNCHRONIZE",
+ "READ_ACL",
+ "READ_ATTR",
+ "READ_EA",
+ "DELETE_CHILD",
+ "WRITE_ATTR",
+ "WRITE_EA",
+ ],
+ },
+ ]
+
+ @staticmethod
+ def get_allocation_aces(rw_groupname: str, ro_groupname: str):
+ return [
+ {
+ "flags": ["CONTAINER_INHERIT"],
+ "type": "ALLOWED",
+ "trustee": {
+ "name": rw_groupname,
+ "domain": "ACTIVE_DIRECTORY",
+ },
+ "rights": [
+ "READ",
+ "SYNCHRONIZE",
+ "READ_ACL",
+ "READ_ATTR",
+ "READ_EA",
+ "EXECUTE",
+ "ADD_FILE",
+ "ADD_SUBDIR",
+ "DELETE_CHILD",
+ "WRITE_ATTR",
+ "WRITE_EA",
+ ],
+ },
+ {
+ "flags": ["OBJECT_INHERIT"],
+ "type": "ALLOWED",
+ "trustee": {
+ "name": rw_groupname,
+ "domain": "ACTIVE_DIRECTORY",
+ },
+ "rights": [
+ "READ",
+ "SYNCHRONIZE",
+ "READ_ACL",
+ "READ_ATTR",
+ "READ_EA",
+ "ADD_FILE",
+ "ADD_SUBDIR",
+ "DELETE_CHILD",
+ "WRITE_ATTR",
+ "WRITE_EA",
+ ],
+ },
+ {
+ "flags": ["CONTAINER_INHERIT"],
+ "type": "ALLOWED",
+ "trustee": {
+ "name": ro_groupname,
+ "domain": "ACTIVE_DIRECTORY",
+ },
+ "rights": [
+ "READ",
+ "SYNCHRONIZE",
+ "READ_ACL",
+ "READ_ATTR",
+ "READ_EA",
+ "EXECUTE",
+ ],
+ },
+ {
+ "flags": ["OBJECT_INHERIT"],
+ "type": "ALLOWED",
+ "trustee": {
+ "name": ro_groupname,
+ "domain": "ACTIVE_DIRECTORY",
+ },
+ "rights": [
+ "READ",
+ "SYNCHRONIZE",
+ "READ_ACL",
+ "READ_ATTR",
+ "READ_EA",
+ ],
+ },
+ ]
+
+ @staticmethod
+ def get_traverse_aces(
+ rw_groupname: str, ro_groupname: str, is_base_allocation: bool
+ ):
+ if is_base_allocation:
+ return [
+ {
+ "flags": ["CONTAINER_INHERIT"],
+ "type": "ALLOWED",
+ "trustee": {
+ "name": rw_groupname,
+ "domain": "ACTIVE_DIRECTORY",
+ },
+ "rights": [
+ "READ",
+ "SYNCHRONIZE",
+ "READ_ACL",
+ "READ_ATTR",
+ "READ_EA",
+ "EXECUTE",
+ ],
+ },
+ {
+ "flags": ["OBJECT_INHERIT"],
+ "type": "ALLOWED",
+ "trustee": {
+ "name": rw_groupname,
+ "domain": "ACTIVE_DIRECTORY",
+ },
+ "rights": [
+ "READ",
+ "SYNCHRONIZE",
+ "READ_ACL",
+ "READ_ATTR",
+ "READ_EA",
+ ],
+ },
+ {
+ "flags": ["CONTAINER_INHERIT"],
+ "type": "ALLOWED",
+ "trustee": {
+ "name": ro_groupname,
+ "domain": "ACTIVE_DIRECTORY",
+ },
+ "rights": [
+ "READ",
+ "SYNCHRONIZE",
+ "READ_ACL",
+ "READ_ATTR",
+ "READ_EA",
+ "EXECUTE",
+ ],
+ },
+ {
+ "flags": ["OBJECT_INHERIT"],
+ "type": "ALLOWED",
+ "trustee": {
+ "name": ro_groupname,
+ "domain": "ACTIVE_DIRECTORY",
+ },
+ "rights": [
+ "READ",
+ "SYNCHRONIZE",
+ "READ_ACL",
+ "READ_ATTR",
+ "READ_EA",
+ ],
+ },
+ ]
+ else:
+ return [
+ {
+ "flags": [],
+ "type": "ALLOWED",
+ "trustee": {
+ "name": rw_groupname,
+ "domain": "ACTIVE_DIRECTORY",
+ },
+ "rights": [
+ "READ",
+ "SYNCHRONIZE",
+ "READ_ACL",
+ "READ_ATTR",
+ "READ_EA",
+ "EXECUTE",
+ ],
+ },
+ {
+ "flags": [],
+ "type": "ALLOWED",
+ "trustee": {
+ "name": ro_groupname,
+ "domain": "ACTIVE_DIRECTORY",
+ },
+ "rights": [
+ "READ",
+ "SYNCHRONIZE",
+ "READ_ACL",
+ "READ_ATTR",
+ "READ_EA",
+ "EXECUTE",
+ ],
+ },
+ ]
+
+ def default_copy(self):
+ return self.default_aces.copy()
diff --git a/coldfront/plugins/qumulo/utils/acl_allocations.py b/coldfront/plugins/qumulo/utils/acl_allocations.py
new file mode 100644
index 0000000000..766a011a11
--- /dev/null
+++ b/coldfront/plugins/qumulo/utils/acl_allocations.py
@@ -0,0 +1,257 @@
+from coldfront.plugins.qumulo.utils.active_directory_api import ActiveDirectoryAPI
+from typing import Optional
+
+from coldfront.core.allocation.models import (
+ Allocation,
+ AllocationAttribute,
+ AllocationAttributeType,
+ AllocationStatusChoice,
+ Resource,
+ AllocationUserStatusChoice,
+ AllocationUser,
+ User,
+)
+
+from coldfront.plugins.qumulo.utils.qumulo_api import QumuloAPI
+from coldfront.plugins.qumulo.utils.aces_manager import AcesManager
+
+from ldap3.core.exceptions import LDAPException
+
+from pathlib import PurePath
+
+import os
+from dotenv import load_dotenv
+
+load_dotenv(override=True)
+
+class AclAllocations:
+ def __init__(self, project_pk):
+ self.project_pk = project_pk
+
+ def add_allocation_users(self, allocation: Allocation, wustlkeys: list):
+ for wustlkey in wustlkeys:
+ AllocationUser.objects.get_or_create(
+ status=AllocationUserStatusChoice.objects.get(name="Active"),
+ user=User.objects.get(username=wustlkey),
+ allocation=allocation,
+ )
+
+ @staticmethod
+ def add_user_to_access_allocation(username: str, allocation: Allocation):
+ # NOTE - just need to provide the proper username
+ # post_save handler will retrieve email, given/surname, etc.
+ user_tuple = User.objects.get_or_create(username=username)
+
+ AllocationUser.objects.create(
+ allocation=allocation,
+ user=user_tuple[0],
+ status=AllocationUserStatusChoice.objects.get(name="Active"),
+ )
+
+ def create_acl_allocation(
+ self, acl_type: str, users: list, active_directory_api=None
+ ):
+ allocation = Allocation.objects.create(
+ project=self.project_pk,
+ justification="",
+ quantity=1,
+ status=AllocationStatusChoice.objects.get(name="Active"),
+ )
+
+ resource = Resource.objects.get(name=acl_type)
+
+ allocation.resources.add(resource)
+
+ self.add_allocation_users(allocation=allocation, wustlkeys=users)
+ self.set_allocation_attributes(
+ allocation=allocation, acl_type=acl_type, wustlkey=users[0]
+ )
+
+ try:
+ self.create_ad_group_and_add_users(
+ wustlkeys=users,
+ allocation=allocation,
+ active_directory_api=active_directory_api,
+ )
+ except LDAPException as e:
+ Allocation.delete(allocation)
+
+ def create_acl_allocations(self, ro_users: list, rw_users: list):
+ active_directory_api = ActiveDirectoryAPI()
+
+ self.create_acl_allocation(
+ acl_type="ro", users=ro_users, active_directory_api=active_directory_api
+ )
+ self.create_acl_allocation(
+ acl_type="rw", users=rw_users, active_directory_api=active_directory_api
+ )
+
+ @staticmethod
+ def create_ad_group_and_add_users(
+ wustlkeys: list,
+ allocation: Allocation,
+ active_directory_api: Optional[ActiveDirectoryAPI] = None,
+ ) -> None:
+ if not active_directory_api:
+ active_directory_api = ActiveDirectoryAPI()
+
+ group_name = allocation.get_attribute(name="storage_acl_name")
+
+ active_directory_api.create_ad_group(group_name)
+
+ for wustlkey in wustlkeys:
+ active_directory_api.add_user_to_ad_group(
+ wustlkey=wustlkey, group_name=group_name
+ )
+
+ @staticmethod
+ def get_access_allocation(storage_allocation: Allocation, resource_name: str):
+ def filter_func(access_allocation: Allocation):
+ try:
+ access_allocation.resources.get(name=resource_name)
+ except:
+ return False
+
+ return True
+
+ access_allocations = AclAllocations.get_access_allocations(storage_allocation)
+
+ access_allocation = next(
+ filter(
+ filter_func,
+ access_allocations,
+ ),
+ None,
+ )
+
+ return access_allocation
+
+ @staticmethod
+ def get_access_allocations(qumulo_allocation: Allocation) -> list[Allocation]:
+ project_access_allocations = Allocation.objects.filter(
+ project=qumulo_allocation.project
+ )
+
+ access_allocations = filter(
+ lambda access_allocation: access_allocation.get_attribute(
+ name="storage_allocation_pk"
+ )
+ == qumulo_allocation.pk,
+ project_access_allocations,
+ )
+
+ return list(access_allocations)
+
+ @staticmethod
+ def remove_acl_access(allocation: Allocation):
+ qumulo_api = QumuloAPI()
+ acl_allocations = AclAllocations.get_access_allocations(allocation)
+ fs_path = allocation.get_attribute(name="storage_filesystem_path")
+
+ for acl_allocation in acl_allocations:
+ acl = qumulo_api.rc.fs.get_acl_v2(fs_path)
+
+ group_name = acl_allocation.get_attribute(name="storage_acl_name")
+
+ filtered_aces = filter(
+ lambda ace: ace["trustee"]["name"] != group_name, acl["aces"]
+ )
+
+ acl["aces"] = list(filtered_aces)
+
+ qumulo_api.rc.fs.set_acl_v2(acl=acl, path=fs_path)
+
+ acl_allocation.status = AllocationStatusChoice.objects.get(name="Revoked")
+ acl_allocation.save()
+
+ def set_allocation_attributes(
+ self, allocation: Allocation, acl_type: str, wustlkey: str
+ ):
+ allocation_attribute_type = AllocationAttributeType.objects.get(
+ name="storage_acl_name"
+ )
+
+ AllocationAttribute.objects.create(
+ allocation_attribute_type=allocation_attribute_type,
+ allocation=allocation,
+ value=f"storage-{wustlkey}-{acl_type}",
+ )
+
+ @staticmethod
+ def set_allocation_acls(
+ base_allocation: Allocation,
+ qumulo_api: QumuloAPI,
+ ):
+ fs_path = base_allocation.get_attribute("storage_filesystem_path")
+ acl = qumulo_api.rc.fs.get_acl_v2(fs_path)
+
+ access_allocations = AclAllocations.get_access_allocations(base_allocation)
+ rw_allocation = next(
+ filter(
+ lambda access_allocation: access_allocation.resources.filter(
+ name="rw"
+ ).exists(),
+ access_allocations,
+ )
+ )
+ ro_allocation = next(
+ filter(
+ lambda access_allocation: access_allocation.resources.filter(
+ name="ro"
+ ).exists(),
+ access_allocations,
+ )
+ )
+
+ rw_groupname = rw_allocation.get_attribute(name="storage_acl_name")
+ ro_groupname = ro_allocation.get_attribute(name="storage_acl_name")
+
+ acl["aces"].extend(AcesManager.get_allocation_aces(rw_groupname, ro_groupname))
+
+ is_base_allocation = QumuloAPI.is_allocation_root_path(fs_path)
+
+ AclAllocations.set_traverse_acl(
+ fs_path=fs_path,
+ rw_groupname=rw_groupname,
+ ro_groupname=ro_groupname,
+ qumulo_api=qumulo_api,
+ is_base_allocation=is_base_allocation,
+ )
+
+ if is_base_allocation:
+ fs_path = f"{fs_path}/Active"
+ qumulo_api.rc.fs.set_acl_v2(acl=acl, path=fs_path)
+
+ @staticmethod
+ def set_traverse_acl(
+ fs_path: str,
+ rw_groupname: str,
+ ro_groupname: str,
+ is_base_allocation,
+ qumulo_api: QumuloAPI,
+ ):
+ if is_base_allocation:
+ fs_path = f"{fs_path}/Active"
+
+ path_parents = list(
+ map(
+ lambda parent: str(parent),
+ PurePath(fs_path).parents
+ )
+ )
+ storage_env_path = (
+ f'{os.environ.get("STORAGE2_PATH", "").rstrip().rstrip("/")}'
+ '/'
+ )
+
+ for path in path_parents:
+ if path.startswith(f"{storage_env_path}"):
+ traverse_acl = qumulo_api.rc.fs.get_acl_v2(path)
+ # bmulligan (20240730): might want to filter duplicates here
+ traverse_acl["aces"].extend(
+ AcesManager.get_traverse_aces(
+ rw_groupname, ro_groupname, is_base_allocation
+ )
+ )
+
+ qumulo_api.rc.fs.set_acl_v2(acl=traverse_acl, path=path)
diff --git a/coldfront/plugins/qumulo/utils/active_directory_api.py b/coldfront/plugins/qumulo/utils/active_directory_api.py
new file mode 100644
index 0000000000..9c68922079
--- /dev/null
+++ b/coldfront/plugins/qumulo/utils/active_directory_api.py
@@ -0,0 +1,101 @@
+from ldap3 import Server, Connection, ALL, NTLM, MODIFY_DELETE
+from ldap3.extend.microsoft.addMembersToGroups import (
+ ad_add_members_to_groups,
+)
+
+import os
+from dotenv import load_dotenv
+
+load_dotenv(override=True)
+
+
+class ActiveDirectoryAPI:
+ def __init__(self) -> None:
+ serverName = os.environ.get("AD_SERVER_NAME")
+ adUser = os.environ.get("AD_USERNAME")
+ adUserPwd = os.environ.get("AD_USER_PASS")
+
+ server = Server(host=serverName, use_ssl=True, get_info=ALL)
+ self.conn = Connection(
+ server,
+ user="ACCOUNTS\\" + adUser,
+ password=adUserPwd,
+ authentication=NTLM,
+ )
+
+ if not self.conn.bind():
+ raise self.conn.result
+
+ def get_user(self, wustlkey: str):
+ if not wustlkey:
+ raise ValueError(("wustlkey must be defined"))
+
+ self.conn.search(
+ "dc=accounts,dc=ad,dc=wustl,dc=edu",
+ f"(&(objectclass=person)(sAMAccountName={wustlkey}))",
+ attributes=["sAMAccountName", "mail", "givenName", "sn"],
+ )
+
+ if not self.conn.response:
+ raise ValueError("Invalid wustlkey")
+
+ return self.conn.response[0]
+
+ def get_user_by_email(self, email: str):
+ if not email:
+ raise ValueError(("email must be defined"))
+
+ self.conn.search(
+ "dc=accounts,dc=ad,dc=wustl,dc=edu",
+ f"(&(objectclass=person)(mail={email}))",
+ attributes=["sAMAccountName", "mail", "givenName", "sn"],
+ )
+
+ if not self.conn.response:
+ raise ValueError("Invalid email")
+
+ return self.conn.response[0]
+
+ def create_ad_group(self, group_name: str) -> None:
+ new_group_DN = self.generate_group_dn(group_name)
+
+ self.conn.add(
+ new_group_DN,
+ "group",
+ attributes={"sAMAccountName": group_name},
+ )
+
+ def add_user_to_ad_group(self, wustlkey: str, group_name: str):
+ group_dn = self.get_group_dn(group_name)
+
+ user = self.get_user(wustlkey)
+ user_dn = user["dn"]
+
+ ad_add_members_to_groups(self.conn, user_dn, group_dn)
+
+ def get_group_dn(self, group_name: str) -> str:
+ groups_OU = os.environ.get("AD_GROUPS_OU")
+ self.conn.search(
+ groups_OU, f"(&(objectclass=group)(sAMAccountName={group_name}))"
+ )
+
+ if not self.conn.response:
+ raise ValueError("Invalid group_name")
+
+ return self.conn.response[0]["dn"]
+
+ def delete_ad_group(self, group_name: str):
+ group_dn = self.get_group_dn(group_name)
+
+ return self.conn.delete(group_dn)
+
+ def remove_user_from_group(self, user_name: str, group_name: str):
+ user_dn = self.get_user(user_name)["dn"]
+ group_dn = self.get_group_dn(group_name)
+
+ self.conn.modify(group_dn, {"member": [(MODIFY_DELETE, [user_dn])]})
+
+ @staticmethod
+ def generate_group_dn(group_name: str) -> str:
+ groups_OU = os.environ.get("AD_GROUPS_OU")
+ return f"cn={group_name},{groups_OU}"
diff --git a/coldfront/plugins/qumulo/utils/qumulo_api.py b/coldfront/plugins/qumulo/utils/qumulo_api.py
new file mode 100644
index 0000000000..7bcfccbf9c
--- /dev/null
+++ b/coldfront/plugins/qumulo/utils/qumulo_api.py
@@ -0,0 +1,292 @@
+import logging
+import os
+import re
+import time
+import urllib.parse
+
+from coldfront.plugins.qumulo.utils.aces_manager import AcesManager
+from coldfront.plugins.qumulo import constants
+
+from qumulo.lib.request import RequestError
+from qumulo.rest_client import RestClient
+from qumulo.commands.nfs import parse_nfs_export_restrictions
+
+from dotenv import load_dotenv
+from typing import Optional, Union
+from pathlib import PurePath
+
+load_dotenv(override=True)
+
+
+class QumuloAPI:
+ def __init__(self):
+ self.rc: RestClient = RestClient(
+ os.environ.get("QUMULO_HOST"), os.environ.get("QUMULO_PORT")
+ )
+ self.rc.login(os.environ.get("QUMULO_USER"), os.environ.get("QUMULO_PASS"))
+ self.valid_protocols = list(
+ map(lambda protocol: protocol[0], constants.PROTOCOL_OPTIONS)
+ )
+
+ def create_allocation(
+ self,
+ protocols: Union[list[str], None],
+ export_path: str,
+ fs_path: str,
+ name: str,
+ limit_in_bytes: int,
+ ):
+
+ if name == None:
+ raise ValueError("name must be defined.")
+
+ if fs_path == None or not fs_path.startswith("/"):
+ raise ValueError("fs_path should be defined and absolute.")
+
+ if protocols == None:
+ protocols = []
+
+ dir_path = str(PurePath(fs_path).parent)
+ name = str(PurePath(fs_path).name)
+ self.rc.fs.create_directory(dir_path=dir_path, name=name)
+
+ self.validate_protocols(protocols)
+
+ for protocol in protocols:
+ self.create_protocol(
+ export_path=export_path, fs_path=fs_path, name=name, protocol=protocol
+ )
+
+ self.create_quota(fs_path=fs_path, limit_in_bytes=limit_in_bytes)
+
+ def setup_allocation(self, fs_path: str):
+ """Create allocation "Active" directory"""
+ if QumuloAPI.is_allocation_root_path(fs_path):
+ self.rc.fs.create_directory(dir_path=fs_path, name="Active")
+
+ acl = AcesManager().get_base_acl()
+ acl["aces"] = AcesManager.default_aces
+
+ self.rc.fs.set_acl_v2(path=fs_path, acl=acl)
+
+ def create_allocation_readme(self, path: str):
+ file_name = "README.txt"
+ readme_meta = self.rc.fs.create_file(dir_path=path, name=file_name)
+ with open("/etc/allocation-README.txt", "r") as arf:
+ self.rc.fs.write_file(id_=readme_meta["id"], data_file=arf)
+
+ def create_protocol(self, export_path: str, fs_path: str, name: str, protocol: str):
+ if name == None:
+ raise ValueError("name must be defined.")
+
+ if fs_path == None or not fs_path.startswith("/"):
+ raise ValueError("fs_path should be defined and absolute.")
+
+ self.validate_protocol(protocol=protocol)
+
+ if protocol == "nfs":
+ if export_path == None or not export_path.startswith("/"):
+ raise ValueError("export_path should be defined and absolute.")
+
+ nfs_restrictions = [
+ {
+ "host_restrictions": [],
+ "user_mapping": "NFS_MAP_NONE",
+ "require_privileged_port": False,
+ "read_only": False,
+ }
+ ]
+
+ self.rc.nfs.nfs_add_export(
+ export_path=export_path,
+ fs_path=fs_path,
+ description=name,
+ restrictions=parse_nfs_export_restrictions(nfs_restrictions),
+ allow_fs_path_create=True,
+ tenant_id=1,
+ )
+ if protocol == "smb":
+ self.rc.smb.smb_add_share(
+ share_name=name,
+ fs_path=fs_path,
+ description=name,
+ allow_fs_path_create=True,
+ )
+
+ def create_quota(self, fs_path: str, limit_in_bytes: int):
+ file_attr = self.rc.fs.get_file_attr(path=fs_path)
+
+ return self.rc.quota.create_quota(file_attr["id"], limit_in_bytes)
+
+ def _delete_allocation(
+ self,
+ protocols: list[str],
+ fs_path: str,
+ export_path: Optional[str] = None,
+ name: Optional[str] = None,
+ ):
+ self.validate_protocols(protocols=protocols)
+
+ self.delete_quota(fs_path)
+
+ for protocol in protocols:
+ self.delete_protocol(export_path=export_path, name=name, protocol=protocol)
+
+ def delete_protocol(
+ self,
+ protocol: str,
+ export_path: Optional[str] = None,
+ name: Optional[str] = None,
+ ):
+ self.validate_protocol(protocol=protocol)
+ if protocol == "nfs":
+ if not export_path:
+ raise TypeError("Export path is not defined.")
+ storage_id = self.get_id(protocol=protocol, export_path=export_path)
+ self.rc.nfs.nfs_delete_export(storage_id)
+ if protocol == "smb":
+ if not name:
+ raise TypeError("Name is not defined.")
+ storage_id = self.get_id(protocol=protocol, name=name)
+ self.rc.smb.smb_delete_share(storage_id)
+
+ def delete_nfs_export(self, export_id):
+ return self.rc.nfs.nfs_delete_export(export_id)
+
+ def delete_quota(self, fs_path: str):
+ file_attr = self.rc.fs.get_file_attr(path=fs_path)
+
+ z = None
+ try:
+ z = self.rc.quota.delete_quota(file_attr["id"])
+ except RequestError:
+ pass
+
+ # return self.rc.quota.delete_quota(file_attr["id"])
+ return z
+
+ def delete_nfs_export(self, export_id):
+ return self.rc.nfs.nfs_delete_export(export_id)
+
+ def get_file_attributes(self, path):
+ return self.rc.fs.get_file_attr(path=path)
+
+ def get_id(
+ self,
+ protocol: str,
+ export_path: Optional[str] = None,
+ name: Optional[str] = None,
+ ):
+ if protocol not in ["nfs", "smb"]:
+ raise ValueError("Invalid Protocol")
+
+ if protocol == "nfs":
+ export = self.rc.request(
+ method="GET",
+ uri="/v2/nfs/exports/" + urllib.parse.quote(export_path, safe=""),
+ )
+ elif protocol == "smb":
+ export = self.rc.request(
+ method="GET", uri="/v2/smb/shares/" + urllib.parse.quote(name, safe="")
+ )
+
+ return export["id"]
+
+ def list_nfs_exports(self):
+ return self.rc.nfs.nfs_list_exports()
+
+ def update_allocation(
+ self,
+ protocols: list[str],
+ export_path: str = None,
+ fs_path: str = None,
+ name: str = None,
+ limit_in_bytes: int = 0,
+ ):
+ if not protocols:
+ raise ValueError("protocols should be defined.")
+
+ self.validate_protocols(protocols=protocols)
+
+ for protocol in self.valid_protocols:
+ if protocol in protocols:
+ try:
+ self.create_protocol(
+ export_path=export_path,
+ fs_path=fs_path,
+ name=name,
+ protocol=protocol,
+ )
+ except RequestError as e:
+ logging.info(f"{protocol} protocol already exists.")
+ else:
+ try:
+ self.delete_protocol(
+ export_path=export_path, name=name, protocol=protocol
+ )
+ except RequestError as e:
+ logging.info(f"{protocol} protocol does not exist.")
+ except TypeError as e:
+ if str(e) not in [
+ "Name is not defined.",
+ "Export path is not defined.",
+ ]:
+ raise
+ else:
+ logging.warn("Name or Export Path is not defined.")
+
+ self.update_quota(fs_path=fs_path, limit_in_bytes=limit_in_bytes)
+
+ def update_nfs_export(
+ self, export_id, export_path=None, fs_path=None, description=None
+ ):
+ return self.rc.nfs.nfs_modify_export(
+ export_id=export_id,
+ export_path=export_path,
+ fs_path=fs_path,
+ description=description,
+ allow_fs_path_create=True,
+ )
+
+ def update_quota(self, fs_path: str, limit_in_bytes: int):
+ file_attr = self.rc.fs.get_file_attr(path=fs_path)
+ return self.rc.quota.update_quota(file_attr["id"], limit_in_bytes)
+
+ def validate_protocol(self, protocol: str):
+ if protocol not in self.valid_protocols:
+ raise ValueError(protocol, " protocol is not valid.")
+
+ def validate_protocols(self, protocols: list[str]):
+ for protocol in protocols:
+ self.validate_protocol(protocol)
+
+ @staticmethod
+ def is_allocation_root_path(fs_path: str) -> bool:
+ # Root path format is
+ # ///
+ return re.fullmatch(r"^/[^/]+/[^/]+/[^/]+$", fs_path.rstrip("/")) is not None
+
+ def get_all_quotas_with_usage(self, page_size=None, if_match=None) -> str:
+ tries = 0
+ MAX_TRIES = 3 # move to configurable constant
+ SNOOZE = 15
+ all_quotas_with_usage = None
+
+ while tries < MAX_TRIES:
+ try:
+ tries = tries + 1
+ # TODO: check for malformed JSON and check response code if available
+ all_quotas_with_usage = self.rc.quota.get_all_quotas_with_status(
+ page_size, if_match
+ )
+ break
+ except Exception as e:
+ logging.warn(f"Unable to access the QUMULO API; attempt #{tries}.")
+ # Don't bother sleeping after the last failed attempt
+ if tries < MAX_TRIES:
+ time.sleep(SNOOZE)
+
+ if all_quotas_with_usage is None:
+ raise Exception("Unable to get_all_quotas_with_status from QUMULO API")
+
+ return next(iter(all_quotas_with_usage))
diff --git a/coldfront/plugins/qumulo/utils/update_user_data.py b/coldfront/plugins/qumulo/utils/update_user_data.py
new file mode 100644
index 0000000000..ed5da20e13
--- /dev/null
+++ b/coldfront/plugins/qumulo/utils/update_user_data.py
@@ -0,0 +1,42 @@
+from typing import Optional
+from django.contrib.auth.models import User
+
+from coldfront.plugins.qumulo.utils.active_directory_api import ActiveDirectoryAPI
+
+import sys
+
+
+def update_user_with_additional_data(username: str, test_override=False) -> Optional[User]:
+ # jprew - NOTE - adding this to avoid this running during tests
+ # as it does not work locally
+ if "test" in sys.argv and not test_override:
+ return None
+
+ # jprew - NOTE: at this point, I think the user *should* exist
+ # since this is post_save
+ # but I'll keep the user creation logic anyway
+ should_update_or_create_user = False
+ try:
+ existing_user = User.objects.get(username=username)
+ if (
+ (existing_user.email is None or existing_user.email == "")
+ or (existing_user.first_name is None or existing_user.first_name == "")
+ or (existing_user.last_name is None or existing_user.last_name == "")
+ ):
+ should_update_or_create_user = True
+ except User.DoesNotExist:
+ should_update_or_create_user = True
+ if should_update_or_create_user:
+ active_directory_api = ActiveDirectoryAPI()
+ attrs = active_directory_api.get_user(username)["attributes"]
+ # either this user *already* exists with the specified username
+ # or it doesn't
+ user_tuple = User.objects.get_or_create(username=username)
+ user = user_tuple[0]
+ user.email = attrs["mail"]
+ user.first_name = attrs["givenName"]
+ user.last_name = attrs["sn"]
+ user.save()
+ # NOTE - returning user to make debugging easier
+ # no code currently uses this returned value
+ return user
diff --git a/coldfront/plugins/qumulo/validators.py b/coldfront/plugins/qumulo/validators.py
new file mode 100644
index 0000000000..d26fc49ae9
--- /dev/null
+++ b/coldfront/plugins/qumulo/validators.py
@@ -0,0 +1,219 @@
+import os
+import re
+
+from django.core.exceptions import ValidationError
+from django.utils.translation import gettext_lazy
+
+from coldfront.core.allocation.models import (
+ Allocation,
+ AllocationAttribute,
+ AllocationAttributeType,
+ AllocationStatusChoice,
+)
+
+from coldfront.plugins.qumulo.utils.active_directory_api import ActiveDirectoryAPI
+from coldfront.plugins.qumulo.utils.qumulo_api import QumuloAPI
+
+from pathlib import PurePath
+from qumulo.lib import request
+
+
+def validate_ad_users(ad_users: list[str]):
+ bad_users = []
+
+ for user in ad_users:
+
+ if not _ad_user_validation_helper(user):
+ bad_users.append(user)
+
+ if len(bad_users) > 0:
+ raise ValidationError(
+ list(
+ map(
+ lambda bad_user: ValidationError(message=bad_user, code="invalid"),
+ bad_users,
+ )
+ )
+ )
+
+
+def validate_filesystem_path_unique(value: str):
+ qumulo_api = QumuloAPI()
+
+ reserved_statuses = AllocationStatusChoice.objects.filter(
+ name__in=["Pending", "Active", "New"]
+ )
+ storage_filesystem_path_attribute_type = AllocationAttributeType.objects.get(
+ name="storage_filesystem_path"
+ )
+ allocations = list(
+ Allocation.objects.filter(
+ allocationattribute__allocation_attribute_type=storage_filesystem_path_attribute_type,
+ allocationattribute__value=value,
+ status__in=reserved_statuses,
+ )
+ )
+
+ if allocations:
+ raise ValidationError(
+ message=f"The entered path ({value}) already exists",
+ code="invalid",
+ )
+
+ path_exists = True
+ try:
+ attr = qumulo_api.rc.fs.get_file_attr(value)
+ except request.RequestError:
+ path_exists = False
+
+ if path_exists is True:
+ raise ValidationError(
+ message=f"The entered path ({value}) already exists",
+ code="invalid",
+ )
+
+
+def validate_ldap_usernames_and_groups(name: str):
+ if name is None:
+ return
+
+ if re.match("^(?=\s*$)", name):
+ return
+
+ if __ldap_usernames_and_groups_validator(name):
+ return True
+
+ raise ValidationError(
+ gettext_lazy(
+ "The name \"%(name)\" must not include '(', ')', '@', '/', or end with a period."
+ ),
+ params={"name": name},
+ )
+
+
+def validate_leading_forward_slash(value: str):
+ if len(value) > 0 and value[0] != "/":
+ raise ValidationError(
+ message=gettext_lazy("%(value)s must start with '/'"),
+ code="invalid",
+ params={"value": value},
+ )
+
+
+def validate_parent_directory(value: str):
+ qumulo_api = QumuloAPI()
+ sub_directories = value.strip("/").split("/")
+
+ for depth in range(1, len(sub_directories), 1):
+ path = "/" + "/".join(sub_directories[0:depth])
+
+ try:
+ qumulo_api.rc.fs.get_file_attr(path)
+ except Exception as e:
+ raise ValidationError(
+ message=f"{path} does not exist. Parent Allocations must first be made.",
+ code="invalid",
+ )
+
+
+def validate_single_ad_user(ad_user: str):
+ if not _ad_user_validation_helper(ad_user):
+ raise ValidationError(
+ message="This WUSTL Key could not be validated", code="invalid"
+ )
+
+
+def validate_single_ad_user_skip_admin(user: str):
+ if user == "admin":
+ return None
+ return validate_single_ad_user(user)
+
+
+def validate_single_ad_user_skip_admin(user: str):
+ if user == "admin":
+ return None
+ return validate_single_ad_user(user)
+
+
+def validate_storage_name(value: str):
+ valid_character_match = re.match("^[0-9a-zA-Z\-_\.]*$", value)
+
+ if not valid_character_match:
+ raise ValidationError(
+ message=gettext_lazy(
+ "Storage name must contain only alphanumeric characters, hyphens, underscores, and periods."
+ ),
+ code="invalid",
+ )
+
+ existing_allocations = AllocationAttribute.objects.filter(
+ allocation_attribute_type__name="storage_name", value=value
+ )
+
+ if existing_allocations.first():
+ raise ValidationError(message=f"{value} already exists", code="invalid")
+
+ return
+
+
+def validate_storage_root(value: str):
+ is_absolute_path = PurePath(value).is_absolute()
+ storage_root = os.environ.get("STORAGE2_PATH").strip("/")
+
+ if is_absolute_path and not value.startswith(f"/{storage_root}"):
+ raise ValidationError(
+ message=f"{value} must start with '/{storage_root}'",
+ code="invalid",
+ )
+
+
+def validate_ticket(ticket: str):
+ if re.match("\d+$", ticket):
+ return
+ if re.match("ITSD-\d+$", ticket, re.IGNORECASE):
+ return
+ raise ValidationError(
+ gettext_lazy("%(value)s must have format: ITSD-12345 or 12345"),
+ params={"value": ticket},
+ )
+
+
+def _ad_user_validation_helper(ad_user: str) -> bool:
+ active_directory_api = ActiveDirectoryAPI()
+
+ try:
+ active_directory_api.get_user(ad_user)
+ return True
+ except ValueError:
+ return False
+
+
+# documentation https://www.ibm.com/docs/en/sva/10.0.8?topic=names-characters-disallowed-user-group-name
+def __ldap_usernames_and_groups_validator(name: str) -> bool:
+ for token in ["(", ")", "@", "/"]:
+ if name.__contains__(token):
+ return False
+
+ for index, chr in enumerate(name):
+ if chr in list(["+", ";", ",", "<", ">", "#"]):
+ escaped = index > 0 and (name[index - 1] == "\\")
+ if not escaped:
+ return False
+
+ index = 0
+ name_length = len(name)
+ while index < name_length:
+ if chr == "\\":
+ escaped = (index < name_length) and (
+ name[index + 1] in list(["+", ";", ",", "<", ">", "#", "\\"])
+ )
+ if not escaped:
+ return False
+ index = index + 1
+
+ index = index + 1
+
+ if name[-1] == ".":
+ return False
+
+ return True
diff --git a/coldfront/plugins/qumulo/views/__init__.py b/coldfront/plugins/qumulo/views/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/coldfront/plugins/qumulo/views/allocation_table_view.py b/coldfront/plugins/qumulo/views/allocation_table_view.py
new file mode 100644
index 0000000000..83639f46ad
--- /dev/null
+++ b/coldfront/plugins/qumulo/views/allocation_table_view.py
@@ -0,0 +1,209 @@
+from typing import List
+
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.core.paginator import EmptyPage, Paginator
+from django.db.models.query import QuerySet
+from django.views.generic import ListView
+
+from coldfront.plugins.qumulo.forms import AllocationTableSearchForm
+
+from coldfront.core.allocation.models import (
+ Allocation,
+ AllocationAttribute,
+ AllocationAttributeType,
+)
+from coldfront.core.resource.models import Resource
+
+from django.db.models import OuterRef, Subquery
+
+
+class AllocationListItem:
+ id: int
+ project_id: int
+ project_name: str
+ resource_name: str
+ department_number: str
+ allocation_status: str
+ pi_last_name: str
+ pi_first_name: str
+ pi_user_name: str
+ itsd_ticket: str
+ file_path: str
+ service_rate: str
+
+ def __init__(self, **kwargs):
+ for key, value in kwargs.items():
+ setattr(self, key, value)
+
+
+class AllocationTableView(LoginRequiredMixin, ListView):
+
+ model = Allocation
+ template_name = "allocation_table_view.html"
+ context_object_name = "allocation_list"
+ paginate_by = 25
+
+ def get_queryset(self):
+
+ view_list: List[AllocationListItem] = []
+
+ allocation_search_form = AllocationTableSearchForm(self.request.GET)
+
+ if allocation_search_form.is_valid():
+ data = allocation_search_form.cleaned_data
+ resource = Resource.objects.get(name="Storage2")
+ allocations = Allocation.objects.filter(resources=resource)
+
+ # find type objects
+ department_type = AllocationAttributeType.objects.get(
+ name="department_number"
+ )
+
+ itsd_ticket_type = AllocationAttributeType.objects.get(
+ name="storage_ticket"
+ )
+
+ file_path_type = AllocationAttributeType.objects.get(
+ name="storage_filesystem_path"
+ )
+
+ service_rate_type = AllocationAttributeType.objects.get(name="service_rate")
+
+ # add sub-queries
+ department_sub_q = AllocationAttribute.objects.filter(
+ allocation=OuterRef("pk"), allocation_attribute_type=department_type
+ ).values("value")[:1]
+
+ itsd_ticket_sub_q = AllocationAttribute.objects.filter(
+ allocation=OuterRef("pk"), allocation_attribute_type=itsd_ticket_type
+ ).values("value")[:1]
+
+ file_path_sub_q = AllocationAttribute.objects.filter(
+ allocation=OuterRef("pk"), allocation_attribute_type=file_path_type
+ ).values("value")[:1]
+
+ service_rate_sub_q = AllocationAttribute.objects.filter(
+ allocation=OuterRef("pk"), allocation_attribute_type=service_rate_type
+ ).values("value")[:1]
+
+ allocations = allocations.annotate(
+ department_number=Subquery(department_sub_q),
+ itsd_ticket=Subquery(itsd_ticket_sub_q),
+ file_path=Subquery(file_path_sub_q),
+ service_rate=Subquery(service_rate_sub_q),
+ )
+
+ # add filters
+
+ if data.get("project_name"):
+ allocations = allocations.filter(
+ project__title__icontains=data.get("project_name")
+ )
+
+ if data.get("pi_last_name"):
+ allocations = allocations.filter(
+ project__pi__last_name__icontains=data.get("pi_last_name")
+ )
+
+ if data.get("pi_first_name"):
+ allocations = allocations.filter(
+ project__pi__first_name__icontains=data.get("pi_first_name")
+ )
+
+ if data.get("status"):
+ allocations = allocations.filter(status__in=data.get("status"))
+
+ if data.get("department_number"):
+ allocations = allocations.filter(
+ department_number=data.get("department_number")
+ )
+
+ if data.get("itsd_ticket"):
+ allocations = allocations.filter(itsd_ticket=data.get("itsd_ticket"))
+
+ allocations = allocations.distinct()
+
+ for allocation in allocations:
+
+ view_list.append(
+ AllocationListItem(
+ id=allocation.pk,
+ pi_last_name=allocation.project.pi.last_name,
+ pi_first_name=allocation.project.pi.first_name,
+ pi_user_name=allocation.project.pi.username,
+ project_id=allocation.project.pk,
+ project_name=allocation.project.title,
+ resource_name=resource.name,
+ allocation_status=allocation.status.name,
+ department_number=allocation.department_number,
+ itsd_ticket=allocation.itsd_ticket,
+ file_path=allocation.file_path,
+ service_rate=allocation.service_rate,
+ )
+ )
+
+ return view_list
+
+ def _handle_pagination(
+ self, allocation_list: List[AllocationListItem], page_num, page_size
+ ):
+ paginator = Paginator(allocation_list, page_size)
+
+ try:
+ next_page = paginator.page(page_num)
+ except EmptyPage:
+ next_page = paginator.page(paginator.num_pages)
+
+ return next_page
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ # context["allocation_list"] = self.get_queryset()
+ allocations_count = len(self.get_queryset())
+ context["allocations_count"] = allocations_count
+
+ allocation_search_form = AllocationTableSearchForm(self.request.GET)
+
+ if allocation_search_form.is_valid():
+ data = allocation_search_form.cleaned_data
+ filter_parameters = ""
+ for key, value in data.items():
+ if value:
+ if isinstance(value, QuerySet):
+ filter_parameters += "".join(
+ [f"{key}={ele.pk}&" for ele in value]
+ )
+ elif hasattr(value, "pk"):
+ filter_parameters += f"{key}={value.pk}&"
+ else:
+ filter_parameters += f"{key}={value}&"
+ context["allocation_search_form"] = allocation_search_form
+ else:
+ filter_parameters = None
+ context["allocation_search_form"] = AllocationTableSearchForm()
+
+ 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"
+ context["filter_parameters"] = filter_parameters
+ context["filter_parameters_with_order_by"] = filter_parameters_with_order_by
+
+ allocation_list = context.get("allocation_list")
+
+ page_num = self.request.GET.get("page")
+ if page_num is None or type(page_num) is not int:
+ page_num = 1
+
+ allocation_list = self._handle_pagination(
+ allocation_list, page_num, self.paginate_by
+ )
+
+ return context
diff --git a/coldfront/plugins/qumulo/views/allocation_view.py b/coldfront/plugins/qumulo/views/allocation_view.py
new file mode 100644
index 0000000000..71a1dced77
--- /dev/null
+++ b/coldfront/plugins/qumulo/views/allocation_view.py
@@ -0,0 +1,246 @@
+from django.shortcuts import get_object_or_404
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.views.generic.edit import FormView
+from django.urls import reverse
+
+from typing import Union
+
+import json
+import os
+
+from coldfront.core.allocation.models import (
+ Allocation,
+ AllocationAttribute,
+ AllocationAttributeType,
+ Project,
+ AllocationStatusChoice,
+ Resource,
+ AllocationUserStatusChoice,
+ AllocationUser,
+)
+
+from coldfront.plugins.qumulo.forms import AllocationForm
+from coldfront.plugins.qumulo.utils.acl_allocations import AclAllocations
+from coldfront.plugins.qumulo.validators import validate_filesystem_path_unique
+
+from pathlib import PurePath
+
+from coldfront.core.utils.mail import send_allocation_admin_email
+from coldfront.core.utils.common import get_domain_url
+
+import logging
+
+
+class AllocationView(LoginRequiredMixin, FormView):
+ form_class = AllocationForm
+ template_name = "allocation.html"
+ new_allocation = None
+
+ def get_form_kwargs(self):
+ kwargs = super(AllocationView, self).get_form_kwargs()
+ kwargs["user_id"] = self.request.user.id
+ return kwargs
+
+ def form_valid(
+ self, form: AllocationForm, parent_allocation: Union[Allocation, None] = None
+ ):
+ form_data = form.cleaned_data
+ user = self.request.user
+
+ storage_filesystem_path = form_data.get("storage_filesystem_path")
+ is_absolute_path = PurePath(storage_filesystem_path).is_absolute()
+ if is_absolute_path:
+ absolute_path = storage_filesystem_path
+ else:
+ storage_root = os.environ.get("STORAGE2_PATH").strip("/")
+ absolute_path = f"/{storage_root}/{storage_filesystem_path}"
+ validate_filesystem_path_unique(absolute_path)
+
+ self.new_allocation = AllocationView.create_new_allocation(
+ form_data, user, self.request, parent_allocation
+ )
+ self.success_id = self.new_allocation.get("allocation").id
+
+ return super().form_valid(form)
+
+ def get_success_url(self):
+
+ return reverse(
+ "qumulo:updateAllocation",
+ kwargs={"allocation_id": self.success_id},
+ )
+
+ @staticmethod
+ def create_new_allocation(
+ form_data, user, request, parent_allocation: Union[Allocation, None] = None
+ ):
+ project_pk = form_data.get("project_pk")
+ project = get_object_or_404(Project, pk=project_pk)
+
+ allocation = Allocation.objects.create(
+ project=project,
+ justification="",
+ quantity=1,
+ status=AllocationStatusChoice.objects.get(name="Pending"),
+ )
+
+ logging.warn(f"sending email. \nrequest: {request}")
+
+ send_allocation_admin_email(
+ allocation,
+ "TEST IGNORE: New Allocation Request",
+ "email/new_allocation_request.txt",
+ domain_url=get_domain_url(request),
+ )
+
+ active_status = AllocationUserStatusChoice.objects.get(name="Active")
+ AllocationUser.objects.create(
+ allocation=allocation, user=user, status=active_status
+ )
+
+ resource = Resource.objects.get(name="Storage2")
+ allocation.resources.add(resource)
+
+ AllocationView.set_allocation_attributes(
+ form_data, allocation, parent_allocation
+ )
+
+ access_allocations = AllocationView.create_access_privileges(
+ form_data, project, allocation
+ )
+
+ for access_allocation in access_allocations:
+ access_users = AllocationUser.objects.filter(allocation=access_allocation)
+ AclAllocations.create_ad_group_and_add_users(
+ access_users, access_allocation
+ )
+
+ return {"allocation": allocation, "access_allocations": access_allocations}
+
+ @staticmethod
+ def create_access_privileges(
+ form_data: dict, project: Project, storage_allocation: Allocation
+ ) -> list[Allocation]:
+ rw_users = {
+ "name": "RW Users",
+ "resource": "rw",
+ "users": form_data["rw_users"],
+ }
+ ro_users = {
+ "name": "RO Users",
+ "resource": "ro",
+ "users": form_data["ro_users"],
+ }
+
+ access_allocations = []
+
+ for value in [rw_users, ro_users]:
+ access_allocation = AllocationView.create_access_allocation(
+ value, project, form_data["storage_name"], storage_allocation
+ )
+
+ for username in value["users"]:
+ AclAllocations.add_user_to_access_allocation(
+ username, access_allocation
+ )
+
+ access_allocations.append(access_allocation)
+
+ return access_allocations
+
+ @staticmethod
+ def create_access_allocation(
+ access_data: dict,
+ project: Project,
+ storage_name: str,
+ storage_allocation: Allocation,
+ ):
+ access_allocation = Allocation.objects.create(
+ project=project,
+ justification=access_data["name"],
+ quantity=1,
+ status=AllocationStatusChoice.objects.get(name="Pending"),
+ )
+
+ storage_acl_name_attribute = AllocationAttributeType.objects.get(
+ name="storage_acl_name"
+ )
+ AllocationAttribute.objects.create(
+ allocation_attribute_type=storage_acl_name_attribute,
+ allocation=access_allocation,
+ value="storage-{0}-{1}".format(storage_name, access_data["resource"]),
+ )
+
+ storage_allocation_pk_attribute = AllocationAttributeType.objects.get(
+ name="storage_allocation_pk"
+ )
+ AllocationAttribute.objects.create(
+ allocation_attribute_type=storage_allocation_pk_attribute,
+ allocation=access_allocation,
+ value=storage_allocation.pk,
+ )
+
+ resource = Resource.objects.get(name=access_data["resource"])
+ access_allocation.resources.add(resource)
+
+ return access_allocation
+
+ @staticmethod
+ def set_allocation_attributes(
+ form_data: dict, allocation, parent_allocation: Union[Allocation, None] = None
+ ):
+ allocation_attribute_names = [
+ "storage_name",
+ "storage_ticket",
+ "storage_quota",
+ "storage_protocols",
+ "storage_filesystem_path",
+ "storage_export_path",
+ "cost_center",
+ "department_number",
+ "technical_contact",
+ "billing_contact",
+ "service_rate",
+ ]
+
+ # some of the above are optional
+
+ for allocation_attribute_name in allocation_attribute_names:
+ allocation_attribute_type = AllocationAttributeType.objects.get(
+ name=allocation_attribute_name
+ )
+
+ if allocation_attribute_name == "storage_protocols":
+ protocols = form_data.get("protocols")
+
+ AllocationAttribute.objects.create(
+ allocation_attribute_type=allocation_attribute_type,
+ allocation=allocation,
+ value=json.dumps(protocols),
+ )
+ else:
+ # jprew - for now just skip over them
+ value = form_data.get(allocation_attribute_name)
+ if value is None:
+ continue
+
+ if allocation_attribute_name == "storage_filesystem_path":
+ if parent_allocation is None:
+ storage_root_path = os.environ.get("STORAGE2_PATH", "")
+ else:
+ storage_root_path = "{:s}/Active".format(
+ parent_allocation.get_attribute(
+ name="storage_filesystem_path"
+ )
+ )
+
+ if not value.startswith(storage_root_path):
+ value = (
+ f'{storage_root_path.rstrip(" /")}/' f'{value.lstrip(" /")}'
+ )
+
+ AllocationAttribute.objects.create(
+ allocation_attribute_type=allocation_attribute_type,
+ allocation=allocation,
+ value=value,
+ )
diff --git a/coldfront/plugins/qumulo/views/project_views.py b/coldfront/plugins/qumulo/views/project_views.py
new file mode 100644
index 0000000000..5a38725f34
--- /dev/null
+++ b/coldfront/plugins/qumulo/views/project_views.py
@@ -0,0 +1,65 @@
+from coldfront.core.field_of_science.models import FieldOfScience
+from coldfront.core.project.models import (Project,
+ ProjectStatusChoice,
+ ProjectUser,
+ ProjectUserRoleChoice,
+ ProjectUserStatusChoice)
+from coldfront.plugins.qumulo.forms import ProjectCreateForm
+
+from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
+from django.contrib.auth.models import User
+from django.urls import reverse
+from django.views.generic.edit import FormView
+
+class PluginProjectCreateView(
+ LoginRequiredMixin,
+ UserPassesTestMixin,
+ FormView
+):
+ form_class = ProjectCreateForm
+ model = Project
+ template_name = 'project/project_create_form.html'
+ project = None
+
+ def test_func(self):
+ # bmulligan (20240626):
+ # This function will likely need work (or removal) with the
+ # permissions-related enhancements in the backlog
+ """ UserPassesTestMixin Tests"""
+ if self.request.user.is_superuser:
+ return True
+
+ if self.request.user.userprofile.is_pi:
+ return True
+
+ def form_valid(self, form: ProjectCreateForm):
+ user = self.user_handler(form.cleaned_data['pi'])
+ self.project = Project.objects.create(
+ field_of_science=FieldOfScience.objects.get(
+ id=form.cleaned_data['field_of_science']
+ ),
+ title=form.cleaned_data['title'],
+ pi=user,
+ description=form.cleaned_data['description'],
+ status=ProjectStatusChoice.objects.get(name='New'),
+ force_review=False,
+ requires_review=False,
+ )
+ project_user = ProjectUser.objects.create(
+ user=user,
+ project=self.project,
+ role=ProjectUserRoleChoice.objects.get(name='Manager'),
+ status=ProjectUserStatusChoice.objects.get(name='Active'),
+ )
+ return super().form_valid(form)
+
+ def get_form_kwargs(self):
+ kwargs = super(PluginProjectCreateView, self).get_form_kwargs()
+ kwargs['user_id'] = self.request.user
+ return kwargs
+
+ def get_success_url(self):
+ return reverse('project-detail', kwargs={'pk': self.project.pk})
+
+ def user_handler(self, user: str):
+ return User.objects.get_or_create(username=user)[0]
diff --git a/coldfront/plugins/qumulo/views/update_allocation_view.py b/coldfront/plugins/qumulo/views/update_allocation_view.py
new file mode 100644
index 0000000000..9f9b979e4e
--- /dev/null
+++ b/coldfront/plugins/qumulo/views/update_allocation_view.py
@@ -0,0 +1,205 @@
+from django.urls import reverse_lazy
+
+from typing import Union
+
+import json
+
+from coldfront.core.allocation.models import (
+ Allocation,
+ AllocationAttribute,
+ AllocationAttributeType,
+ AllocationAttributeChangeRequest,
+ AllocationChangeRequest,
+ AllocationChangeStatusChoice,
+ AllocationUser,
+)
+from coldfront.plugins.qumulo.forms import UpdateAllocationForm
+from coldfront.plugins.qumulo.views.allocation_view import AllocationView
+from coldfront.plugins.qumulo.utils.acl_allocations import AclAllocations
+from coldfront.plugins.qumulo.utils.active_directory_api import ActiveDirectoryAPI
+
+
+class UpdateAllocationView(AllocationView):
+ form_class = UpdateAllocationForm
+ template_name = "allocation.html"
+ success_url = reverse_lazy("home")
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ allocation_id = self.kwargs.get("allocation_id")
+ allocation = Allocation.objects.get(pk=allocation_id)
+ alloc_status = allocation.status.name
+
+ if alloc_status == "Pending":
+ pending_status = True
+ else:
+ pending_status = False
+ context["is_pending"] = pending_status
+
+ return context
+
+ def get_form_kwargs(self):
+ kwargs = super(UpdateAllocationView, self).get_form_kwargs()
+ kwargs["user_id"] = self.request.user.id
+
+ allocation_id = self.kwargs.get("allocation_id")
+ allocation = Allocation.objects.get(pk=allocation_id)
+ allocation_attrs = AllocationAttribute.objects.filter(allocation=allocation_id)
+
+ form_data = {
+ "project_pk": allocation.project.pk,
+ }
+
+ allocation_attribute_keys = [
+ "storage_name",
+ "storage_quota",
+ "protocols",
+ "storage_filesystem_path",
+ "storage_export_path",
+ "storage_ticket",
+ "cost_center",
+ "department_number",
+ "technical_contact",
+ "billing_contact",
+ "service_rate",
+ ]
+ for key in allocation_attribute_keys:
+ form_data[key] = self.get_allocation_attribute(
+ allocation_attributes=allocation_attrs, attribute_key=key
+ )
+
+ access_keys = ["rw", "ro"]
+ for key in access_keys:
+ form_data[key + "_users"] = self.get_access_users(key, allocation)
+
+ kwargs["initial"] = form_data
+ return kwargs
+
+ def form_valid(self, form: UpdateAllocationForm):
+ form_data = form.cleaned_data
+
+ allocation = Allocation.objects.get(pk=self.kwargs.get("allocation_id"))
+
+ allocation_change_request = AllocationChangeRequest.objects.create(
+ allocation=allocation,
+ status=AllocationChangeStatusChoice.objects.get(name="Pending"),
+ justification="updating",
+ notes="updating",
+ end_date_extension=10,
+ )
+
+ # NOTE - "storage_protocols" will have special handling
+ attributes_to_check = [
+ "cost_center",
+ "department_number",
+ "technical_contact",
+ "billing_contact",
+ "service_rate",
+ "storage_ticket",
+ "storage_quota",
+ ]
+
+ form_values = [form_data.get(field_name) for field_name in attributes_to_check]
+
+ # handle "storage_protocols" separately
+ attributes_to_check.append("storage_protocols")
+ form_values.append(json.dumps(form_data.get("protocols")))
+
+ for attribute_name, form_value in zip(attributes_to_check, form_values):
+ UpdateAllocationView._handle_attribute_change(
+ allocation=allocation,
+ allocation_change_request=allocation_change_request,
+ attribute_name=attribute_name,
+ form_value=form_value,
+ )
+
+ # RW and RO users are not handled via an AllocationChangeRequest
+ access_keys = ["rw", "ro"]
+ for key in access_keys:
+ access_users = form_data[key + "_users"]
+ self.set_access_users(key, access_users, allocation)
+
+ return super(AllocationView, self).form_valid(form)
+
+ @staticmethod
+ def _handle_attribute_change(
+ allocation: Allocation,
+ allocation_change_request: AllocationChangeRequest,
+ attribute_name: str,
+ form_value: Union[str, int],
+ ) -> None:
+ attribute = AllocationAttribute.objects.get(
+ allocation_attribute_type=AllocationAttributeType.objects.get(
+ name=attribute_name
+ ),
+ allocation=allocation,
+ )
+
+ # storage quota needs to be compared as an integer
+ comparand = int(attribute.value) if type(form_value) is int else attribute.value
+ if comparand != form_value:
+ AllocationAttributeChangeRequest.objects.create(
+ allocation_attribute=attribute,
+ allocation_change_request=allocation_change_request,
+ new_value=form_value,
+ )
+
+ @staticmethod
+ def set_access_users(
+ access_key: str, access_users: list[str], storage_allocation: Allocation
+ ):
+ active_directory_api = ActiveDirectoryAPI()
+
+ access_allocation = AclAllocations.get_access_allocation(
+ storage_allocation, access_key
+ )
+
+ allocation_users = AllocationUser.objects.filter(allocation=access_allocation)
+ allocation_usernames = [
+ allocation_user.user.username for allocation_user in allocation_users
+ ]
+
+ for access_user in access_users:
+ if access_user not in allocation_usernames:
+ AclAllocations.add_user_to_access_allocation(
+ access_user, access_allocation
+ )
+ active_directory_api.add_user_to_ad_group(
+ access_user, access_allocation.get_attribute("storage_acl_name")
+ )
+
+ for allocation_username in allocation_usernames:
+ if allocation_username not in access_users:
+ allocation_users.get(user__username=allocation_username).delete()
+ active_directory_api.remove_user_from_group(
+ allocation_username,
+ access_allocation.get_attribute("storage_acl_name"),
+ )
+
+ def get_allocation_attribute(self, allocation_attributes: list, attribute_key: str):
+ for allocation_attribute in allocation_attributes:
+ if (
+ attribute_key == "protocols"
+ and allocation_attribute.allocation_attribute_type.name
+ == "storage_protocols"
+ ):
+ return json.loads(allocation_attribute.value)
+
+ if allocation_attribute.allocation_attribute_type.name == attribute_key:
+ return allocation_attribute.value
+
+ @staticmethod
+ def get_access_users(key: str, storage_allocation: Allocation) -> list[str]:
+ access_allocation = AclAllocations.get_access_allocation(
+ storage_allocation, key
+ )
+
+ access_allocation_users = AllocationUser.objects.filter(
+ allocation=access_allocation
+ )
+
+ access_users = [
+ allocation_user.user.username for allocation_user in access_allocation_users
+ ]
+
+ return access_users
diff --git a/coldfront/plugins/qumulo/widgets.py b/coldfront/plugins/qumulo/widgets.py
new file mode 100644
index 0000000000..e37e31c9a5
--- /dev/null
+++ b/coldfront/plugins/qumulo/widgets.py
@@ -0,0 +1,27 @@
+from django.forms import Widget
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class MultiSelectLookupInput(Widget):
+ template_name = "multi_select_lookup_input.html"
+
+ class Media:
+ js = ("multi_select_lookup_input.js",)
+
+ def value_from_datadict(self, data, files, name):
+ try:
+ getter = data.getlist
+ except AttributeError:
+ getter = data.get
+
+ getter_return = getter(name)
+
+ raw_string = (
+ getter_return[0]
+ if hasattr(getter_return, "__getitem__") and len(getter_return) > 0
+ else ""
+ )
+
+ return raw_string.split(",")
diff --git a/coldfront/templates/common/authorized_navbar.html b/coldfront/templates/common/authorized_navbar.html
index 0809339b20..c13397f21d 100644
--- a/coldfront/templates/common/authorized_navbar.html
+++ b/coldfront/templates/common/authorized_navbar.html
@@ -31,6 +31,12 @@
{% endif %}
+