Skip to content

Commit

Permalink
Merge pull request #266 from UW-GAC/feature/data-prep-workspace
Browse files Browse the repository at this point in the history
Add a new DataPrepWorkspace workspace type
  • Loading branch information
amstilp authored Nov 10, 2023
2 parents b25eefb + fceeb38 commit 7a12603
Show file tree
Hide file tree
Showing 13 changed files with 605 additions and 55 deletions.
1 change: 1 addition & 0 deletions config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,7 @@
"primed.dbgap.adapters.dbGaPWorkspaceAdapter",
"primed.cdsa.adapters.CDSAWorkspaceAdapter",
"primed.miscellaneous_workspaces.adapters.ConsortiumDevelWorkspaceAdapter",
"primed.miscellaneous_workspaces.adapters.DataPrepWorkspaceAdapter",
"primed.miscellaneous_workspaces.adapters.ExampleWorkspaceAdapter",
"primed.miscellaneous_workspaces.adapters.SimulatedDataWorkspaceAdapter",
"primed.miscellaneous_workspaces.adapters.OpenAccessWorkspaceAdapter",
Expand Down
15 changes: 15 additions & 0 deletions primed/miscellaneous_workspaces/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,18 @@ class OpenAccessWorkspaceAdapter(BaseWorkspaceAdapter):
workspace_detail_template_name = (
"miscellaneous_workspaces/openaccessworkspace_detail.html"
)


class DataPrepWorkspaceAdapter(BaseWorkspaceAdapter):
"""Adapter for DataPrepWorkspace."""

type = "data_prep"
name = "Data prep workspace"
description = "Workspaces used to prepare data."
list_table_class = tables.DataPrepWorkspaceTable
workspace_form_class = WorkspaceForm
workspace_data_model = models.DataPrepWorkspace
workspace_data_form_class = forms.DataPrepWorkspaceForm
workspace_detail_template_name = (
"miscellaneous_workspaces/dataprepworkspace_detail.html"
)
24 changes: 24 additions & 0 deletions primed/miscellaneous_workspaces/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,27 @@ class Meta:
),
"available_data": forms.CheckboxSelectMultiple,
}


class DataPrepWorkspaceForm(Bootstrap5MediaFormMixin, forms.ModelForm):
"""Form for a DataPrepWorkspace object."""

class Meta:
model = models.DataPrepWorkspace
fields = (
"workspace",
"target_workspace",
"requested_by",
"is_active",
)
# widgets = {
# "studies": autocomplete.ModelSelect2Multiple(
# url="primed_anvil:studies:autocomplete",
# attrs={"data-theme": "bootstrap-5"},
# ),
# "requested_by": autocomplete.ModelSelect2(
# url="users:autocomplete",
# attrs={"data-theme": "bootstrap-5"},
# ),
# "available_data": forms.CheckboxSelectMultiple,
# }
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Generated by Django 3.2.19 on 2023-11-09 22:11

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
import simple_history.models


class Migration(migrations.Migration):

