diff --git a/CHANGELOG.md b/CHANGELOG.md index f29b39da..cf95383e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Change log +## Devel + +- Add ability to clone a workspace on AnVIL. + ## 0.6 (2022-11-29) - Add ability to update `BillingProject`, `Account`, `ManagedGroup`, and `Workspace`, and workspace data models. diff --git a/anvil_consortium_manager/__init__.py b/anvil_consortium_manager/__init__.py index 4f20502e..1d811ce5 100644 --- a/anvil_consortium_manager/__init__.py +++ b/anvil_consortium_manager/__init__.py @@ -1 +1 @@ -__version__ = "0.6" +__version__ = "0.7dev1" diff --git a/anvil_consortium_manager/anvil_api.py b/anvil_consortium_manager/anvil_api.py index 68c68b04..65f2caf3 100644 --- a/anvil_consortium_manager/anvil_api.py +++ b/anvil_consortium_manager/anvil_api.py @@ -241,11 +241,56 @@ def create_workspace( "name": workspace_name, "attributes": {}, } + if not isinstance(authorization_domains, list): + raise ValueError("authorization_domains must be a list.") + + # Add authorization domains. + if authorization_domains: + auth_domain = [{"membersGroupName": g} for g in authorization_domains] + body["authorizationDomain"] = auth_domain + + return self.auth_session.post(method, 201, json=body) + + def clone_workspace( + self, + existing_workspace_namespace, + existing_workspace_name, + cloned_workspace_namespace, + cloned_workspace_name, + authorization_domains=[], + ): + """Clone an existing workspace on AnVIL. + + Calls the /api/create_workspace POST method. + + Args: + existing_workspace_namespace (str): The namespace (or billing project) of the + existing workspace to clone. + existing_workspace_name (str): The name of the existing workspace to clone. + cloned_workspace_namespace (str): The namespace (or billing project) in which + to create the cloned workspace. + cloned_workspace_name (str): The name of the cloned workspace to create. + authorization_domains (str): If desired, a list of group names that should be + used as the authorization domain for this workspace. This must include the + authorization domains of the existing workspace. + + Returns: + requests.Response + """ + method = "api/workspaces/{namespace}/{name}/clone".format( + namespace=existing_workspace_namespace, + name=existing_workspace_name, + ) + body = { + "namespace": cloned_workspace_namespace, + "name": cloned_workspace_name, + "attributes": {}, + } + if not isinstance(authorization_domains, list): + raise ValueError("authorization_domains must be a list.") # Add authorization domains. if authorization_domains: - if not isinstance(authorization_domains, list): - authorization_domains = [authorization_domains] auth_domain = [{"membersGroupName": g} for g in authorization_domains] body["authorizationDomain"] = auth_domain diff --git a/anvil_consortium_manager/forms.py b/anvil_consortium_manager/forms.py index f6af0cc8..239d6393 100644 --- a/anvil_consortium_manager/forms.py +++ b/anvil_consortium_manager/forms.py @@ -182,6 +182,101 @@ def __init__(self, workspace_choices=[], *args, **kwargs): ) +class WorkspaceCloneForm(forms.ModelForm): + """Form to create a new workspace on AnVIL by cloning an existing workspace.""" + + # Only allow billing groups where we can create a workspace. + billing_project = forms.ModelChoiceField( + queryset=models.BillingProject.objects.filter(has_app_as_user=True), + widget=autocomplete.ModelSelect2( + url="anvil_consortium_manager:billing_projects:autocomplete", + attrs={"data-theme": "bootstrap-5"}, + ), + help_text="""Select the billing project in which the workspace should be created. + Only billing projects where this app is a user are shown.""", + ) + + class Meta: + model = models.Workspace + fields = ( + "billing_project", + "name", + "authorization_domains", + "note", + ) + widgets = { + "billing_project": autocomplete.ModelSelect2( + url="anvil_consortium_manager:billing_projects:autocomplete", + attrs={"data-theme": "bootstrap-5"}, + ), + "authorization_domains": autocomplete.ModelSelect2Multiple( + url="anvil_consortium_manager:managed_groups:autocomplete", + attrs={"data-theme": "bootstrap-5"}, + ), + } + help_texts = { + "billing_project": """Enter the billing project in which the workspace should be created. + Only billing projects that have this app as a user are shown.""", + "name": "Enter the name of the workspace to create.", + "authorization_domains": """Select the authorization domain(s) to use for this workspace. + This cannot be changed after creation.""", + } + + def __init__(self, workspace_to_clone, *args, **kwargs): + super().__init__(*args, **kwargs) + # Save this so we can modify the authorization domains to include this workspace's auth domain(s). + self.workspace_to_clone = workspace_to_clone + # Add the list of required auth domains to the help text. + if self.workspace_to_clone.authorization_domains.exists(): + auth_domain_names = ( + self.workspace_to_clone.authorization_domains.values_list( + "name", flat=True + ) + ) + print(auth_domain_names) + extra_text = " You must also include the authorization domain(s) from the original workspace ({}).".format( + ", ".join(auth_domain_names) + ) + self.fields["authorization_domains"].help_text = ( + self.fields["authorization_domains"].help_text + extra_text + ) + + def clean_authorization_domains(self): + """Verify that all authorization domains from the original workspace are selected.""" + authorization_domains = self.cleaned_data["authorization_domains"] + required_authorization_domains = ( + self.workspace_to_clone.authorization_domains.all() + ) + missing = [ + g for g in required_authorization_domains if g not in authorization_domains + ] + if missing: + msg = "Must contain all original workspace authorization domains: {}".format( + # ", ".join([g.name for g in self.workspace_to_clone.authorization_domains.all()]) + ", ".join(required_authorization_domains.values_list("name", flat=True)) + ) + raise ValidationError(msg) + return authorization_domains + + def clean(self): + # Check for the same case insensitive name in the same billing project. + billing_project = self.cleaned_data.get("billing_project", None) + name = self.cleaned_data.get("name", None) + if ( + billing_project + and name + and models.Workspace.objects.filter( + billing_project=billing_project, + name__iexact=name, + ).exists() + ): + # The workspace already exists - raise a Validation error. + raise ValidationError( + "Workspace with this Billing Project and Name already exists." + ) + return self.cleaned_data + + class DefaultWorkspaceDataForm(forms.ModelForm): """Default (empty) form for the workspace data object.""" diff --git a/anvil_consortium_manager/models.py b/anvil_consortium_manager/models.py index f1ccc6b4..45afe753 100644 --- a/anvil_consortium_manager/models.py +++ b/anvil_consortium_manager/models.py @@ -708,6 +708,41 @@ def anvil_delete(self): """Delete the workspace on AnVIL.""" AnVILAPIClient().delete_workspace(self.billing_project.name, self.name) + def anvil_clone(self, billing_project, workspace_name, authorization_domains=[]): + """Clone this workspace to create a new workspace on AnVIL. + + If the workspace to clone already has authorization domains, they will be added to + the authorization domains specified in `authorization_domains`.""" + # Check that the app can create workspaes in this billing project. + if not billing_project.has_app_as_user: + raise ValueError("BillingProject must have has_app_as_user=True.") + # Do not check if the new workspace already exists in the app. + # It may have already been created for some reason. + # if Workspace.objects.filter( + # billing_project=billing_project, name=workspace_name + # ).exists(): + # raise ValueError( + # "Workspace with this BillingProject and Name already exists." + # ) + # All checks have passed, so start the cloning process. + # Set up new auth domains using: + # - existing auth domains for the workspace being cloned + # - new auth domains that are specified when cloning. + current_auth_domains = self.authorization_domains.all() + auth_domains = [g.name for g in current_auth_domains] + [ + g.name for g in authorization_domains if g not in current_auth_domains + ] + # Clone the workspace on AnVIL. + AnVILAPIClient().clone_workspace( + self.billing_project.name, + self.name, + billing_project.name, + workspace_name, + authorization_domains=auth_domains, + ) + # Do not create the cloned workspace - it can be imported or created elsewhere. + # That way, the workspace_type can be set. + @classmethod def anvil_import( cls, billing_project_name, workspace_name, workspace_type, note="" diff --git a/anvil_consortium_manager/templates/anvil_consortium_manager/base.html b/anvil_consortium_manager/templates/anvil_consortium_manager/base.html index 1294dffb..899993a3 100644 --- a/anvil_consortium_manager/templates/anvil_consortium_manager/base.html +++ b/anvil_consortium_manager/templates/anvil_consortium_manager/base.html @@ -11,6 +11,8 @@ + + {% block css %} diff --git a/anvil_consortium_manager/templates/anvil_consortium_manager/workspace_clone.html b/anvil_consortium_manager/templates/anvil_consortium_manager/workspace_clone.html new file mode 100644 index 00000000..7ee3af36 --- /dev/null +++ b/anvil_consortium_manager/templates/anvil_consortium_manager/workspace_clone.html @@ -0,0 +1,23 @@ +{% extends "anvil_consortium_manager/base.html" %} +{% load static %} +{% load crispy_forms_tags %} + +{% block title %}Clone {{ object }}{% endblock title %} + +{% block content %} + +

