Skip to content

Commit

Permalink
Merge pull request #228 from UW-GAC/feature/clone-workspace
Browse files Browse the repository at this point in the history
Allow workspace cloning
  • Loading branch information
amstilp authored Dec 8, 2022
2 parents 3ba30ac + d0ae33f commit e83281a
Show file tree
Hide file tree
Showing 14 changed files with 2,108 additions and 3 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion anvil_consortium_manager/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.6"
__version__ = "0.7dev1"
49 changes: 47 additions & 2 deletions anvil_consortium_manager/anvil_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
95 changes: 95 additions & 0 deletions anvil_consortium_manager/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
35 changes: 35 additions & 0 deletions anvil_consortium_manager/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

<link rel="icon" href="{% static 'images/favicons/favicon.ico' %}">

<!-- TEST COMMENT - app template -->

{% block css %}
<!-- Latest compiled and minified Bootstrap CSS -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{% extends "anvil_consortium_manager/base.html" %}
{% load static %}
{% load crispy_forms_tags %}

{% block title %}Clone {{ object }}{% endblock title %}

{% block content %}

<h2>Clone {{ object }} on AnVIL</h2>

<form method="post">

{% csrf_token %}
{{ workspace_data_formset|crispy }}
{{ form|crispy }}

<button type="submit" class="btn btn-success">Clone workspace</button>
</form>
{% endblock %}

{% block inline_javascript %}
{{ form.media }}
{% endblock inline_javascript %}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
{% endblock panel %}

{% block after_panel %}

<div>
<div class="card my-3">
<div class="card-header">
Expand Down Expand Up @@ -68,6 +69,21 @@ <h2 class="accordion-header" id="headingGroupsOne">
<a href="{% url 'anvil_consortium_manager:workspaces:sharing:new' billing_project_slug=object.billing_project.name workspace_slug=object.name %}" class="btn btn-primary" role="button">Share with a group</a>
</p>

<p>
<div class="dropdown">
<a class="btn btn-warning dropdown-toggle" href="#" role="button" id="cloneWorkspaceMenuLink" data-bs-toggle="dropdown" aria-expanded="false">
Clone workspace
</a>

<ul class="dropdown-menu" aria-labelledby="cloneWorkspaceMenuLink">
{% for workspace_type, workspace_name in registered_workspaces.items %}
<li><a class="dropdown-item" href="{% url 'anvil_consortium_manager:workspaces:clone' billing_project_slug=object.billing_project.name workspace_slug=object.name workspace_type=workspace_type %}">{{ workspace_name }}</a></li>
{% endfor %}

</ul>
</div>
</p>

<p>
<a href="{% url 'anvil_consortium_manager:workspaces:update' billing_project_slug=object.billing_project.name workspace_slug=object.name %}" class="btn btn-secondary" role="button">Update</a>
<a href="{% url 'anvil_consortium_manager:workspaces:delete' billing_project_slug=object.billing_project.name workspace_slug=object.name %}" class="btn btn-danger" role="button">Delete on AnVIL</a>
Expand Down
Loading

0 comments on commit e83281a

Please sign in to comment.