diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b6b3e89..4b531107 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ * Add support for Django 5.0. * Add `convert_mariadb_uuid_fields` command to convert UUID fields for MariaDB 10.7+ and Django 5.0+. See the documentation of this command for more information. * Move app settings to their own file, and set defaults for some settings. +* Add additional customization for WorkspaceAdapters. Users can override the `before_anvil_create`, `after_anvil_create`, and `after_anvil_import` methods to run custom code before or after creating or after importing a workspace. ## 0.23.0 (2024-05-31) diff --git a/anvil_consortium_manager/__init__.py b/anvil_consortium_manager/__init__.py index 7e7cfa29..d0255aed 100644 --- a/anvil_consortium_manager/__init__.py +++ b/anvil_consortium_manager/__init__.py @@ -1 +1 @@ -__version__ = "0.23.1.dev3" +__version__ = "0.24.0.dev0" diff --git a/anvil_consortium_manager/adapters/workspace.py b/anvil_consortium_manager/adapters/workspace.py index 2efd3942..f6e6e669 100644 --- a/anvil_consortium_manager/adapters/workspace.py +++ b/anvil_consortium_manager/adapters/workspace.py @@ -149,6 +149,18 @@ def get_extra_detail_context_data(self, workspace, request): return {} + def before_anvil_create(self, workspace): + """Custom actions to take after a workspace is created on AnVIL.""" + pass + + def after_anvil_create(self, workspace): + """Custom actions to take after a workspace is created on AnVIL.""" + pass + + def after_anvil_import(self, workspace): + """Custom actions to take after a workspace is imported from AnVIL.""" + pass + class AdapterAlreadyRegisteredError(Exception): """Exception raised when an adapter or its type is already registered.""" @@ -212,8 +224,6 @@ def get_registered_names(self): def populate_from_settings(self): """Populate the workspace adapter registry from settings. Called by AppConfig ready() method.""" adapter_modules = app_settings.WORKSPACE_ADAPTERS - print("adapter modules") - print(adapter_modules) if len(self._registry): msg = "Registry has already been populated." raise RuntimeError(msg) diff --git a/anvil_consortium_manager/forms.py b/anvil_consortium_manager/forms.py index e17d8424..b3081a36 100644 --- a/anvil_consortium_manager/forms.py +++ b/anvil_consortium_manager/forms.py @@ -181,7 +181,6 @@ def clean(self): # Check for the same case insensitive name in the same billing project. is_mysql = settings.DATABASES["default"]["ENGINE"] == "django.db.backends.mysql" if not is_mysql: - print("here") billing_project = self.cleaned_data.get("billing_project", None) name = self.cleaned_data.get("name", None) if ( diff --git a/anvil_consortium_manager/tests/test_app/adapters.py b/anvil_consortium_manager/tests/test_app/adapters.py index 81454a60..92883e1b 100644 --- a/anvil_consortium_manager/tests/test_app/adapters.py +++ b/anvil_consortium_manager/tests/test_app/adapters.py @@ -61,3 +61,44 @@ class TestForeignKeyWorkspaceAdapter(BaseWorkspaceAdapter): workspace_data_model = models.TestForeignKeyWorkspaceData workspace_data_form_class = forms.TestForeignKeyWorkspaceDataForm workspace_detail_template_name = "workspace_detail.html" + + +class TestWorkspaceMethodsAdapter(BaseWorkspaceAdapter): + """Adapter superclass for testing adapter methods.""" + + name = "workspace adapter methods testing" + type = "methods_tester" + description = "Workspace type for testing custom adapter methods method" + list_table_class_staff_view = WorkspaceStaffTable + list_table_class_view = WorkspaceUserTable + workspace_form_class = WorkspaceForm + workspace_data_model = models.TestWorkspaceMethodsData + workspace_data_form_class = forms.TestWorkspaceMethodsForm + workspace_detail_template_name = "workspace_detail.html" + + +class TestBeforeWorkspaceCreateAdapter(TestWorkspaceMethodsAdapter): + """Test adapter for workspaces with custom methods defined.""" + + def before_anvil_create(self, workspace): + # Append a -2 to the name of the workspace. + workspace.name = workspace.name + "-2" + workspace.save() + + +class TestAfterWorkspaceCreateAdapter(TestWorkspaceMethodsAdapter): + """Test adapter for workspaces with custom methods defined.""" + + def after_anvil_create(self, workspace): + # Set the extra field to "FOO" + workspace.testworkspacemethodsdata.test_field = "FOO" + workspace.testworkspacemethodsdata.save() + + +class TestAfterWorkspaceImportAdapter(TestWorkspaceMethodsAdapter): + """Test adapter for workspaces with custom methods defined.""" + + def after_anvil_import(self, workspace): + # Set the extra field. + workspace.testworkspacemethodsdata.test_field = "imported!" + workspace.testworkspacemethodsdata.save() diff --git a/anvil_consortium_manager/tests/test_app/forms.py b/anvil_consortium_manager/tests/test_app/forms.py index fefff797..55a1c4ec 100644 --- a/anvil_consortium_manager/tests/test_app/forms.py +++ b/anvil_consortium_manager/tests/test_app/forms.py @@ -36,3 +36,11 @@ class TestForeignKeyWorkspaceDataForm(forms.ModelForm): class Meta: model = models.TestForeignKeyWorkspaceData fields = ("other_workspace", "workspace") + + +class TestWorkspaceMethodsForm(forms.ModelForm): + """Form for a TestWorkspaceMethods object.""" + + class Meta: + model = models.TestWorkspaceMethodsData + fields = ("test_field", "workspace") diff --git a/anvil_consortium_manager/tests/test_app/migrations/0005_historicaltestworkspacemethodsdata_and_more.py b/anvil_consortium_manager/tests/test_app/migrations/0005_historicaltestworkspacemethodsdata_and_more.py new file mode 100644 index 00000000..d57b9ff0 --- /dev/null +++ b/anvil_consortium_manager/tests/test_app/migrations/0005_historicaltestworkspacemethodsdata_and_more.py @@ -0,0 +1,49 @@ +# Generated by Django 5.0 on 2024-07-01 22:21 + +import django.db.models.deletion +import simple_history.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('anvil_consortium_manager', '0019_accountuserarchive'), + ('test_app', '0004_testforeignkeyworkspacedata_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalTestWorkspaceMethodsData', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('test_field', models.CharField(max_length=255)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('workspace', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='anvil_consortium_manager.workspace')), + ], + options={ + 'verbose_name': 'historical test workspace methods data', + 'verbose_name_plural': 'historical test workspace methods datas', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='TestWorkspaceMethodsData', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('test_field', models.CharField(max_length=255)), + ('workspace', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='anvil_consortium_manager.workspace')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/anvil_consortium_manager/tests/test_app/models.py b/anvil_consortium_manager/tests/test_app/models.py index 3975262c..a75182c0 100644 --- a/anvil_consortium_manager/tests/test_app/models.py +++ b/anvil_consortium_manager/tests/test_app/models.py @@ -32,3 +32,9 @@ class TestForeignKeyWorkspaceData(BaseWorkspaceData): """Custom model with a second fk to Workspace.""" other_workspace = models.ForeignKey(Workspace, related_name="test_foreign_key_workspaces", on_delete=models.PROTECT) + + +class TestWorkspaceMethodsData(BaseWorkspaceData): + """Custom model with additional fields.""" + + test_field = models.CharField(max_length=255) diff --git a/anvil_consortium_manager/tests/test_views.py b/anvil_consortium_manager/tests/test_views.py index 1d33d104..ea928669 100644 --- a/anvil_consortium_manager/tests/test_views.py +++ b/anvil_consortium_manager/tests/test_views.py @@ -29,7 +29,13 @@ from .test_app import forms as app_forms from .test_app import models as app_models from .test_app import tables as app_tables -from .test_app.adapters import TestForeignKeyWorkspaceAdapter, TestWorkspaceAdapter +from .test_app.adapters import ( + TestAfterWorkspaceCreateAdapter, + TestAfterWorkspaceImportAdapter, + TestBeforeWorkspaceCreateAdapter, + TestForeignKeyWorkspaceAdapter, + TestWorkspaceAdapter, +) from .test_app.factories import TestWorkspaceDataFactory from .test_app.filters import TestAccountListFilter from .utils import AnVILAPIMockTestMixin, TestCase # Redefined to work with Django < 4.2 and Django=4.2. @@ -8273,6 +8279,83 @@ def test_post_workspace_data_with_second_foreign_key_to_workspace(self): self.assertEqual(new_workspace_data.workspace, new_workspace) self.assertEqual(new_workspace_data.other_workspace, other_workspace) + def test_post_custom_adapter_before_anvil_create(self): + """The before_anvil_create method is run before a workspace is created.""" + # Overriding settings doesn't work, because appconfig.ready has already run and + # registered the default adapter. Instead, unregister the default and register the + # new adapter here. + workspace_adapter_registry.register(TestBeforeWorkspaceCreateAdapter) + self.workspace_type = TestBeforeWorkspaceCreateAdapter().get_type() + billing_project = factories.BillingProjectFactory.create(name="test-billing-project") + json_data = { + "namespace": "test-billing-project", + "name": "test-workspace-2", + "attributes": {}, + } + self.anvil_response_mock.add( + responses.POST, + self.api_url, + status=self.api_success_code, + match=[responses.matchers.json_params_matcher(json_data)], + ) + self.client.force_login(self.user) + response = self.client.post( + self.get_url(self.workspace_type), + { + "billing_project": billing_project.pk, + "name": "test-workspace", + # Default workspace data for formset. + "workspacedata-TOTAL_FORMS": 1, + "workspacedata-INITIAL_FORMS": 0, + "workspacedata-MIN_NUM_FORMS": 1, + "workspacedata-MAX_NUM_FORMS": 1, + "workspacedata-0-test_field": "my field value", + }, + ) + self.assertEqual(response.status_code, 302) + # The workspace is created. + new_workspace = models.Workspace.objects.latest("pk") + self.assertEqual(new_workspace.name, "test-workspace-2") + + def test_post_custom_adapter_after_anvil_create(self): + """The after_anvil_create method is run after a workspace is created.""" + # Overriding settings doesn't work, because appconfig.ready has already run and + # registered the default adapter. Instead, unregister the default and register the + # new adapter here. + workspace_adapter_registry.register(TestAfterWorkspaceCreateAdapter) + self.workspace_type = TestAfterWorkspaceCreateAdapter().get_type() + billing_project = factories.BillingProjectFactory.create(name="test-billing-project") + json_data = { + "namespace": "test-billing-project", + "name": "test-workspace", + "attributes": {}, + } + self.anvil_response_mock.add( + responses.POST, + self.api_url, + status=self.api_success_code, + match=[responses.matchers.json_params_matcher(json_data)], + ) + self.client.force_login(self.user) + response = self.client.post( + self.get_url(self.workspace_type), + { + "billing_project": billing_project.pk, + "name": "test-workspace", + # Default workspace data for formset. + "workspacedata-TOTAL_FORMS": 1, + "workspacedata-INITIAL_FORMS": 0, + "workspacedata-MIN_NUM_FORMS": 1, + "workspacedata-MAX_NUM_FORMS": 1, + "workspacedata-0-test_field": "my field value", + }, + ) + self.assertEqual(response.status_code, 302) + # The workspace is created. + new_workspace = models.Workspace.objects.latest("pk") + # The test_field field was modified by the adapter. + self.assertEqual(new_workspace.testworkspacemethodsdata.test_field, "FOO") + class WorkspaceImportTest(AnVILAPIMockTestMixin, TestCase): """Tests for the WorkspaceImport view.""" @@ -9713,6 +9796,59 @@ def test_post_workspace_data_with_second_foreign_key_to_workspace(self): self.assertEqual(new_workspace_data.workspace, new_workspace) self.assertEqual(new_workspace_data.other_workspace, other_workspace) + def test_post_custom_adapter_after_anvil_import(self): + """The after_anvil_create method is run after a workspace is imported.""" + # Overriding settings doesn't work, because appconfig.ready has already run and + # registered the default adapter. Instead, unregister the default and register the + # new adapter here. + workspace_adapter_registry.unregister(DefaultWorkspaceAdapter) + workspace_adapter_registry.register(TestAfterWorkspaceImportAdapter) + self.workspace_type = TestAfterWorkspaceImportAdapter().get_type() + billing_project = factories.BillingProjectFactory.create(name="billing-project") + workspace_name = "workspace" + # Available workspaces API call. + self.anvil_response_mock.add( + responses.GET, + self.workspace_list_url, + match=[ + responses.matchers.query_param_matcher({"fields": "workspace.namespace,workspace.name,accessLevel"}) + ], + status=200, + json=[self.get_api_json_response(billing_project.name, workspace_name)], + ) + # Response for ACL query. + self.anvil_response_mock.add( + responses.GET, + self.get_api_url_acl(billing_project.name, workspace_name), + status=200, # successful response code. + json=self.api_json_response_acl, + ) + url = self.get_api_url(billing_project.name, workspace_name) + self.anvil_response_mock.add( + responses.GET, + url, + status=self.api_success_code, + json=self.get_api_json_response(billing_project.name, workspace_name), + ) + self.client.force_login(self.user) + response = self.client.post( + self.get_url(self.workspace_type), + { + "workspace": billing_project.name + "/" + workspace_name, + # Default workspace data for formset. + "workspacedata-TOTAL_FORMS": 1, + "workspacedata-INITIAL_FORMS": 0, + "workspacedata-MIN_NUM_FORMS": 1, + "workspacedata-MAX_NUM_FORMS": 1, + "workspacedata-0-test_field": "my field value", + }, + ) + self.assertEqual(response.status_code, 302) + # The workspace is created. + new_workspace = models.Workspace.objects.latest("pk") + # The test_field field was modified by the adapter. + self.assertEqual(new_workspace.testworkspacemethodsdata.test_field, "imported!") + class WorkspaceCloneTest(AnVILAPIMockTestMixin, TestCase): """Tests for the WorkspaceClone view.""" @@ -10839,6 +10975,93 @@ def test_post_workspace_data_with_second_foreign_key_to_workspace(self): self.assertEqual(new_workspace_data.workspace, new_workspace) self.assertEqual(new_workspace_data.other_workspace, other_workspace) + def test_post_custom_adapter_before_anvil_create(self): + """The before_anvil_create method is run before a workspace is created.""" + # Overriding settings doesn't work, because appconfig.ready has already run and + # registered the default adapter. Instead, unregister the default and register the + # new adapter here. + workspace_adapter_registry.register(TestBeforeWorkspaceCreateAdapter) + self.workspace_type = TestBeforeWorkspaceCreateAdapter().get_type() + billing_project = factories.BillingProjectFactory.create(name="test-billing-project") + json_data = { + "namespace": "test-billing-project", + "name": "test-workspace-2", + "attributes": {}, + "copyFilesWithPrefix": "notebooks", + } + self.anvil_response_mock.add( + responses.POST, + self.api_url, + status=self.api_success_code, + match=[responses.matchers.json_params_matcher(json_data)], + ) + self.client.force_login(self.user) + response = self.client.post( + self.get_url( + self.workspace_to_clone.billing_project.name, + self.workspace_to_clone.name, + self.workspace_type, + ), + { + "billing_project": billing_project.pk, + "name": "test-workspace", + # Default workspace data for formset. + "workspacedata-TOTAL_FORMS": 1, + "workspacedata-INITIAL_FORMS": 0, + "workspacedata-MIN_NUM_FORMS": 1, + "workspacedata-MAX_NUM_FORMS": 1, + "workspacedata-0-test_field": "my field value", + }, + ) + self.assertEqual(response.status_code, 302) + # The workspace is created. + new_workspace = models.Workspace.objects.latest("pk") + self.assertEqual(new_workspace.name, "test-workspace-2") + + def test_post_custom_adapter_after_anvil_create(self): + """The after_anvil_create method is run after a workspace is created.""" + # Overriding settings doesn't work, because appconfig.ready has already run and + # registered the default adapter. Instead, unregister the default and register the + # new adapter here. + workspace_adapter_registry.register(TestAfterWorkspaceCreateAdapter) + self.workspace_type = TestAfterWorkspaceCreateAdapter().get_type() + billing_project = factories.BillingProjectFactory.create(name="test-billing-project") + json_data = { + "namespace": "test-billing-project", + "name": "test-workspace", + "attributes": {}, + "copyFilesWithPrefix": "notebooks", + } + self.anvil_response_mock.add( + responses.POST, + self.api_url, + status=self.api_success_code, + match=[responses.matchers.json_params_matcher(json_data)], + ) + self.client.force_login(self.user) + response = self.client.post( + self.get_url( + self.workspace_to_clone.billing_project.name, + self.workspace_to_clone.name, + self.workspace_type, + ), + { + "billing_project": billing_project.pk, + "name": "test-workspace", + # Default workspace data for formset. + "workspacedata-TOTAL_FORMS": 1, + "workspacedata-INITIAL_FORMS": 0, + "workspacedata-MIN_NUM_FORMS": 1, + "workspacedata-MAX_NUM_FORMS": 1, + "workspacedata-0-test_field": "my field value", + }, + ) + self.assertEqual(response.status_code, 302) + # The workspace is created. + new_workspace = models.Workspace.objects.latest("pk") + # The test_field field was modified by the adapter. + self.assertEqual(new_workspace.testworkspacemethodsdata.test_field, "FOO") + class WorkspaceUpdateTest(TestCase): def setUp(self): diff --git a/anvil_consortium_manager/views.py b/anvil_consortium_manager/views.py index a1f6376b..66bfc190 100644 --- a/anvil_consortium_manager/views.py +++ b/anvil_consortium_manager/views.py @@ -1060,11 +1060,13 @@ def form_valid(self, form): transaction.set_rollback(True) return self.forms_invalid(form, workspace_data_formset) # Now save the auth domains and the workspace_data_form. - for auth_domain in form.cleaned_data["authorization_domains"]: + for auth_domain in form.cleaned_data.get("authorization_domains", []): models.WorkspaceAuthorizationDomain.objects.create(workspace=self.workspace, group=auth_domain) workspace_data_formset.forms[0].save() - # Then create the workspace on AnVIL. + # Then create the workspace on AnVIL, running custom adapter methods as appropriate. + self.adapter.before_anvil_create(self.workspace) self.workspace.anvil_create() + self.adapter.after_anvil_create(self.workspace) except AnVILAPIError as e: # If the API call failed, rerender the page with the responses and show a message. messages.add_message(self.request, messages.ERROR, "AnVIL API Error: " + str(e)) @@ -1202,6 +1204,7 @@ def form_valid(self, form): transaction.set_rollback(True) return self.forms_invalid(form, workspace_data_formset) workspace_data_formset.forms[0].save() + self.adapter.after_anvil_import(self.workspace) except anvil_api.AnVILAPIError as e: messages.add_message(self.request, messages.ERROR, "AnVIL API Error: " + str(e)) return self.render_to_response(self.get_context_data(form=form)) @@ -1325,16 +1328,18 @@ def form_valid(self, form): transaction.set_rollback(True) return self.forms_invalid(form, workspace_data_formset) # Now save the auth domains and the workspace_data_form. - for auth_domain in form.cleaned_data["authorization_domains"]: + for auth_domain in form.cleaned_data.get("authorization_domains", []): models.WorkspaceAuthorizationDomain.objects.create(workspace=self.new_workspace, group=auth_domain) workspace_data_formset.forms[0].save() # Then create the workspace on AnVIL. + self.adapter.before_anvil_create(self.new_workspace) authorization_domains = self.new_workspace.authorization_domains.all() self.object.anvil_clone( self.new_workspace.billing_project, self.new_workspace.name, authorization_domains=authorization_domains, ) + self.adapter.after_anvil_create(self.new_workspace) except AnVILAPIError as e: # If the API call failed, rerender the page with the responses and show a message. messages.add_message(self.request, messages.ERROR, "AnVIL API Error: " + str(e)) diff --git a/docs/advanced.rst b/docs/advanced.rst index 088ea771..e165ed3e 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -90,6 +90,9 @@ You may also override default settings and methods: - ``get_autocomplete_queryset``: a method to filter a workspace queryset for use in the :class:`~anvil_consortium_manager.views.WorkspaceAutocompleteByType` view. This queryset passed to this method is the workspace data model specified by the adapter, not the `Workspace` model. - ``get_extra_detail_context_data``: a method to add extra context data to the :class:`~anvil_consortium_manager.views.WorkspaceDetail` view. This method is passed the `Workspace` model, not the workspace data model specified by the adapter. +- ``before_anvil_create``: a method to perform any actions before creating a workspace on AnVIL via the :class:`~anvil_consortium_manager.views.WorkspaceCreate` view. +- ``after_anvil_create``: a method to perform any actions after creating a workspace on AnVIL via the :class:`~anvil_consortium_manager.views.WorkspaceCreate` view. +- ``after_anvil_import``: a method to perform any actions after importing a workspace from AnVIL via the :class:`~anvil_consortium_manager.views.WorkspaceImport` view. Here is example of the custom adapter for ``my_app`` with the model, form and table defined above. diff --git a/example_site/app/adapters.py b/example_site/app/adapters.py index bc3d3e45..6faa5a25 100644 --- a/example_site/app/adapters.py +++ b/example_site/app/adapters.py @@ -1,4 +1,5 @@ from anvil_consortium_manager.adapters.workspace import BaseWorkspaceAdapter +from anvil_consortium_manager.models import ManagedGroup from . import forms, models, tables @@ -15,3 +16,12 @@ class CustomWorkspaceAdapter(BaseWorkspaceAdapter): workspace_data_model = models.CustomWorkspaceData workspace_data_form_class = forms.CustomWorkspaceDataForm workspace_detail_template_name = "app/custom_workspace_detail.html" + + def before_anvil_create(self, workspace): + """Add authorization domain to workspace.""" + auth_domain_name = "AUTH_" + workspace.name + auth_domain = ManagedGroup.objects.create( + name=auth_domain_name, is_managed_by_app=True, email=auth_domain_name + "@firecloud.org" + ) + workspace.authorization_domains.add(auth_domain) + auth_domain.anvil_create() diff --git a/example_site/app/forms.py b/example_site/app/forms.py index fd366313..3275c435 100644 --- a/example_site/app/forms.py +++ b/example_site/app/forms.py @@ -9,11 +9,21 @@ class CustomWorkspaceForm(WorkspaceForm): - """Example custom form for creating a Workspace.""" + """Example custom form for creating a Workspace. + + This form has a custom clean method that does not allow a "y" in the workspace name. + It also disables the authorization_domains field.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["authorization_domains"].disabled = True class Meta(WorkspaceForm.Meta): help_texts = { "name": "Enter the name of the workspace to create. (Hint: Example workspace names cannot include a 'y'.)", + "authorization_domains": ( + "An authorization domain will be automatically created " "using the name of the workspace." + ), } def clean_name(self):