Clone {{ object }} on AnVIL

+ +
+ + {% csrf_token %} + {{ workspace_data_formset|crispy }} + {{ form|crispy }} + + +
+{% endblock %} + +{% block inline_javascript %} + {{ form.media }} +{% endblock inline_javascript %} diff --git a/anvil_consortium_manager/templates/anvil_consortium_manager/workspace_detail.html b/anvil_consortium_manager/templates/anvil_consortium_manager/workspace_detail.html index ca8538de..e997fd9a 100644 --- a/anvil_consortium_manager/templates/anvil_consortium_manager/workspace_detail.html +++ b/anvil_consortium_manager/templates/anvil_consortium_manager/workspace_detail.html @@ -29,6 +29,7 @@ {% endblock panel %} {% block after_panel %} +
@@ -68,6 +69,21 @@

Share with a group

+

+

+

+

Update Delete on AnVIL diff --git a/anvil_consortium_manager/tests/test_forms.py b/anvil_consortium_manager/tests/test_forms.py index 1a7718ce..7ba7968e 100644 --- a/anvil_consortium_manager/tests/test_forms.py +++ b/anvil_consortium_manager/tests/test_forms.py @@ -461,6 +461,222 @@ def test_invalid_missing_workspace(self): self.assertEqual(len(form.errors), 1) +class WorkspaceCloneFormTest(TestCase): + """Tests for the WorkspaceCloneForm.""" + + form_class = forms.WorkspaceCloneForm + + def setUp(self): + """Create a workspace to clone for use in tests.""" + self.workspace_to_clone = factories.WorkspaceFactory.create() + + def test_valid_no_required_auth_domains(self): + """Form is valid with a workspace to clone with no auth domains, and no auth domains selected.""" + billing_project = factories.BillingProjectFactory.create() + form_data = { + "billing_project": billing_project, + "name": "test-workspace", + "authorization_domains": [], + } + form = self.form_class(self.workspace_to_clone, data=form_data) + self.assertTrue(form.is_valid()) + + def test_valid_no_required_auth_domains_with_one_selected_auth_domain(self): + """Form is valid with a workspace to clone with no auth domains, and one auth domain selected.""" + billing_project = factories.BillingProjectFactory.create() + new_auth_domain = factories.ManagedGroupFactory.create() + form_data = { + "billing_project": billing_project, + "name": "test-workspace", + "authorization_domains": [new_auth_domain], + } + form = self.form_class(self.workspace_to_clone, data=form_data) + self.assertTrue(form.is_valid()) + + def test_valid_no_required_auth_domains_with_two_selected_auth_domains(self): + """Form is valid with a workspace to clone with no auth domains, and two auth domains selected.""" + billing_project = factories.BillingProjectFactory.create() + new_auth_domain_1 = factories.ManagedGroupFactory.create() + new_auth_domain_2 = factories.ManagedGroupFactory.create() + form_data = { + "billing_project": billing_project, + "name": "test-workspace", + "authorization_domains": [new_auth_domain_1, new_auth_domain_2], + } + form = self.form_class(self.workspace_to_clone, data=form_data) + self.assertTrue(form.is_valid()) + + def test_valid_one_required_auth_domains(self): + """Form is valid with a workspace to clone with one auth domain, and that auth domain selected.""" + auth_domain = factories.ManagedGroupFactory.create() + self.workspace_to_clone.authorization_domains.add(auth_domain) + billing_project = factories.BillingProjectFactory.create() + form_data = { + "billing_project": billing_project, + "name": "test-workspace", + "authorization_domains": [auth_domain], + } + form = self.form_class(self.workspace_to_clone, data=form_data) + self.assertTrue(form.is_valid()) + + def test_invalid_one_required_auth_domains_no_auth_domains_selected(self): + """Form is not valid when no auth domains are selected but workspace to clone has one auth domain.""" + auth_domain = factories.ManagedGroupFactory.create() + self.workspace_to_clone.authorization_domains.add(auth_domain) + billing_project = factories.BillingProjectFactory.create() + form_data = { + "billing_project": billing_project, + "name": "test-workspace", + "authorization_domains": [], + } + form = self.form_class(self.workspace_to_clone, data=form_data) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertIn("authorization_domains", form.errors) + self.assertEqual(len(form.errors["authorization_domains"]), 1) + self.assertIn( + "contain all original workspace authorization domains", + form.errors["authorization_domains"][0], + ) + self.assertIn(auth_domain.name, form.errors["authorization_domains"][0]) + + def test_invalid_one_required_auth_domains_different_auth_domains_selected(self): + """Form is not valid when no auth domains are selected but workspace to clone has one auth domain.""" + auth_domain = factories.ManagedGroupFactory.create() + self.workspace_to_clone.authorization_domains.add(auth_domain) + billing_project = factories.BillingProjectFactory.create() + other_auth_domain = factories.ManagedGroupFactory.create() + form_data = { + "billing_project": billing_project, + "name": "test-workspace", + "authorization_domains": [other_auth_domain], + } + form = self.form_class(self.workspace_to_clone, data=form_data) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertIn("authorization_domains", form.errors) + self.assertEqual(len(form.errors["authorization_domains"]), 1) + self.assertIn( + "contain all original workspace authorization domains", + form.errors["authorization_domains"][0], + ) + self.assertIn(auth_domain.name, form.errors["authorization_domains"][0]) + + def test_valid_one_required_auth_domains_with_extra_selected_auth_domain(self): + """Form is valid with a workspace to clone with one auth domains, and an extra auth domain selected.""" + auth_domain = factories.ManagedGroupFactory.create() + self.workspace_to_clone.authorization_domains.add(auth_domain) + billing_project = factories.BillingProjectFactory.create() + new_auth_domain = factories.ManagedGroupFactory.create() + form_data = { + "billing_project": billing_project, + "name": "test-workspace", + "authorization_domains": [auth_domain, new_auth_domain], + } + form = self.form_class(self.workspace_to_clone, data=form_data) + self.assertTrue(form.is_valid()) + + def test_valid_two_required_auth_domains(self): + """Form is valid with a workspace to clone with two auth domains, and both auth domains selected.""" + auth_domain_1 = factories.ManagedGroupFactory.create() + auth_domain_2 = factories.ManagedGroupFactory.create() + self.workspace_to_clone.authorization_domains.add(auth_domain_1, auth_domain_2) + billing_project = factories.BillingProjectFactory.create() + form_data = { + "billing_project": billing_project, + "name": "test-workspace", + "authorization_domains": [auth_domain_1, auth_domain_2], + } + form = self.form_class(self.workspace_to_clone, data=form_data) + self.assertTrue(form.is_valid()) + + def test_invalid_two_required_auth_domains_no_auth_domains_selected(self): + """Form is not valid when no auth domains are selected but workspace to clone has two auth domain.""" + auth_domain_1 = factories.ManagedGroupFactory.create() + auth_domain_2 = factories.ManagedGroupFactory.create() + self.workspace_to_clone.authorization_domains.add(auth_domain_1, auth_domain_2) + billing_project = factories.BillingProjectFactory.create() + form_data = { + "billing_project": billing_project, + "name": "test-workspace", + "authorization_domains": [], + } + form = self.form_class(self.workspace_to_clone, data=form_data) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertIn("authorization_domains", form.errors) + self.assertEqual(len(form.errors["authorization_domains"]), 1) + self.assertIn( + "contain all original workspace authorization domains", + form.errors["authorization_domains"][0], + ) + self.assertIn(auth_domain_1.name, form.errors["authorization_domains"][0]) + self.assertIn(auth_domain_2.name, form.errors["authorization_domains"][0]) + + def test_invalid_two_required_auth_domains_one_auth_domain_selected(self): + """Form is not valid when no auth domains are selected but workspace to clone has two auth domain.""" + auth_domain_1 = factories.ManagedGroupFactory.create() + auth_domain_2 = factories.ManagedGroupFactory.create() + self.workspace_to_clone.authorization_domains.add(auth_domain_1, auth_domain_2) + billing_project = factories.BillingProjectFactory.create() + form_data = { + "billing_project": billing_project, + "name": "test-workspace", + "authorization_domains": [auth_domain_1], + } + form = self.form_class(self.workspace_to_clone, data=form_data) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertIn("authorization_domains", form.errors) + self.assertEqual(len(form.errors["authorization_domains"]), 1) + self.assertIn( + "contain all original workspace authorization domains", + form.errors["authorization_domains"][0], + ) + self.assertIn(auth_domain_1.name, form.errors["authorization_domains"][0]) + self.assertIn(auth_domain_2.name, form.errors["authorization_domains"][0]) + + def test_invalid_two_required_auth_domains_different_auth_domains_selected(self): + """Form is not valid when different auth domains are selected but workspace to clone has two auth domains.""" + auth_domain_1 = factories.ManagedGroupFactory.create() + auth_domain_2 = factories.ManagedGroupFactory.create() + self.workspace_to_clone.authorization_domains.add(auth_domain_1, auth_domain_2) + billing_project = factories.BillingProjectFactory.create() + other_auth_domain_1 = factories.ManagedGroupFactory.create() + other_auth_domain_2 = factories.ManagedGroupFactory.create() + form_data = { + "billing_project": billing_project, + "name": "test-workspace", + "authorization_domains": [other_auth_domain_1, other_auth_domain_2], + } + form = self.form_class(self.workspace_to_clone, data=form_data) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertIn("authorization_domains", form.errors) + self.assertEqual(len(form.errors["authorization_domains"]), 1) + self.assertIn( + "contain all original workspace authorization domains", + form.errors["authorization_domains"][0], + ) + self.assertIn(auth_domain_1.name, form.errors["authorization_domains"][0]) + self.assertIn(auth_domain_2.name, form.errors["authorization_domains"][0]) + + def test_valid_two_required_auth_domains_with_extra_selected_auth_domain(self): + """Form is valid with a workspace to clone with one auth domains, and an extra auth domain selected.""" + auth_domain_1 = factories.ManagedGroupFactory.create() + auth_domain_2 = factories.ManagedGroupFactory.create() + self.workspace_to_clone.authorization_domains.add(auth_domain_1, auth_domain_2) + billing_project = factories.BillingProjectFactory.create() + new_auth_domain = factories.ManagedGroupFactory.create() + form_data = { + "billing_project": billing_project, + "name": "test-workspace", + "authorization_domains": [auth_domain_1, auth_domain_2, new_auth_domain], + } + form = self.form_class(self.workspace_to_clone, data=form_data) + self.assertTrue(form.is_valid()) + + class GroupGroupMembershipFormTest(TestCase): form_class = forms.GroupGroupMembershipForm diff --git a/anvil_consortium_manager/tests/test_models_anvil_api_unit.py b/anvil_consortium_manager/tests/test_models_anvil_api_unit.py index 2706829f..8d1c1c11 100644 --- a/anvil_consortium_manager/tests/test_models_anvil_api_unit.py +++ b/anvil_consortium_manager/tests/test_models_anvil_api_unit.py @@ -2310,6 +2310,380 @@ def test_anvil_delete_other(self): responses.assert_call_count(self.url_workspace, 1) +class WorkspaceAnVILCloneTest(AnVILAPIMockTestMixin, TestCase): + """Tests of the Workspace.anvil_clone method.""" + + def setUp(self): + super().setUp() + self.workspace = factories.WorkspaceFactory.create() + + def get_api_url(self, billing_project_name, workspace_name): + return ( + self.entry_point + + "/api/workspaces/" + + billing_project_name + + "/" + + workspace_name + + "/clone" + ) + + def get_api_json_response(self, billing_project_name, workspace_name): + """Return a pared down version of the json response from the AnVIL API with only fields we need.""" + json_data = { + "name": workspace_name, + "namespace": billing_project_name, + } + return json_data + + def test_can_clone_workspace_no_auth_domain(self): + """Can clone a workspace with no auth domains.""" + billing_project = factories.BillingProjectFactory.create() + # Add response. + responses.add( + responses.POST, + self.get_api_url(self.workspace.billing_project.name, self.workspace.name), + status=201, # successful response code. + json=self.get_api_json_response(billing_project.name, "test-workspace"), + match=[ + responses.matchers.json_params_matcher( + { + "namespace": billing_project.name, + "name": "test-workspace", + "attributes": {}, + } + ) + ], + ) + self.workspace.anvil_clone(billing_project, "test-workspace") + # No new workspaces were created in the app. + self.assertEqual(models.Workspace.objects.count(), 1) + self.assertIn(self.workspace, models.Workspace.objects.all()) + + def test_can_clone_workspace_one_auth_domain(self): + """Can clone a workspace with one auth domain.""" + auth_domain = factories.ManagedGroupFactory.create() + self.workspace.authorization_domains.add(auth_domain) + billing_project = factories.BillingProjectFactory.create() + # Add response. + responses.add( + responses.POST, + self.get_api_url(self.workspace.billing_project.name, self.workspace.name), + status=201, # successful response code. + json=self.get_api_json_response(billing_project.name, "test-workspace"), + match=[ + responses.matchers.json_params_matcher( + { + "namespace": billing_project.name, + "name": "test-workspace", + "attributes": {}, + "authorizationDomain": [{"membersGroupName": auth_domain.name}], + } + ) + ], + ) + self.workspace.anvil_clone(billing_project, "test-workspace") + + def test_can_clone_workspace_two_auth_domains(self): + """Can clone a workspace with two auth domains.""" + auth_domain_1 = factories.ManagedGroupFactory.create() + auth_domain_2 = factories.ManagedGroupFactory.create() + self.workspace.authorization_domains.add(auth_domain_1, auth_domain_2) + billing_project = factories.BillingProjectFactory.create() + # Add response. + responses.add( + responses.POST, + self.get_api_url(self.workspace.billing_project.name, self.workspace.name), + status=201, # successful response code. + json=self.get_api_json_response(billing_project.name, "test-workspace"), + match=[ + responses.matchers.json_params_matcher( + { + "namespace": billing_project.name, + "name": "test-workspace", + "attributes": {}, + "authorizationDomain": [ + {"membersGroupName": auth_domain_1.name}, + {"membersGroupName": auth_domain_2.name}, + ], + } + ) + ], + ) + self.workspace.anvil_clone(billing_project, "test-workspace") + + def test_can_clone_workspace_add_one_auth_domain(self): + """Can clone a workspace and add one auth domain.""" + auth_domain = factories.ManagedGroupFactory.create() + billing_project = factories.BillingProjectFactory.create() + # Add response. + responses.add( + responses.POST, + self.get_api_url(self.workspace.billing_project.name, self.workspace.name), + status=201, # successful response code. + json=self.get_api_json_response(billing_project.name, "test-workspace"), + match=[ + responses.matchers.json_params_matcher( + { + "namespace": billing_project.name, + "name": "test-workspace", + "attributes": {}, + "authorizationDomain": [{"membersGroupName": auth_domain.name}], + } + ) + ], + ) + # import ipdb; ipdb.set_trace() + self.workspace.anvil_clone( + billing_project, + "test-workspace", + authorization_domains=[auth_domain], + ) + + def test_can_clone_workspace_add_two_auth_domains(self): + """Can clone a workspace and add one auth domain.""" + auth_domain_1 = factories.ManagedGroupFactory.create() + auth_domain_2 = factories.ManagedGroupFactory.create() + billing_project = factories.BillingProjectFactory.create() + # Add response. + responses.add( + responses.POST, + self.get_api_url(self.workspace.billing_project.name, self.workspace.name), + status=201, # successful response code. + json=self.get_api_json_response(billing_project.name, "test-workspace"), + match=[ + responses.matchers.json_params_matcher( + { + "namespace": billing_project.name, + "name": "test-workspace", + "attributes": {}, + "authorizationDomain": [ + {"membersGroupName": auth_domain_1.name}, + {"membersGroupName": auth_domain_2.name}, + ], + } + ) + ], + ) + self.workspace.anvil_clone( + billing_project, + "test-workspace", + authorization_domains=[auth_domain_1, auth_domain_2], + ) + + def test_can_clone_workspace_one_auth_domain_add_one_auth_domain(self): + """Can clone a workspace with one auth domain and add another auth domain.""" + auth_domain = factories.ManagedGroupFactory.create() + self.workspace.authorization_domains.add(auth_domain) + new_auth_domain = factories.ManagedGroupFactory.create() + billing_project = factories.BillingProjectFactory.create() + # Add response. + responses.add( + responses.POST, + self.get_api_url(self.workspace.billing_project.name, self.workspace.name), + status=201, # successful response code. + json=self.get_api_json_response(billing_project.name, "test-workspace"), + match=[ + responses.matchers.json_params_matcher( + { + "namespace": billing_project.name, + "name": "test-workspace", + "attributes": {}, + "authorizationDomain": [ + {"membersGroupName": auth_domain.name}, + {"membersGroupName": new_auth_domain.name}, + ], + } + ) + ], + ) + self.workspace.anvil_clone( + billing_project, + "test-workspace", + authorization_domains=[new_auth_domain], + ) + + def test_can_clone_workspace_one_auth_domain_add_two_auth_domains(self): + """Can clone a workspace with one auth domain and add another auth domain.""" + auth_domain = factories.ManagedGroupFactory.create() + self.workspace.authorization_domains.add(auth_domain) + new_auth_domain_1 = factories.ManagedGroupFactory.create() + new_auth_domain_2 = factories.ManagedGroupFactory.create() + billing_project = factories.BillingProjectFactory.create() + # Add response. + responses.add( + responses.POST, + self.get_api_url(self.workspace.billing_project.name, self.workspace.name), + status=201, # successful response code. + json=self.get_api_json_response(billing_project.name, "test-workspace"), + match=[ + responses.matchers.json_params_matcher( + { + "namespace": billing_project.name, + "name": "test-workspace", + "attributes": {}, + "authorizationDomain": [ + {"membersGroupName": auth_domain.name}, + {"membersGroupName": new_auth_domain_1.name}, + {"membersGroupName": new_auth_domain_2.name}, + ], + } + ) + ], + ) + self.workspace.anvil_clone( + billing_project, + "test-workspace", + authorization_domains=[new_auth_domain_1, new_auth_domain_2], + ) + + def test_can_clone_workspace_one_auth_domain_add_same_auth_domain(self): + """Can clone a workspace with one auth domain and add the same auth domain.""" + auth_domain = factories.ManagedGroupFactory.create() + self.workspace.authorization_domains.add(auth_domain) + billing_project = factories.BillingProjectFactory.create() + # Add response. + responses.add( + responses.POST, + self.get_api_url(self.workspace.billing_project.name, self.workspace.name), + status=201, # successful response code. + json=self.get_api_json_response(billing_project.name, "test-workspace"), + match=[ + responses.matchers.json_params_matcher( + { + "namespace": billing_project.name, + "name": "test-workspace", + "attributes": {}, + "authorizationDomain": [{"membersGroupName": auth_domain.name}], + } + ) + ], + ) + self.workspace.anvil_clone( + billing_project, + "test-workspace", + authorization_domains=[auth_domain], + ) + + def test_error_cloning_workspace_into_billing_project_where_app_is_not_user(self): + """Error when cloning a workspace into a billing project where the app is not a user.""" + billing_project = factories.BillingProjectFactory.create(has_app_as_user=False) + # No API call exected. + with self.assertRaises(ValueError) as e: + self.workspace.anvil_clone(billing_project, "test-workspace") + self.assertIn("has_app_as_user", str(e.exception)) + # No new workspace was created. + self.assertEqual(models.Workspace.objects.count(), 1) + self.assertIn(self.workspace, models.Workspace.objects.all()) + + def test_error_workspace_already_exists_in_anvil_but_not_in_app(self): + """Error when the workspace to be cloned already exists in AnVIL but is not in the app.""" + billing_project = factories.BillingProjectFactory.create() + # Add response. + responses.add( + responses.POST, + self.get_api_url(self.workspace.billing_project.name, self.workspace.name), + status=409, # already exists + json={"message": "other"}, + match=[ + responses.matchers.json_params_matcher( + { + "namespace": billing_project.name, + "name": "test-workspace", + "attributes": {}, + } + ) + ], + ) + with self.assertRaises(anvil_api.AnVILAPIError409): + self.workspace.anvil_clone(billing_project, "test-workspace") + # No new workspace was created. + self.assertEqual(models.Workspace.objects.count(), 1) + self.assertIn(self.workspace, models.Workspace.objects.all()) + + def test_error_workspace_does_not_exist_in_anvil(self): + """Error the workspace to clone does not exist on AnVIL.""" + billing_project = factories.BillingProjectFactory.create() + # Add response. + responses.add( + responses.POST, + self.get_api_url(self.workspace.billing_project.name, self.workspace.name), + status=404, # already exists + json={"message": "other"}, + match=[ + responses.matchers.json_params_matcher( + { + "namespace": billing_project.name, + "name": "test-workspace", + "attributes": {}, + } + ) + ], + ) + with self.assertRaises(anvil_api.AnVILAPIError404): + self.workspace.anvil_clone(billing_project, "test-workspace") + # No new workspace was created. + self.assertEqual(models.Workspace.objects.count(), 1) + self.assertIn(self.workspace, models.Workspace.objects.all()) + + def test_error_authorization_domain_exists_in_app_but_not_on_anvil(self): + """Error when the authorization domain exists in the app but not on AnVIL.""" + billing_project = factories.BillingProjectFactory.create() + new_auth_domain = factories.ManagedGroupFactory.create() + # Add response. + responses.add( + responses.POST, + self.get_api_url(self.workspace.billing_project.name, self.workspace.name), + status=404, # resource not found + json={"message": "other"}, + match=[ + responses.matchers.json_params_matcher( + { + "namespace": billing_project.name, + "name": "test-workspace", + "attributes": {}, + "authorizationDomain": [ + {"membersGroupName": new_auth_domain.name} + ], + } + ) + ], + ) + with self.assertRaises(anvil_api.AnVILAPIError404): + self.workspace.anvil_clone( + billing_project, + "test-workspace", + authorization_domains=[new_auth_domain], + ) + # No new workspace was created. + self.assertEqual(models.Workspace.objects.count(), 1) + self.assertIn(self.workspace, models.Workspace.objects.all()) + + def test_other_api_error(self): + """Error when the response is an error for an unknown reason.""" + billing_project = factories.BillingProjectFactory.create() + # Add response. + responses.add( + responses.POST, + self.get_api_url(self.workspace.billing_project.name, self.workspace.name), + status=500, # other + json={"message": "other"}, + match=[ + responses.matchers.json_params_matcher( + { + "namespace": billing_project.name, + "name": "test-workspace", + "attributes": {}, + } + ) + ], + ) + with self.assertRaises(anvil_api.AnVILAPIError500): + self.workspace.anvil_clone(billing_project, "test-workspace") + # No new workspace was created. + self.assertEqual(models.Workspace.objects.count(), 1) + self.assertIn(self.workspace, models.Workspace.objects.all()) + + class WorkspaceAnVILImportAnVILAPIMockTest(AnVILAPIMockTestMixin, TestCase): """Tests for the Workspace.anvil_import method.""" diff --git a/anvil_consortium_manager/tests/test_views.py b/anvil_consortium_manager/tests/test_views.py index 2c572cab..9a70011a 100644 --- a/anvil_consortium_manager/tests/test_views.py +++ b/anvil_consortium_manager/tests/test_views.py @@ -8073,6 +8073,1114 @@ def test_adapter_does_not_create_objects_if_workspace_data_form_invalid(self): self.assertEqual(len(responses.calls), 2) +class WorkspaceCloneTest(AnVILAPIMockTestMixin, TestCase): + """Tests for the WorkspaceClone view.""" + + api_success_code = 201 + + def setUp(self): + """Set up test class.""" + # The superclass uses the responses package to mock API responses. + super().setUp() + self.factory = RequestFactory() + # Create a user with both view and edit permissions. + self.user = User.objects.create_user(username="test", password="test") + self.user.user_permissions.add( + Permission.objects.get( + codename=models.AnVILProjectManagerAccess.VIEW_PERMISSION_CODENAME + ) + ) + self.user.user_permissions.add( + Permission.objects.get( + codename=models.AnVILProjectManagerAccess.EDIT_PERMISSION_CODENAME + ) + ) + self.workspace_to_clone = factories.WorkspaceFactory.create() + self.api_url = self.entry_point + "/api/workspaces/{}/{}/clone".format( + self.workspace_to_clone.billing_project.name, + self.workspace_to_clone.name, + ) + self.workspace_type = DefaultWorkspaceAdapter.type + + def tearDown(self): + """Clean up after tests.""" + # Unregister all adapters. + workspace_adapter_registry._registry = {} + # Register the default adapter. + workspace_adapter_registry.register(DefaultWorkspaceAdapter) + super().tearDown() + + def get_url(self, *args): + """Get the url for the view being tested.""" + return reverse("anvil_consortium_manager:workspaces:clone", args=args) + + def get_view(self): + """Return the view being tested.""" + return views.WorkspaceClone.as_view() + + def test_view_redirect_not_logged_in(self): + "View redirects to login view when user is not logged in." + response = self.client.get( + self.get_url( + self.workspace_to_clone.billing_project.name, + self.workspace_to_clone.name, + self.workspace_type, + ) + ) + self.assertRedirects( + response, + resolve_url(settings.LOGIN_URL) + + "?next=" + + self.get_url( + self.workspace_to_clone.billing_project.name, + self.workspace_to_clone.name, + self.workspace_type, + ), + ) + + def test_status_code_with_user_permission(self): + """Returns successful response code.""" + self.client.force_login(self.user) + response = self.client.get( + self.get_url( + self.workspace_to_clone.billing_project.name, + self.workspace_to_clone.name, + self.workspace_type, + ) + ) + self.assertEqual(response.status_code, 200) + + def test_access_with_view_permission(self): + """Raises permission denied if user has only view permission.""" + user_with_view_perm = User.objects.create_user( + username="test-other", password="test-other" + ) + user_with_view_perm.user_permissions.add( + Permission.objects.get( + codename=models.AnVILProjectManagerAccess.VIEW_PERMISSION_CODENAME + ) + ) + request = self.factory.get( + self.get_url( + self.workspace_to_clone.billing_project.name, + self.workspace_to_clone.name, + self.workspace_type, + ) + ) + request.user = user_with_view_perm + with self.assertRaises(PermissionDenied): + self.get_view()( + request, + billing_project_slug=self.workspace_to_clone.billing_project.name, + workspace_slug=self.workspace_to_clone.name, + workspace_type=self.workspace_type, + ) + + def test_access_without_user_permission(self): + """Raises permission denied if user has no permissions.""" + user_no_perms = User.objects.create_user( + username="test-none", password="test-none" + ) + request = self.factory.get( + self.get_url( + self.workspace_to_clone.billing_project.name, + self.workspace_to_clone.name, + self.workspace_type, + ) + ) + request.user = user_no_perms + with self.assertRaises(PermissionDenied): + self.get_view()( + request, + billing_project_slug=self.workspace_to_clone.billing_project.name, + workspace_slug=self.workspace_to_clone.name, + workspace_type=self.workspace_type, + ) + + def test_get_workspace_type_not_registered(self): + """Raises 404 with get request if workspace type is not registered with adapter.""" + request = self.factory.get( + self.get_url( + self.workspace_to_clone.billing_project.name, + self.workspace_to_clone.name, + "foo", + ) + ) + request.user = self.user + with self.assertRaises(Http404): + self.get_view()( + request, + billing_project_slug=self.workspace_to_clone.billing_project.name, + workspace_slug=self.workspace_to_clone.name, + workspace_type="foo", + ) + + def test_post_workspace_type_not_registered(self): + """Raises 404 with post request if workspace type is not registered with adapter.""" + request = self.factory.post( + self.get_url( + self.workspace_to_clone.billing_project.name, + self.workspace_to_clone.name, + "foo", + ), + {}, + ) + request.user = self.user + with self.assertRaises(Http404): + self.get_view()( + request, + billing_project_slug=self.workspace_to_clone.billing_project.name, + workspace_slug=self.workspace_to_clone.name, + workspace_type="foo", + ) + + def test_get_workspace_not_found(self): + """Raises a 404 error when workspace does not exist.""" + print(self.get_url("foo", "bar", self.workspace_type)) + request = self.factory.get(self.get_url("foo", "bar", self.workspace_type)) + request.user = self.user + with self.assertRaises(Http404): + self.get_view()( + request, + billing_project_slug="foo", + workspace_slug="bar", + workspace_type=self.workspace_type, + ) + + def test_has_form_in_context(self): + """Response includes a form.""" + self.client.force_login(self.user) + response = self.client.get( + self.get_url( + self.workspace_to_clone.billing_project.name, + self.workspace_to_clone.name, + self.workspace_type, + ) + ) + self.assertTrue("form" in response.context_data) + self.assertIsInstance(response.context_data["form"], forms.WorkspaceCloneForm) + + def test_has_formset_in_context(self): + """Response includes a formset for the workspace_data model.""" + self.client.force_login(self.user) + response = self.client.get( + self.get_url( + self.workspace_to_clone.billing_project.name, + self.workspace_to_clone.name, + self.workspace_type, + ) + ) + self.assertTrue("workspace_data_formset" in response.context_data) + formset = response.context_data["workspace_data_formset"] + self.assertIsInstance(formset, BaseInlineFormSet) + self.assertEqual(len(formset.forms), 1) + self.assertIsInstance(formset.forms[0], forms.DefaultWorkspaceDataForm) + + def test_can_create_an_object(self): + """Posting valid data to the form creates an object.""" + billing_project = factories.BillingProjectFactory.create( + name="test-billing-project" + ) + json_data = { + "namespace": "test-billing-project", + "name": "test-workspace", + "attributes": {}, + } + responses.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, + }, + ) + self.assertEqual(response.status_code, 302) + new_object = models.Workspace.objects.latest("pk") + self.assertIsInstance(new_object, models.Workspace) + self.assertEqual( + new_object.workspace_type, + DefaultWorkspaceAdapter().get_type(), + ) + responses.assert_call_count(self.api_url, 1) + # History is added. + self.assertEqual(new_object.history.count(), 1) + self.assertEqual(new_object.history.latest().history_type, "+") + + def test_can_create_object_with_auth_domains(self): + """Posting valid data to the form creates an object.""" + auth_domain = factories.ManagedGroupFactory.create() + self.workspace_to_clone.authorization_domains.add(auth_domain) + billing_project = factories.BillingProjectFactory.create( + name="test-billing-project" + ) + json_data = { + "namespace": "test-billing-project", + "name": "test-workspace", + "attributes": {}, + "authorizationDomain": [{"membersGroupName": auth_domain.name}], + } + responses.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", + "authorization_domains": [auth_domain.pk], + # Default workspace data for formset. + "workspacedata-TOTAL_FORMS": 1, + "workspacedata-INITIAL_FORMS": 0, + "workspacedata-MIN_NUM_FORMS": 1, + "workspacedata-MAX_NUM_FORMS": 1, + }, + ) + self.assertEqual(response.status_code, 302) + new_object = models.Workspace.objects.latest("pk") + self.assertIsInstance(new_object, models.Workspace) + # Has an auth domain. + self.assertEqual(new_object.authorization_domains.count(), 1) + self.assertIn(auth_domain, new_object.authorization_domains.all()) + responses.assert_call_count(self.api_url, 1) + # History is added. + self.assertEqual(new_object.history.count(), 1) + self.assertEqual(new_object.history.latest().history_type, "+") + + def test_can_add_an_auth_domains(self): + """Posting valid data to the form creates an object.""" + auth_domain = factories.ManagedGroupFactory.create() + self.workspace_to_clone.authorization_domains.add(auth_domain) + new_auth_domain = factories.ManagedGroupFactory.create() + billing_project = factories.BillingProjectFactory.create( + name="test-billing-project" + ) + json_data = { + "namespace": "test-billing-project", + "name": "test-workspace", + "attributes": {}, + "authorizationDomain": [ + {"membersGroupName": auth_domain.name}, + {"membersGroupName": new_auth_domain.name}, + ], + } + responses.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", + "authorization_domains": [auth_domain.pk, new_auth_domain.pk], + # Default workspace data for formset. + "workspacedata-TOTAL_FORMS": 1, + "workspacedata-INITIAL_FORMS": 0, + "workspacedata-MIN_NUM_FORMS": 1, + "workspacedata-MAX_NUM_FORMS": 1, + }, + ) + self.assertEqual(response.status_code, 302) + new_object = models.Workspace.objects.latest("pk") + self.assertIsInstance(new_object, models.Workspace) + # Has an auth domain. + self.assertEqual(new_object.authorization_domains.count(), 2) + self.assertIn(auth_domain, new_object.authorization_domains.all()) + self.assertIn(new_auth_domain, new_object.authorization_domains.all()) + responses.assert_call_count(self.api_url, 1) + + def test_creates_default_workspace_data(self): + """Posting valid data to the form creates the default workspace data object.""" + billing_project = factories.BillingProjectFactory.create( + name="test-billing-project" + ) + json_data = { + "namespace": "test-billing-project", + "name": "test-workspace", + "attributes": {}, + } + responses.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, + }, + ) + self.assertEqual(response.status_code, 302) + new_workspace = models.Workspace.objects.latest("pk") + # Also creates a workspace data object. + self.assertEqual(models.DefaultWorkspaceData.objects.count(), 1) + self.assertIsInstance( + new_workspace.defaultworkspacedata, models.DefaultWorkspaceData + ) + + def test_success_message(self): + """Response includes a success message if successful.""" + billing_project = factories.BillingProjectFactory.create() + json_data = { + "namespace": billing_project.name, + "name": "test-workspace", + "attributes": {}, + } + responses.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, + }, + follow=True, + ) + self.assertIn("messages", response.context) + messages = list(response.context["messages"]) + self.assertEqual(len(messages), 1) + self.assertEqual(views.WorkspaceCreate.success_msg, str(messages[0])) + + def test_redirects_to_new_object_detail(self): + """After successfully creating an object, view redirects to the object's detail page.""" + # This needs to use the client because the RequestFactory doesn't handle redirects. + billing_project = factories.BillingProjectFactory.create() + json_data = { + "namespace": billing_project.name, + "name": "test-workspace", + "attributes": {}, + } + responses.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, + }, + ) + new_object = models.Workspace.objects.latest("pk") + self.assertRedirects(response, new_object.get_absolute_url()) + responses.assert_call_count(self.api_url, 1) + + def test_cannot_create_duplicate_object(self): + """Cannot create two workspaces with the same billing project and name.""" + obj = factories.WorkspaceFactory.create() + 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": obj.billing_project.pk, + "name": obj.name, + # Default workspace data for formset. + "workspacedata-TOTAL_FORMS": 1, + "workspacedata-INITIAL_FORMS": 0, + "workspacedata-MIN_NUM_FORMS": 1, + "workspacedata-MAX_NUM_FORMS": 1, + }, + ) + self.assertEqual(response.status_code, 200) + form = response.context_data["form"] + self.assertFalse(form.is_valid()) + self.assertIn("already exists", form.non_field_errors()[0]) + self.assertEqual(models.Workspace.objects.count(), 2) + self.assertIn(self.workspace_to_clone, models.Workspace.objects.all()) + self.assertIn(obj, models.Workspace.objects.all()) + + def test_can_create_workspace_with_same_billing_project_different_name(self): + """Can create a workspace with a different name in the same billing project.""" + billing_project = factories.BillingProjectFactory.create() + factories.WorkspaceFactory.create( + billing_project=billing_project, name="test-name-1" + ) + json_data = { + "namespace": billing_project.name, + "name": "test-name-2", + "attributes": {}, + } + responses.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-name-2", + # Default workspace data for formset. + "workspacedata-TOTAL_FORMS": 1, + "workspacedata-INITIAL_FORMS": 0, + "workspacedata-MIN_NUM_FORMS": 1, + "workspacedata-MAX_NUM_FORMS": 1, + }, + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(models.Workspace.objects.count(), 3) + # Make sure you can get the new object. + models.Workspace.objects.get( + billing_project=billing_project, name="test-name-2" + ) + responses.assert_call_count(self.api_url, 1) + + def test_can_create_workspace_with_same_name_different_billing_project(self): + """Can create a workspace with the same name in a different billing project.""" + billing_project_1 = factories.BillingProjectFactory.create(name="project-1") + billing_project_2 = factories.BillingProjectFactory.create(name="project-2") + workspace_name = "test-name" + factories.WorkspaceFactory.create( + billing_project=billing_project_1, name=workspace_name + ) + json_data = { + "namespace": billing_project_2.name, + "name": "test-name", + "attributes": {}, + } + responses.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_2.pk, + "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, + }, + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(models.Workspace.objects.count(), 3) + # Make sure you can get the new object. + models.Workspace.objects.get( + billing_project=billing_project_2, name=workspace_name + ) + responses.assert_call_count(self.api_url, 1) + + def test_invalid_input_name(self): + """Posting invalid data to name field does not create an object.""" + billing_project = factories.BillingProjectFactory.create() + 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": "invalid name", + # Default workspace data for formset. + "workspacedata-TOTAL_FORMS": 1, + "workspacedata-INITIAL_FORMS": 0, + "workspacedata-MIN_NUM_FORMS": 1, + "workspacedata-MAX_NUM_FORMS": 1, + }, + ) + self.assertEqual(response.status_code, 200) + form = response.context_data["form"] + self.assertFalse(form.is_valid()) + self.assertIn("name", form.errors.keys()) + self.assertIn("slug", form.errors["name"][0]) + self.assertEqual(models.Workspace.objects.count(), 1) + self.assertIn(self.workspace_to_clone, models.Workspace.objects.all()) + self.assertEqual(len(responses.calls), 0) + + def test_invalid_input_billing_project(self): + """Posting invalid data to billing_project field does not create an object.""" + 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": 100, + "name": "test-name", + # Default workspace data for formset. + "workspacedata-TOTAL_FORMS": 1, + "workspacedata-INITIAL_FORMS": 0, + "workspacedata-MIN_NUM_FORMS": 1, + "workspacedata-MAX_NUM_FORMS": 1, + }, + ) + self.assertEqual(response.status_code, 200) + form = response.context_data["form"] + self.assertFalse(form.is_valid()) + self.assertIn("billing_project", form.errors.keys()) + self.assertIn("valid choice", form.errors["billing_project"][0]) + self.assertEqual(models.Workspace.objects.count(), 1) + self.assertIn(self.workspace_to_clone, models.Workspace.objects.all()) + self.assertEqual(len(responses.calls), 0) + + def test_post_invalid_name_billing_project(self): + """Posting blank data does not create an object.""" + 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, + ), + {}, + ) + self.assertEqual(response.status_code, 200) + form = response.context_data["form"] + self.assertFalse(form.is_valid()) + self.assertIn("billing_project", form.errors.keys()) + self.assertIn("required", form.errors["billing_project"][0]) + self.assertIn("name", form.errors.keys()) + self.assertIn("required", form.errors["name"][0]) + self.assertEqual(models.Workspace.objects.count(), 1) + self.assertIn(self.workspace_to_clone, models.Workspace.objects.all()) + self.assertEqual(len(responses.calls), 0) + + def test_post_blank_data(self): + """Posting blank data does not create an object.""" + 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, + ), + {}, + ) + self.assertEqual(response.status_code, 200) + form = response.context_data["form"] + self.assertFalse(form.is_valid()) + self.assertIn("billing_project", form.errors.keys()) + self.assertIn("required", form.errors["billing_project"][0]) + self.assertIn("name", form.errors.keys()) + self.assertIn("required", form.errors["name"][0]) + self.assertEqual(models.Workspace.objects.count(), 1) + self.assertIn(self.workspace_to_clone, models.Workspace.objects.all()) + self.assertEqual(len(responses.calls), 0) + + def test_api_error_message(self): + """Shows a method if an AnVIL API error occurs.""" + billing_project = factories.BillingProjectFactory.create() + json_data = { + "namespace": billing_project.name, + "name": "test-workspace", + "attributes": {}, + } + responses.add( + responses.POST, + self.api_url, + status=500, + match=[responses.matchers.json_params_matcher(json_data)], + json={"message": "workspace create test error"}, + ) + # Need a client to check messages. + 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, + }, + ) + self.assertEqual(response.status_code, 200) + self.assertIn("form", response.context) + self.assertIn("messages", response.context) + messages = list(response.context["messages"]) + self.assertEqual(len(messages), 1) + self.assertIn("AnVIL API Error: workspace create test error", str(messages[0])) + responses.assert_call_count(self.api_url, 1) + # Make sure that no object is created. + self.assertEqual(models.Workspace.objects.count(), 1) + self.assertIn(self.workspace_to_clone, models.Workspace.objects.all()) + + def test_invalid_auth_domain(self): + """Does not create a workspace when an invalid authorization domain is specified.""" + billing_project = factories.BillingProjectFactory.create( + name="test-billing-project" + ) + 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", + "authorization_domains": [1], + # Default workspace data for formset. + "workspacedata-TOTAL_FORMS": 1, + "workspacedata-INITIAL_FORMS": 0, + "workspacedata-MIN_NUM_FORMS": 1, + "workspacedata-MAX_NUM_FORMS": 1, + }, + ) + self.assertEqual(response.status_code, 200) + self.assertIn("form", response.context_data) + form = response.context_data["form"] + self.assertFalse(form.is_valid()) + self.assertIn("authorization_domains", form.errors.keys()) + self.assertIn("valid choice", form.errors["authorization_domains"][0]) + # No object was created. + self.assertEqual(models.Workspace.objects.count(), 1) + self.assertIn(self.workspace_to_clone, models.Workspace.objects.all()) + # No API calls made. + responses.assert_call_count(self.api_url, 0) + + def test_one_valid_one_invalid_auth_domain(self): + billing_project = factories.BillingProjectFactory.create( + name="test-billing-project" + ) + auth_domain = factories.ManagedGroupFactory.create() + 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", + "authorization_domains": [auth_domain.pk, auth_domain.pk + 1], + # Default workspace data for formset. + "workspacedata-TOTAL_FORMS": 1, + "workspacedata-INITIAL_FORMS": 0, + "workspacedata-MIN_NUM_FORMS": 1, + "workspacedata-MAX_NUM_FORMS": 1, + }, + ) + self.assertEqual(response.status_code, 200) + self.assertIn("form", response.context_data) + form = response.context_data["form"] + self.assertFalse(form.is_valid()) + self.assertIn("authorization_domains", form.errors.keys()) + self.assertIn("valid choice", form.errors["authorization_domains"][0]) + # No object was created. + self.assertEqual(len(models.Workspace.objects.all()), 1) + self.assertIn(self.workspace_to_clone, models.Workspace.objects.all()) + # No API calls made. + responses.assert_call_count(self.api_url, 0) + + def test_auth_domain_does_not_exist_on_anvil(self): + """No workspace is displayed if the auth domain group doesn't exist on AnVIL.""" + billing_project = factories.BillingProjectFactory.create( + name="test-billing-project" + ) + auth_domain = factories.ManagedGroupFactory.create() + json_data = { + "namespace": "test-billing-project", + "name": "test-workspace", + "attributes": {}, + "authorizationDomain": [ + {"membersGroupName": auth_domain.name}, + ], + } + responses.add( + responses.POST, + self.api_url, + status=400, + json={"message": "api error"}, + match=[responses.matchers.json_params_matcher(json_data)], + ) + # Need a client to check messages. + 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", + "authorization_domains": [auth_domain.pk], + # Default workspace data for formset. + "workspacedata-TOTAL_FORMS": 1, + "workspacedata-INITIAL_FORMS": 0, + "workspacedata-MIN_NUM_FORMS": 1, + "workspacedata-MAX_NUM_FORMS": 1, + }, + ) + self.assertEqual(response.status_code, 200) + # The form is valid but there was an API error. + form = response.context_data["form"] + self.assertTrue(form.is_valid()) + # Check messages. + self.assertIn("messages", response.context) + messages = list(response.context["messages"]) + self.assertEqual(len(messages), 1) + self.assertEqual("AnVIL API Error: api error", str(messages[0])) + # Did not create any new Workspaces. + self.assertEqual(models.Workspace.objects.count(), 1) + self.assertIn(self.workspace_to_clone, models.Workspace.objects.all()) + responses.assert_call_count(self.api_url, 1) + + def test_not_admin_of_auth_domain_on_anvil(self): + """No workspace is displayed if we are not the admins of the auth domain on AnVIL.""" + billing_project = factories.BillingProjectFactory.create( + name="test-billing-project" + ) + auth_domain = factories.ManagedGroupFactory.create() + json_data = { + "namespace": "test-billing-project", + "name": "test-workspace", + "attributes": {}, + "authorizationDomain": [ + {"membersGroupName": auth_domain.name}, + ], + } + responses.add( + responses.POST, + self.api_url, + status=400, + json={"message": "api error"}, + match=[responses.matchers.json_params_matcher(json_data)], + ) + # Need a client to check messages. + 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", + "authorization_domains": [auth_domain.pk], + # Default workspace data for formset. + "workspacedata-TOTAL_FORMS": 1, + "workspacedata-INITIAL_FORMS": 0, + "workspacedata-MIN_NUM_FORMS": 1, + "workspacedata-MAX_NUM_FORMS": 1, + }, + ) + self.assertEqual(response.status_code, 200) + # The form is valid but there was an API error. + form = response.context_data["form"] + self.assertTrue(form.is_valid()) + # Check messages. + self.assertIn("messages", response.context) + messages = list(response.context["messages"]) + self.assertEqual(len(messages), 1) + self.assertEqual("AnVIL API Error: api error", str(messages[0])) + # Did not create any new Workspaces. + self.assertEqual(models.Workspace.objects.count(), 1) + self.assertIn(self.workspace_to_clone, models.Workspace.objects.all()) + responses.assert_call_count(self.api_url, 1) + + def test_not_user_of_billing_project(self): + """Posting a billing project where we are not users does not create an object.""" + billing_project = factories.BillingProjectFactory.create( + name="test-billing-project", has_app_as_user=False + ) + 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, + }, + ) + self.assertEqual(response.status_code, 200) + self.assertIn("form", response.context_data) + form = response.context_data["form"] + self.assertFalse(form.is_valid()) + self.assertIn("billing_project", form.errors.keys()) + self.assertIn("valid choice", form.errors["billing_project"][0]) + # No workspace was created. + self.assertEqual(models.Workspace.objects.count(), 1) + self.assertIn(self.workspace_to_clone, models.Workspace.objects.all()) + + def test_adapter_includes_workspace_data_formset(self): + """Response includes the workspace data formset if specified.""" + # 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(TestWorkspaceAdapter) + self.workspace_type = "test" + self.client.force_login(self.user) + response = self.client.get( + self.get_url( + self.workspace_to_clone.billing_project.name, + self.workspace_to_clone.name, + self.workspace_type, + ) + ) + self.assertTrue("workspace_data_formset" in response.context_data) + formset = response.context_data["workspace_data_formset"] + self.assertIsInstance(formset, BaseInlineFormSet) + self.assertEqual(len(formset.forms), 1) + self.assertIsInstance(formset.forms[0], app_forms.TestWorkspaceDataForm) + + def test_adapter_creates_workspace_data(self): + """Posting valid data to the form creates a workspace data object when using a custom adapter.""" + # 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(TestWorkspaceAdapter) + self.workspace_type = "test" + billing_project = factories.BillingProjectFactory.create( + name="test-billing-project" + ) + json_data = { + "namespace": "test-billing-project", + "name": "test-workspace", + "attributes": {}, + } + responses.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-study_name": "test study", + }, + ) + self.assertEqual(response.status_code, 302) + # The workspace is created. + new_workspace = models.Workspace.objects.latest("pk") + # workspace_type is set properly. + self.assertEqual( + new_workspace.workspace_type, + TestWorkspaceAdapter().get_type(), + ) + # Workspace data is added. + self.assertEqual(app_models.TestWorkspaceData.objects.count(), 1) + new_workspace_data = app_models.TestWorkspaceData.objects.latest("pk") + self.assertEqual(new_workspace_data.workspace, new_workspace) + self.assertEqual(new_workspace_data.study_name, "test study") + responses.assert_call_count(self.api_url, 1) + + def test_adapter_does_not_create_objects_if_workspace_data_form_invalid(self): + """Posting invalid data to the workspace_data_form form does not create a workspace when using an adapter.""" + # 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(TestWorkspaceAdapter) + self.workspace_type = "test" + billing_project = factories.BillingProjectFactory.create() + url = self.entry_point + "/api/workspaces" + json_data = { + "namespace": "test-billing-project", + "name": "test-workspace", + "attributes": {}, + } + responses.add( + responses.POST, + 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-study_name": "", + }, + ) + self.assertEqual(response.status_code, 200) + # Workspace form is valid. + form = response.context_data["form"] + self.assertTrue(form.is_valid()) + # workspace_data_form is not valid. + workspace_data_formset = response.context_data["workspace_data_formset"] + self.assertEqual(workspace_data_formset.is_valid(), False) + workspace_data_form = workspace_data_formset.forms[0] + self.assertEqual(workspace_data_form.is_valid(), False) + self.assertEqual(len(workspace_data_form.errors), 1) + self.assertIn("study_name", workspace_data_form.errors) + self.assertEqual(len(workspace_data_form.errors["study_name"]), 1) + self.assertIn("required", workspace_data_form.errors["study_name"][0]) + self.assertEqual(models.Workspace.objects.count(), 1) + self.assertIn(self.workspace_to_clone, models.Workspace.objects.all()) + self.assertEqual(app_models.TestWorkspaceData.objects.count(), 0) + self.assertEqual(len(responses.calls), 0) + + def test_workspace_to_clone_does_not_exist_on_anvil(self): + """Shows a method if an AnVIL API 404 error occurs.""" + billing_project = factories.BillingProjectFactory.create() + json_data = { + "namespace": billing_project.name, + "name": "test-workspace", + "attributes": {}, + } + responses.add( + responses.POST, + self.api_url, + status=404, + match=[responses.matchers.json_params_matcher(json_data)], + json={"message": "workspace create test error"}, + ) + # Need a client to check messages. + 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, + }, + ) + self.assertEqual(response.status_code, 200) + self.assertIn("form", response.context) + self.assertIn("messages", response.context) + messages = list(response.context["messages"]) + self.assertEqual(len(messages), 1) + self.assertIn("AnVIL API Error: workspace create test error", str(messages[0])) + responses.assert_call_count(self.api_url, 1) + # Make sure that no object is created. + self.assertEqual(models.Workspace.objects.count(), 1) + self.assertIn(self.workspace_to_clone, models.Workspace.objects.all()) + + class WorkspaceUpdateTest(TestCase): def setUp(self): """Set up test class.""" diff --git a/anvil_consortium_manager/urls.py b/anvil_consortium_manager/urls.py index 7110a28f..03acad5b 100644 --- a/anvil_consortium_manager/urls.py +++ b/anvil_consortium_manager/urls.py @@ -208,6 +208,11 @@ views.WorkspaceImport.as_view(), name="import", ), + # path( + # "types//clone/", + # views.WorkspaceClone.as_view(), + # name="clone", + # ), path("audit/", views.WorkspaceAudit.as_view(), name="audit"), path( "//delete/", @@ -233,6 +238,11 @@ views.WorkspaceUpdate.as_view(), name="update", ), + path( + "//clone//", + views.WorkspaceClone.as_view(), + name="clone", + ), ], "workspaces", ) diff --git a/anvil_consortium_manager/views.py b/anvil_consortium_manager/views.py index a92b7b34..361efc8d 100644 --- a/anvil_consortium_manager/views.py +++ b/anvil_consortium_manager/views.py @@ -1313,6 +1313,158 @@ def forms_invalid(self, form, workspace_data_formset): ) +class WorkspaceClone( + auth.AnVILConsortiumManagerEditRequired, + SuccessMessageMixin, + WorkspaceAdapterMixin, + SingleObjectMixin, + FormView, +): + model = models.Workspace + form_class = forms.WorkspaceCloneForm + success_msg = "Successfully created Workspace on AnVIL." + template_name = "anvil_consortium_manager/workspace_clone.html" + + def get_object(self, queryset=None): + """Return the workspace to clone.""" + if queryset is None: + queryset = self.get_queryset() + # Filter the queryset based on kwargs. + billing_project_slug = self.kwargs.get("billing_project_slug") + workspace_slug = self.kwargs.get("workspace_slug") + queryset = queryset.filter( + billing_project__name=billing_project_slug, name=workspace_slug + ) + try: + # Get the single item from the filtered queryset + obj = queryset.get() + except queryset.model.DoesNotExist: + raise Http404( + _("No %(verbose_name)s found matching the query") + % {"verbose_name": queryset.model._meta.verbose_name} + ) + return obj + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + return super().get(request, *args, **kwargs) + + def get_initial(self): + """Add the authorization domains of the workspace to be cloned to the form.""" + initial = super().get_initial() + initial["authorization_domains"] = self.object.authorization_domains.all() + return initial + + def get_form(self, form_class=None): + """Return an instance of the form to be used in this view.""" + if form_class is None: + form_class = self.get_form_class() + return form_class(self.object, **self.get_form_kwargs()) + + def get_workspace_data_formset(self): + """Return an instance of the workspace data form to be used in this view.""" + formset_prefix = "workspacedata" + form_class = self.adapter.get_workspace_data_form_class() + model = self.adapter.get_workspace_data_model() + formset_factory = inlineformset_factory( + models.Workspace, + model, + form=form_class, + can_delete=False, + can_delete_extra=False, + absolute_max=1, + max_num=1, + min_num=1, + ) + if self.request.method in ("POST"): + formset = formset_factory( + self.request.POST, + instance=self.new_workspace, + prefix=formset_prefix, + initial=[{"workspace": self.new_workspace}], + ) + else: + formset = formset_factory(prefix=formset_prefix, initial=[{}]) + return formset + + def get_context_data(self, **kwargs): + """Insert the workspace data formset into the context dict.""" + if "workspace_data_formset" not in kwargs: + kwargs["workspace_data_formset"] = self.get_workspace_data_formset() + return super().get_context_data(**kwargs) + + def post(self, request, *args, **kwargs): + """ + Handle POST requests: instantiate the forms instances with the passed + POST variables and then check if they are valid. + """ + self.adapter = self.get_adapter() + self.object = self.get_object() + self.new_workspace = None + form = self.get_form() + # First, check if the workspace form is valid. + # If it is, we'll save the model and then check the workspace data formset in the post method. + if form.is_valid(): + return self.form_valid(form) + else: + workspace_data_formset = self.get_workspace_data_formset() + return self.forms_invalid(form, workspace_data_formset) + + def form_valid(self, form): + """If the form(s) are valid, save the associated model(s) and create the workspace on AnVIL.""" + # Need to use a transaction because the object needs to be saved to access the many-to-many field. + try: + with transaction.atomic(): + # Calling form.save() does not create the history for the authorization domain many to many field. + # Instead, save the workspace first and then create the auth domain relationships one by one. + # Add the workspace data type from the adapter to the instance. + form.instance.workspace_type = self.adapter.get_type() + self.new_workspace = form.save(commit=False) + self.new_workspace.save() + # Now check the workspace_data_formset. + workspace_data_formset = self.get_workspace_data_formset() + if not workspace_data_formset.is_valid(): + # Tell the transaction to roll back, since we are not raising an exception. + 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"]: + models.WorkspaceAuthorizationDomain.objects.create( + workspace=self.new_workspace, group=auth_domain + ) + workspace_data_formset.forms[0].save() + # Then create the workspace on AnVIL. + authorization_domains = self.new_workspace.authorization_domains.all() + print(authorization_domains) + self.object.anvil_clone( + self.new_workspace.billing_project, + self.new_workspace.name, + authorization_domains=authorization_domains, + ) + 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) + ) + return self.render_to_response( + self.get_context_data( + form=form, workspace_data_formset=workspace_data_formset + ) + ) + return super().form_valid(form) + + def forms_invalid(self, form, workspace_data_formset): + """If the form(s) are invalid, render the invalid form.""" + return self.render_to_response( + self.get_context_data( + form=form, workspace_data_formset=workspace_data_formset + ) + ) + + def get_success_url(self): + return self.new_workspace.get_absolute_url() + + class WorkspaceUpdate( auth.AnVILConsortiumManagerEditRequired, SuccessMessageMixin, diff --git a/docs/user_guide.rst b/docs/user_guide.rst index fa91ceb8..ddb5cc62 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -55,6 +55,31 @@ Creating a new workspace If successful, you will be shown a success message and information about the workspace that you just created. The examples would create a workspace named "primed-cc/test-workspace". +Cloning an existing workspace +----------------------------- + +1. Navigate to the workspace detail page that you would like to clone. + +2. Click the "Clone workspace" button and then choose the workspace type that you would like to use for the new workspace. + +3. In the "Billing project" field, select the billing project in which to create the workspace (e.g., primed-cc). If it doesn’t exist, you may need to import a billing project (see instructions). + +4. In the "name" field, type the name of the workspace that you would like to create within the selected billing project (e.g., test-workspace). + +5. If applicable, in the "authorization domains" box, select one or more authorization domains for this workspace. + + * You can select multiple authorization domains using Command-click. + * To de-select an authorization domain, click on it while holding the Command key. + * You must include all the authorization domains from the workspace you are cloning. The form will be autopopulated with these groups. + +6. If desired, add any notes about the Workspace in the "note" field. + +7. Fill out all other required fields for the type of workspace you are creating. + +8. Click on the "Save workspace" button. This can take some time due to delays from AnVIL. + +If successful, you will be shown a success message and information about the workspace that you just created. The examples would create a workspace named "primed-cc/test-workspace". + Importing an existing workspace -------------------------------