dependencies = [
('anvil_consortium_manager', '0015_add_new_permissions'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('miscellaneous_workspaces', '0007_openaccessworkspace_data_url'),
]

operations = [
migrations.CreateModel(
name='HistoricalDataPrepWorkspace',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')),
('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')),
('is_active', models.BooleanField(default=True, help_text='Indicator of whether this workspace is currently being used for data preparation.')),
('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)),
('requested_by', models.ForeignKey(blank=True, db_constraint=False, help_text='The user who requested creation.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)),
('target_workspace', models.ForeignKey(blank=True, db_constraint=False, help_text='The workspace for which data is being prepared or updated.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='anvil_consortium_manager.workspace')),
('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 data prep workspace',
'verbose_name_plural': 'historical data prep workspaces',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='DataPrepWorkspace',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')),
('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')),
('is_active', models.BooleanField(default=True, help_text='Indicator of whether this workspace is currently being used for data preparation.')),
('requested_by', models.ForeignKey(help_text='The user who requested creation.', on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
('target_workspace', models.ForeignKey(help_text='The workspace for which data is being prepared or updated.', on_delete=django.db.models.deletion.PROTECT, related_name='data_prep_workspaces', to='anvil_consortium_manager.workspace')),
('workspace', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='anvil_consortium_manager.workspace')),
],
options={
'abstract': False,
},
),
]
34 changes: 33 additions & 1 deletion primed/miscellaneous_workspaces/models.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Model definitions for the `miscellaneous_workspaces` app."""

from anvil_consortium_manager.adapters.workspace import workspace_adapter_registry
from anvil_consortium_manager.models import BaseWorkspaceData
from anvil_consortium_manager.models import BaseWorkspaceData, Workspace
from django.core.exceptions import ValidationError
from django.db import models
from django_extensions.db.models import TimeStampedModel
Expand Down Expand Up @@ -62,3 +62,35 @@ class OpenAccessWorkspace(RequesterModel, TimeStampedModel, BaseWorkspaceData):
help_text="The types of data available in this workspace.",
blank=True,
)


class DataPrepWorkspace(RequesterModel, TimeStampedModel, BaseWorkspaceData):
"""A model to track workspaces that are used to update data in another workspace."""

target_workspace = models.ForeignKey(
Workspace,
on_delete=models.PROTECT,
related_name="data_prep_workspaces",
help_text="The workspace for which data is being prepared or updated.",
)
is_active = models.BooleanField(
default=True,
help_text="Indicator of whether this workspace is currently being used to prepare data.",
)

def clean(self):
if hasattr(self, "target_workspace"):
if self.target_workspace.workspace_type == "data_prep":
raise ValidationError(
{
"target_workspace": "target_workspace cannot be a DataPrepWorkspace."
}
)

if hasattr(self, "target_workspace") and hasattr(self, "workspace"):
if self.target_workspace == self.workspace:
raise ValidationError(
{
"target_workspace": "target_workspace must be different than workspace."
}
)
27 changes: 26 additions & 1 deletion primed/miscellaneous_workspaces/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
import django_tables2 as tables
from anvil_consortium_manager.models import Workspace

from primed.primed_anvil.tables import WorkspaceSharedWithConsortiumColumn
from primed.primed_anvil.tables import (
BooleanIconColumn,
WorkspaceSharedWithConsortiumColumn,
)


class OpenAccessWorkspaceTable(tables.Table):
Expand Down Expand Up @@ -40,3 +43,25 @@ class Meta:
"is_shared",
)
order_by = ("name",)


class DataPrepWorkspaceTable(tables.Table):
"""Class to render a table of Workspace objects with DataPrepWorkspace workspace data."""

name = tables.columns.Column(linkify=True)
# TODO: Figure out why this is not showing up
dataprepworkspace__target_workspace__name = tables.columns.Column(
linkify=True, verbose_name="Target workspace"
)
dataprepworkspace__is_active = BooleanIconColumn(
verbose_name="Active?", show_false_icon=True
)

class Meta:
model = Workspace
fields = (
"name",
"dataprepworkspace__target_workspace__name",
"dataprepworkspace__is_active",
)
order_by = ("name",)
11 changes: 11 additions & 0 deletions primed/miscellaneous_workspaces/tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,14 @@ class OpenAccessWorkspaceFactory(DjangoModelFactory):

class Meta:
model = models.OpenAccessWorkspace


class DataPrepWorkspaceFactory(DjangoModelFactory):
"""A factory for the DataPrepWorkspace model."""

workspace = SubFactory(WorkspaceFactory, workspace_type="data_prep")
target_workspace = SubFactory(WorkspaceFactory)
requested_by = SubFactory(UserFactory)

class Meta:
model = models.DataPrepWorkspace
85 changes: 84 additions & 1 deletion primed/miscellaneous_workspaces/tests/test_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from primed.users.tests.factories import UserFactory

from .. import forms
from . import factories


class SimulatedDataWorkspaceFormTest(TestCase):
Expand Down Expand Up @@ -365,7 +366,6 @@ def test_valid_data_url(self):

def test_invalid_data_url_is_not_url(self):
"""Form is invalid if data_url is not a valid url."""
"""Form is invalid when missing data_source."""
form_data = {
"workspace": self.workspace,
"requested_by": self.requester,
Expand All @@ -379,3 +379,86 @@ def test_invalid_data_url_is_not_url(self):
self.assertIn("data_url", form.errors)
self.assertEqual(len(form.errors["data_url"]), 1)
self.assertIn("valid URL", form.errors["data_url"][0])


class DataPrepWorkspaceFormTest(TestCase):

form_class = forms.DataPrepWorkspaceForm

def setUp(self):
"""Create a workspace for use in the form."""
self.workspace = WorkspaceFactory.create()
# Use OpenAccessData workspaces for now.
self.target_workspace = factories.OpenAccessWorkspaceFactory.create().workspace
self.requester = UserFactory.create()

def test_valid(self):
"""Form is valid with necessary input."""
form_data = {
"workspace": self.workspace,
"target_workspace": self.target_workspace,
"requested_by": self.requester,
}
form = self.form_class(data=form_data)
self.assertTrue(form.is_valid())

def test_invalid_missing_workspace(self):
"""Form is invalid when missing workspace."""
form_data = {
# "workspace": self.workspace,
"target_workspace": self.target_workspace,
"requested_by": self.requester,
}
form = self.form_class(data=form_data)
self.assertFalse(form.is_valid())
self.assertEqual(len(form.errors), 1)
self.assertIn("workspace", form.errors)
self.assertEqual(len(form.errors["workspace"]), 1)
self.assertIn("required", form.errors["workspace"][0])

def test_invalid_missing_target_workspace(self):
"""Form is invalid if target_workspace is missing."""
form_data = {
"workspace": self.workspace,
# "target_workspace": self.target_workspace,
"requested_by": self.requester,
}
form = self.form_class(data=form_data)
self.assertFalse(form.is_valid())
self.assertEqual(len(form.errors), 1)
self.assertIn("target_workspace", form.errors)
self.assertEqual(len(form.errors["target_workspace"]), 1)
self.assertIn("required", form.errors["target_workspace"][0])

def test_invalid_missing_requested_by(self):
"""Form is invalid if requested_by is missing."""
form_data = {
"workspace": self.workspace,
"target_workspace": self.target_workspace,
# "requested_by": self.requester
}
form = self.form_class(data=form_data)
self.assertFalse(form.is_valid())
self.assertEqual(len(form.errors), 1)
self.assertIn("requested_by", form.errors)
self.assertEqual(len(form.errors["requested_by"]), 1)
self.assertIn("required", form.errors["requested_by"][0])

def test_form_all_registered_workspaces(self):
"""Form is invalid if intended_workspace_type is not a registered type."""
workspace_types = list(workspace_adapter_registry.get_registered_names().keys())
for workspace_type in workspace_types:
if workspace_type == "data_prep":
# Cannot create data prep workspaces for data prep workspace target_workspaces.
pass
else:
target_workspace = WorkspaceFactory.create(
workspace_type=workspace_type
)
form_data = {
"workspace": self.workspace,
"target_workspace": target_workspace,
"requested_by": self.requester,
}
form = self.form_class(data=form_data)
self.assertTrue(form.is_valid())
72 changes: 72 additions & 0 deletions primed/miscellaneous_workspaces/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,3 +223,75 @@ def test_data_url(self):
workspace=workspace, requested_by=user, data_url="http://www.example.com"
)
self.assertEqual(instance.data_url, "http://www.example.com")


class DataPrepWorkspaceTest(TestCase):
"""Tests for the DataPrepWorkspace model."""

def test_model_saving(self):
"""Creation using the model constructor and .save() works."""
workspace = WorkspaceFactory.create()
target_workspace = WorkspaceFactory.create()
user = UserFactory.create()
instance = models.DataPrepWorkspace(
workspace=workspace, target_workspace=target_workspace, requested_by=user
)
instance.save()
self.assertIsInstance(instance, models.DataPrepWorkspace)

def test_str_method(self):
workspace = WorkspaceFactory.create(
billing_project__name="test-bp", name="test-ws"
)
instance = factories.DataPrepWorkspaceFactory.create(workspace=workspace)
self.assertIsInstance(str(instance), str)
self.assertEqual(str(instance), "test-bp/test-ws")

def test_two_update_workspaces_for_same_final_workspace(self):
target_workspace = WorkspaceFactory.create()
instance_1 = factories.DataPrepWorkspaceFactory.create(
target_workspace=target_workspace
)
instance_2 = factories.DataPrepWorkspaceFactory.create(
target_workspace=target_workspace
)
self.assertEqual(target_workspace.data_prep_workspaces.count(), 2)
self.assertIn(instance_1, target_workspace.data_prep_workspaces.all())
self.assertIn(instance_2, target_workspace.data_prep_workspaces.all())

def test_clean_original_workspace_different_than_workspace(self):
"""Clean method raises ValidationError when workspace is the same as original_workspace."""
workspace = WorkspaceFactory.create()
user = UserFactory.create()
instance = models.DataPrepWorkspace(
requested_by=user, workspace=workspace, target_workspace=workspace
)
with self.assertRaises(ValidationError) as e:
instance.full_clean()
self.assertEqual(len(e.exception.message_dict), 1)
self.assertIn("target_workspace", e.exception.message_dict)
self.assertEqual(len(e.exception.message_dict["target_workspace"]), 1)
self.assertIn(
"target_workspace must be different",
e.exception.message_dict["target_workspace"][0],
)

def test_clean_target_workspace_cannot_be_a_data_prep_workspace(self):
"""Clean method raises ValidationError when the original_workspace is a data prep workspace."""
workspace = WorkspaceFactory.create()
target_workspace = factories.DataPrepWorkspaceFactory.create()
user = UserFactory.create()
instance = models.DataPrepWorkspace(
requested_by=user,
workspace=workspace,
target_workspace=target_workspace.workspace,
)
with self.assertRaises(ValidationError) as e:
instance.full_clean()
self.assertEqual(len(e.exception.message_dict), 1)
self.assertIn("target_workspace", e.exception.message_dict)
self.assertEqual(len(e.exception.message_dict["target_workspace"]), 1)
self.assertIn(
"cannot be a DataPrepWorkspace",
e.exception.message_dict["target_workspace"][0],
)
Loading

0 comments on commit 7a12603

Please sign in to comment.