diff --git a/config/settings/base.py b/config/settings/base.py index e9fd5e53..ceaf30bd 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -200,7 +200,6 @@ "django.template.context_processors.tz", "django.contrib.messages.context_processors.messages", "gregor_django.utils.context_processors.settings_context", - "anvil_consortium_manager.context_processors.workspace_adapter", ], }, } @@ -357,6 +356,7 @@ "gregor_django.gregor_anvil.adapters.TemplateWorkspaceAdapter", "gregor_django.gregor_anvil.adapters.UploadWorkspaceAdapter", "gregor_django.gregor_anvil.adapters.CombinedConsortiumDataWorkspaceAdapter", + "gregor_django.gregor_anvil.adapters.ReleaseWorkspaceAdapter", ] ANVIL_ACCOUNT_ADAPTER = "gregor_django.gregor_anvil.adapters.AccountAdapter" diff --git a/gregor_django/gregor_anvil/adapters.py b/gregor_django/gregor_anvil/adapters.py index 2b802baf..759d38e6 100644 --- a/gregor_django/gregor_anvil/adapters.py +++ b/gregor_django/gregor_anvil/adapters.py @@ -30,6 +30,7 @@ class UploadWorkspaceAdapter(BaseWorkspaceAdapter): type = "upload" name = "Upload workspace" + description = "Workspaces that contain data uploaded by RCs in a given upload cycle" list_table_class = tables.UploadWorkspaceTable workspace_data_model = models.UploadWorkspace workspace_data_form_class = forms.UploadWorkspaceForm @@ -41,6 +42,9 @@ class ExampleWorkspaceAdapter(BaseWorkspaceAdapter): type = "example" name = "Example workspace" + description = ( + "Workspaces that contain examples of using AnVIL, working with data, etc." + ) list_table_class = WorkspaceTable workspace_data_model = models.ExampleWorkspace workspace_data_form_class = forms.ExampleWorkspaceForm @@ -52,6 +56,7 @@ class TemplateWorkspaceAdapter(BaseWorkspaceAdapter): type = "template" name = "Template workspace" + description = "Template workspaces that can be cloned to create other workspaces" list_table_class = tables.TemplateWorkspaceTable workspace_data_model = models.TemplateWorkspace workspace_data_form_class = forms.TemplateWorkspaceForm @@ -63,9 +68,22 @@ class CombinedConsortiumDataWorkspaceAdapter(BaseWorkspaceAdapter): type = "combined_consortium" name = "Combined consortium data workspace" + description = "Workspaces for internal consortium use that contain data tables combined across upload workspaces" list_table_class = WorkspaceTable workspace_data_model = models.CombinedConsortiumDataWorkspace workspace_data_form_class = forms.CombinedConsortiumDataWorkspaceForm workspace_detail_template_name = ( "gregor_anvil/combinedconsortiumdataworkspace_detail.html" ) + + +class ReleaseWorkspaceAdapter(BaseWorkspaceAdapter): + """Adapter for ReleaseWorkspace.""" + + type = "release" + name = "Release workspace" + description = "Workspaces for release to the general scientific community via dbGaP" + list_table_class = tables.ReleaseWorkspaceTable + workspace_data_model = models.ReleaseWorkspace + workspace_data_form_class = forms.ReleaseWorkspaceForm + workspace_detail_template_name = "gregor_anvil/releaseworkspace_detail.html" diff --git a/gregor_django/gregor_anvil/forms.py b/gregor_django/gregor_anvil/forms.py index daa6d65b..cd9926ff 100644 --- a/gregor_django/gregor_anvil/forms.py +++ b/gregor_django/gregor_anvil/forms.py @@ -66,3 +66,75 @@ def clean_upload_workspaces(self): ValidationError(self.ERROR_UPLOAD_VERSION_DOES_NOT_MATCH), ) return data + + +class CustomDateInput(forms.widgets.DateInput): + input_type = "date" + + +class ReleaseWorkspaceForm(Bootstrap5MediaFormMixin, forms.ModelForm): + """Form for a ReleaseWorkspace object.""" + + ERROR_CONSENT_DOES_NOT_MATCH = ( + "Consent group must match consent group of upload workspaces." + ) + ERROR_UPLOAD_WORKSPACE_CONSENT = ( + "Consent group for upload workspaces must be the same." + ) + + class Meta: + model = models.ReleaseWorkspace + fields = ( + "consent_group", + "upload_workspaces", + "full_data_use_limitations", + "dbgap_version", + "dbgap_participant_set", + "date_released", + "workspace", + ) + help_texts = { + "upload_workspaces": """Upload workspaces contributing to this Release Workspace. + All upload workspaces must have the same consent group.""", + "date_released": """Do not select a date for this field unless the workspace has been + released to the scientific community.""", + } + widgets = { + # We considered checkboxes for workspaces with default all checked. + # Unfortunately we need to select only those with a given consent, not all workspaces. + # So go back to the ModelSelect2Multiple widget. + "upload_workspaces": autocomplete.ModelSelect2Multiple( + url="gregor_anvil:upload_workspaces:autocomplete", + attrs={"data-theme": "bootstrap-5"}, + forward=["consent_group"], + ), + # "date_released": forms.SelectDateInput(), + # "date_released": forms.DateInput(), + # "date_released": AdminDateWidget(), + "date_released": CustomDateInput(), + } + + def clean_upload_workspaces(self): + """Validate that all UploadWorkspaces have the same consent group.""" + data = self.cleaned_data["upload_workspaces"] + versions = set([x.consent_group for x in data]) + if len(versions) > 1: + self.add_error( + "upload_workspaces", + ValidationError(self.ERROR_UPLOAD_WORKSPACE_CONSENT), + ) + return data + + def clean(self): + """Validate that consent_group matches the consent group for upload_workspaces.""" + cleaned_data = super().clean() + # Make sure that the consent group specified matches the consent group for the upload_workspaces. + consent_group = cleaned_data.get("consent_group") + upload_workspaces = cleaned_data.get("upload_workspaces") + if consent_group and upload_workspaces: + # We only need to check the first workspace since the clean_upload_workspaces method checks + # that all upload_workspaces have the same consent. + if consent_group != upload_workspaces[0].consent_group: + raise ValidationError(self.ERROR_CONSENT_DOES_NOT_MATCH) + + return cleaned_data diff --git a/gregor_django/gregor_anvil/migrations/0007_releaseworkspace.py b/gregor_django/gregor_anvil/migrations/0007_releaseworkspace.py new file mode 100644 index 00000000..cd3eaff6 --- /dev/null +++ b/gregor_django/gregor_anvil/migrations/0007_releaseworkspace.py @@ -0,0 +1,65 @@ +# Generated by Django 3.2.16 on 2023-03-27 18:47 + +from django.conf import settings +import django.core.validators +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', '0008_workspace_is_locked'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('gregor_anvil', '0006_combinedconsortiumdataworkspace_historicalcombinedconsortiumdataworkspace'), + ] + + operations = [ + migrations.CreateModel( + name='ReleaseWorkspace', + 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')), + ('full_data_use_limitations', models.TextField(help_text='The full data use limitations for this workspace.')), + ('dbgap_version', models.IntegerField(help_text='Version of the release (should be the same as dbGaP version).', validators=[django.core.validators.MinValueValidator(1)], verbose_name=' dbGaP version')), + ('dbgap_participant_set', models.IntegerField(help_text='dbGaP participant set of the workspace', validators=[django.core.validators.MinValueValidator(1)], verbose_name=' dbGaP participant set')), + ('date_released', models.DateField(blank=True, help_text='Date that this workspace was released to the scientific community.', null=True)), + ('consent_group', models.ForeignKey(help_text='Consent group for the data in this workspace.', on_delete=django.db.models.deletion.PROTECT, to='gregor_anvil.consentgroup')), + ('upload_workspaces', models.ManyToManyField(help_text='Upload workspaces contributing data to this workspace.', to='gregor_anvil.UploadWorkspace')), + ('workspace', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='anvil_consortium_manager.workspace')), + ], + ), + migrations.CreateModel( + name='HistoricalReleaseWorkspace', + 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')), + ('full_data_use_limitations', models.TextField(help_text='The full data use limitations for this workspace.')), + ('dbgap_version', models.IntegerField(help_text='Version of the release (should be the same as dbGaP version).', validators=[django.core.validators.MinValueValidator(1)], verbose_name=' dbGaP version')), + ('dbgap_participant_set', models.IntegerField(help_text='dbGaP participant set of the workspace', validators=[django.core.validators.MinValueValidator(1)], verbose_name=' dbGaP participant set')), + ('date_released', models.DateField(blank=True, help_text='Date that this workspace was released to the scientific community.', null=True)), + ('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)), + ('consent_group', models.ForeignKey(blank=True, db_constraint=False, help_text='Consent group for the data in this workspace.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='gregor_anvil.consentgroup')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('workspace', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='anvil_consortium_manager.workspace')), + ], + options={ + 'verbose_name': 'historical release workspace', + 'verbose_name_plural': 'historical release workspaces', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.AddConstraint( + model_name='releaseworkspace', + constraint=models.UniqueConstraint(fields=('consent_group', 'dbgap_version'), name='unique_release_workspace'), + ), + ] diff --git a/gregor_django/gregor_anvil/models.py b/gregor_django/gregor_anvil/models.py index cdf43ee9..d568729f 100644 --- a/gregor_django/gregor_anvil/models.py +++ b/gregor_django/gregor_anvil/models.py @@ -101,3 +101,52 @@ class CombinedConsortiumDataWorkspace(TimeStampedModel, BaseWorkspaceData): upload_workspaces = models.ManyToManyField( UploadWorkspace, help_text="Upload workspaces" ) + + +class ReleaseWorkspace(TimeStampedModel, BaseWorkspaceData): + """A model to track a workspace for release to the scientific community.""" + + phs = 3047 + """dbGaP-assigned phs for the GREGoR study.""" + + consent_group = models.ForeignKey( + ConsentGroup, + help_text="Consent group for the data in this workspace.", + on_delete=models.PROTECT, + ) + upload_workspaces = models.ManyToManyField( + UploadWorkspace, + help_text="Upload workspaces contributing data to this workspace.", + ) + full_data_use_limitations = models.TextField( + help_text="The full data use limitations for this workspace." + ) + dbgap_version = models.IntegerField( + verbose_name=" dbGaP version", + validators=[MinValueValidator(1)], + help_text="Version of the release (should be the same as dbGaP version).", + ) + dbgap_participant_set = models.IntegerField( + verbose_name=" dbGaP participant set", + validators=[MinValueValidator(1)], + help_text="dbGaP participant set of the workspace", + ) + date_released = models.DateField( + null=True, + blank=True, + help_text="Date that this workspace was released to the scientific community.", + ) + + class Meta: + constraints = [ + # Model uniqueness. + models.UniqueConstraint( + name="unique_release_workspace", + fields=["consent_group", "dbgap_version"], + ), + ] + + def get_dbgap_accession(self): + return "phs{phs:06d}.v{v}.p{p}".format( + phs=self.phs, v=self.dbgap_version, p=self.dbgap_participant_set + ) diff --git a/gregor_django/gregor_anvil/tables.py b/gregor_django/gregor_anvil/tables.py index cfe9cf2d..bf42ae76 100644 --- a/gregor_django/gregor_anvil/tables.py +++ b/gregor_django/gregor_anvil/tables.py @@ -90,3 +90,28 @@ class Meta: def render_workspace_type(self, value): adapter_names = workspace_adapter_registry.get_registered_names() return adapter_names[value] + "s" + + +class ReleaseWorkspaceTable(tables.Table): + """A table for Workspaces that includes fields from ReleaseWorkspace.""" + + name = tables.columns.Column(linkify=True) + number_workspaces = tables.columns.Column( + accessor="pk", + verbose_name="Number of workspaces", + orderable=False, + ) + + class Meta: + model = Workspace + fields = ( + "name", + "releaseworkspace__consent_group", + "releaseworkspace__dbgap_version", + "releaseworkspace__dbgap_participant_set", + "number_workspaces", + "releaseworkspace__date_released", + ) + + def render_number_workspaces(self, record): + return record.releaseworkspace.upload_workspaces.count() diff --git a/gregor_django/gregor_anvil/tests/factories.py b/gregor_django/gregor_anvil/tests/factories.py index 8a0848eb..9ef5d619 100644 --- a/gregor_django/gregor_anvil/tests/factories.py +++ b/gregor_django/gregor_anvil/tests/factories.py @@ -76,3 +76,20 @@ class Meta: WorkspaceFactory, workspace_type="combined_consortium", ) + + +class ReleaseWorkspaceFactory(DjangoModelFactory): + """A factory for the ReleaseWorkspace model.""" + + class Meta: + model = models.ReleaseWorkspace + + full_data_use_limitations = Faker("paragraph") + consent_group = SubFactory(ConsentGroupFactory) + dbgap_version = Faker("random_int", min=1, max=10) + dbgap_participant_set = Faker("random_int", min=1, max=10) + + workspace = SubFactory( + WorkspaceFactory, + workspace_type="release", + ) diff --git a/gregor_django/gregor_anvil/tests/test_forms.py b/gregor_django/gregor_anvil/tests/test_forms.py index 86ac1123..5f385f84 100644 --- a/gregor_django/gregor_anvil/tests/test_forms.py +++ b/gregor_django/gregor_anvil/tests/test_forms.py @@ -261,3 +261,212 @@ def test_invalid_different_upload_workspace_versions(self): form.ERROR_UPLOAD_VERSION_DOES_NOT_MATCH, form.errors["upload_workspaces"][0], ) + + +class ReleaseWorkspaceFormTest(TestCase): + """Tests for the ReleaseWorkspace class.""" + + form_class = forms.ReleaseWorkspaceForm + + def setUp(self): + """Create a workspace for use in the form.""" + self.workspace = WorkspaceFactory() + + def test_valid(self): + """Form is valid with necessary input.""" + consent_group = factories.ConsentGroupFactory() + upload_workspace = factories.UploadWorkspaceFactory.create( + consent_group=consent_group + ) + form_data = { + "workspace": self.workspace, + "upload_workspaces": [upload_workspace], + "full_data_use_limitations": "foo bar", + "consent_group": consent_group, + "dbgap_version": 1, + "dbgap_participant_set": 1, + } + form = self.form_class(data=form_data) + self.assertTrue(form.is_valid()) + + def test_invalid_missing_workspace(self): + """Form is invalid when missing research_center.""" + consent_group = factories.ConsentGroupFactory() + upload_workspace = factories.UploadWorkspaceFactory.create( + consent_group=consent_group + ) + form_data = { + "upload_workspaces": [upload_workspace], + "full_data_use_limitations": "foo bar", + "consent_group": consent_group, + "dbgap_version": 1, + "dbgap_participant_set": 1, + } + 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_upload_workspaces(self): + """Form is invalid when missing research_center.""" + consent_group = factories.ConsentGroupFactory() + form_data = { + "workspace": self.workspace, + "full_data_use_limitations": "foo bar", + "consent_group": consent_group, + "dbgap_version": 1, + "dbgap_participant_set": 1, + } + form = self.form_class(data=form_data) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertIn("upload_workspaces", form.errors) + self.assertEqual(len(form.errors["upload_workspaces"]), 1) + self.assertIn("required", form.errors["upload_workspaces"][0]) + + def test_invalid_missing_consent_group(self): + """Form is invalid when missing consent_group.""" + upload_workspace = factories.UploadWorkspaceFactory.create() + form_data = { + "workspace": self.workspace, + "upload_workspaces": [upload_workspace], + "full_data_use_limitations": "foo bar", + "dbgap_version": 1, + "dbgap_participant_set": 1, + } + form = self.form_class(data=form_data) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertIn("consent_group", form.errors) + self.assertEqual(len(form.errors["consent_group"]), 1) + self.assertIn("required", form.errors["consent_group"][0]) + + def test_invalid_missing_version(self): + """Form is invalid when missing research_center.""" + consent_group = factories.ConsentGroupFactory() + upload_workspace = factories.UploadWorkspaceFactory.create( + consent_group=consent_group + ) + form_data = { + "workspace": self.workspace, + "upload_workspaces": [upload_workspace], + "full_data_use_limitations": "foo bar", + "consent_group": consent_group, + "dbgap_participant_set": 1, + } + form = self.form_class(data=form_data) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertIn("dbgap_version", form.errors) + self.assertEqual(len(form.errors["dbgap_version"]), 1) + self.assertIn("required", form.errors["dbgap_version"][0]) + + def test_invalid_missing_dbgap_participant_set(self): + """Form is invalid when missing research_center.""" + consent_group = factories.ConsentGroupFactory() + upload_workspace = factories.UploadWorkspaceFactory.create( + consent_group=consent_group + ) + form_data = { + "workspace": self.workspace, + "upload_workspaces": [upload_workspace], + "full_data_use_limitations": "foo bar", + "consent_group": consent_group, + "dbgap_version": 1, + } + form = self.form_class(data=form_data) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertIn("dbgap_participant_set", form.errors) + self.assertEqual(len(form.errors["dbgap_participant_set"]), 1) + self.assertIn("required", form.errors["dbgap_participant_set"][0]) + + def test_invalid_duplicate_object(self): + """Form is invalid with a duplicated object.""" + release_workspace = factories.ReleaseWorkspaceFactory.create() + upload_workspace = factories.UploadWorkspaceFactory.create( + consent_group=release_workspace.consent_group + ) + form_data = { + "workspace": self.workspace, + "upload_workspaces": [upload_workspace], + "full_data_use_limitations": "foo bar", + "consent_group": release_workspace.consent_group, + "dbgap_version": release_workspace.dbgap_version, + "dbgap_participant_set": 1, + } + form = self.form_class(data=form_data) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + non_field_errors = form.non_field_errors() + self.assertEqual(len(non_field_errors), 1) + self.assertIn("already exists", non_field_errors[0]) + + def test_valid_two_upload_workspaces(self): + consent_group = factories.ConsentGroupFactory() + upload_workspace_1 = factories.UploadWorkspaceFactory.create( + consent_group=consent_group + ) + upload_workspace_2 = factories.UploadWorkspaceFactory.create( + consent_group=consent_group + ) + form_data = { + "workspace": self.workspace, + "upload_workspaces": [upload_workspace_1, upload_workspace_2], + "full_data_use_limitations": "foo bar", + "consent_group": consent_group, + "dbgap_version": 1, + "dbgap_participant_set": 1, + } + form = self.form_class(data=form_data) + self.assertTrue(form.is_valid()) + + def test_invalid_upload_workspaces_have_different_consent_group(self): + consent_group_1 = factories.ConsentGroupFactory() + consent_group_2 = factories.ConsentGroupFactory() + upload_workspace_1 = factories.UploadWorkspaceFactory.create( + consent_group=consent_group_1 + ) + upload_workspace_2 = factories.UploadWorkspaceFactory.create( + consent_group=consent_group_2 + ) + form_data = { + "workspace": self.workspace, + "upload_workspaces": [upload_workspace_1, upload_workspace_2], + "full_data_use_limitations": "foo bar", + "consent_group": consent_group_1, + "dbgap_version": 1, + "dbgap_participant_set": 1, + } + form = self.form_class(data=form_data) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertIn("upload_workspaces", form.errors) + self.assertEqual(len(form.errors["upload_workspaces"]), 1) + self.assertIn( + self.form_class.ERROR_UPLOAD_WORKSPACE_CONSENT, + form.errors["upload_workspaces"][0], + ) + + def test_invalid_upload_workspace_consent_does_not_match_consent_group(self): + consent_group = factories.ConsentGroupFactory() + other_consent_group = factories.ConsentGroupFactory() + upload_workspace = factories.UploadWorkspaceFactory.create( + consent_group=other_consent_group + ) + form_data = { + "workspace": self.workspace, + "upload_workspaces": [upload_workspace], + "full_data_use_limitations": "foo bar", + "consent_group": consent_group, + "dbgap_version": 1, + "dbgap_participant_set": 1, + } + form = self.form_class(data=form_data) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + non_field_errors = form.non_field_errors() + self.assertEqual(len(non_field_errors), 1) + self.assertIn(self.form_class.ERROR_CONSENT_DOES_NOT_MATCH, non_field_errors[0]) diff --git a/gregor_django/gregor_anvil/tests/test_models.py b/gregor_django/gregor_anvil/tests/test_models.py index f5fee391..1c372fb9 100644 --- a/gregor_django/gregor_anvil/tests/test_models.py +++ b/gregor_django/gregor_anvil/tests/test_models.py @@ -361,3 +361,178 @@ def test_two_upload_workspaces(self): self.assertEqual(instance.upload_workspaces.count(), 2) self.assertIn(upload_workspace_1, instance.upload_workspaces.all()) self.assertIn(upload_workspace_2, instance.upload_workspaces.all()) + + +class ReleaseWorkspaceTest(TestCase): + """Tests for the ReleaseWorkspace model.""" + + def test_model_saving(self): + """Creation using the model constructor and .save() works.""" + workspace = WorkspaceFactory.create() + consent_group = factories.ConsentGroupFactory.create() + instance = models.ReleaseWorkspace( + workspace=workspace, + full_data_use_limitations="foo", + consent_group=consent_group, + dbgap_version=1, + dbgap_participant_set=1, + ) + instance.save() + self.assertIsInstance(instance, models.ReleaseWorkspace) + + def test_str_method(self): + """The custom __str__ method returns the correct string.""" + instance = factories.ReleaseWorkspaceFactory.create() + instance.save() + self.assertIsInstance(instance.__str__(), str) + self.assertEqual(instance.__str__(), instance.workspace.__str__()) + + def test_one_upload_workspace(self): + """Can link one upload workspace.""" + instance = factories.ReleaseWorkspaceFactory.create() + instance.save() + upload_workspace = factories.UploadWorkspaceFactory.create( + consent_group=instance.consent_group + ) + instance.upload_workspaces.add(upload_workspace) + self.assertEqual(instance.upload_workspaces.count(), 1) + self.assertIn(upload_workspace, instance.upload_workspaces.all()) + + def test_two_upload_workspaces(self): + """Can link two upload workspaces.""" + instance = factories.ReleaseWorkspaceFactory.create() + instance.save() + upload_workspace_1 = factories.UploadWorkspaceFactory.create( + consent_group=instance.consent_group + ) + upload_workspace_2 = factories.UploadWorkspaceFactory.create( + consent_group=instance.consent_group + ) + instance.upload_workspaces.add(upload_workspace_1, upload_workspace_2) + self.assertEqual(instance.upload_workspaces.count(), 2) + self.assertIn(upload_workspace_1, instance.upload_workspaces.all()) + self.assertIn(upload_workspace_2, instance.upload_workspaces.all()) + + def test_unique_constraint(self): + """Cannot save two instances with the same ConsentGroup and dbgap_version.""" + consent_group = factories.ConsentGroupFactory.create() + workspace_1 = WorkspaceFactory.create(name="ws-1") + factories.ReleaseWorkspaceFactory.create( + workspace=workspace_1, + consent_group=consent_group, + dbgap_version=1, + ) + workspace_2 = WorkspaceFactory.create(name="ws-2") + instance_2 = factories.ReleaseWorkspaceFactory.build( + workspace=workspace_2, + consent_group=consent_group, + dbgap_version=1, + ) + with self.assertRaises(ValidationError): + instance_2.full_clean() + with self.assertRaises(IntegrityError): + instance_2.save() + + def test_same_consent_group(self): + """Can save multiple ReleaseWorkspace models with the same ConsentGroup and different dbgap_version.""" + consent_group = factories.ConsentGroupFactory.create() + workspace_1 = WorkspaceFactory.create(name="ws-1") + factories.ReleaseWorkspaceFactory.create( + workspace=workspace_1, + consent_group=consent_group, + dbgap_version=1, + ) + workspace_2 = WorkspaceFactory.create(name="ws-2") + instance_2 = factories.ReleaseWorkspaceFactory.build( + workspace=workspace_2, + consent_group=consent_group, + dbgap_version=2, + ) + instance_2.full_clean() + instance_2.save() + self.assertEqual(models.ReleaseWorkspace.objects.count(), 2) + + def test_same_dbgap_version(self): + """Can save multiple ReleaseWorkspace models with the same dbgap_version and different ConsentGroup.""" + consent_group_1 = factories.ConsentGroupFactory() + consent_group_2 = factories.ConsentGroupFactory() + factories.ReleaseWorkspaceFactory.create( + consent_group=consent_group_1, + dbgap_version=1, + ) + workspace_2 = WorkspaceFactory.create(name="ws-2") + instance_2 = factories.ReleaseWorkspaceFactory.build( + workspace=workspace_2, + consent_group=consent_group_2, + dbgap_version=1, + ) + instance_2.full_clean() + instance_2.save() + self.assertEqual(models.ReleaseWorkspace.objects.count(), 2) + + def test_positive_dbgap_version_not_negative(self): + """Version cannot be negative.""" + consent_group = factories.ConsentGroupFactory.create() + workspace = WorkspaceFactory.create(name="ws") + instance = factories.ReleaseWorkspaceFactory.build( + consent_group=consent_group, + workspace=workspace, + dbgap_version=-1, + ) + with self.assertRaises(ValidationError) as e: + instance.full_clean() + self.assertEqual(len(e.exception.error_dict), 1) + self.assertIn("dbgap_version", e.exception.error_dict) + self.assertEqual(len(e.exception.error_dict["dbgap_version"]), 1) + + def test_positive_dbgap_version_not_zero(self): + """Version cannot be 0.""" + consent_group = factories.ConsentGroupFactory.create() + workspace = WorkspaceFactory.create(name="ws") + instance = factories.ReleaseWorkspaceFactory.build( + consent_group=consent_group, + workspace=workspace, + dbgap_version=0, + ) + with self.assertRaises(ValidationError) as e: + instance.full_clean() + self.assertEqual(len(e.exception.error_dict), 1) + self.assertIn("dbgap_version", e.exception.error_dict) + self.assertEqual(len(e.exception.error_dict["dbgap_version"]), 1) + + def test_positive_dbgap_participant_set_not_negative(self): + """dbgap_participant_set cannot be negative.""" + consent_group = factories.ConsentGroupFactory.create() + workspace = WorkspaceFactory.create(name="ws") + instance = factories.ReleaseWorkspaceFactory.build( + consent_group=consent_group, + workspace=workspace, + dbgap_participant_set=-1, + ) + with self.assertRaises(ValidationError) as e: + instance.full_clean() + self.assertEqual(len(e.exception.error_dict), 1) + self.assertIn("dbgap_participant_set", e.exception.error_dict) + self.assertEqual(len(e.exception.error_dict["dbgap_participant_set"]), 1) + + def test_positive_dbgap_participant_set_not_zero(self): + """dbgap_participant_set cannot be 0.""" + consent_group = factories.ConsentGroupFactory.create() + workspace = WorkspaceFactory.create(name="ws") + instance = factories.ReleaseWorkspaceFactory.build( + consent_group=consent_group, + workspace=workspace, + dbgap_participant_set=0, + ) + with self.assertRaises(ValidationError) as e: + instance.full_clean() + self.assertEqual(len(e.exception.error_dict), 1) + self.assertIn("dbgap_participant_set", e.exception.error_dict) + self.assertEqual(len(e.exception.error_dict["dbgap_participant_set"]), 1) + + def test_get_dbgap_accession(self): + """get_dbgap_accession works as expected.""" + instance = factories.ReleaseWorkspaceFactory.create( + dbgap_version=1, dbgap_participant_set=2 + ) + self.assertEqual(instance.get_dbgap_accession(), "phs003047.v1.p2") diff --git a/gregor_django/gregor_anvil/tests/test_tables.py b/gregor_django/gregor_anvil/tests/test_tables.py index cb1bf414..d81fda87 100644 --- a/gregor_django/gregor_anvil/tests/test_tables.py +++ b/gregor_django/gregor_anvil/tests/test_tables.py @@ -126,6 +126,46 @@ def test_row_count_with_two_objects(self): self.assertEqual(len(table.rows), 2) +class ReleaseWorkspaceTableTest(TestCase): + """Tests for the AccountTable in this app.""" + + model = Workspace + model_factory = factories.ReleaseWorkspaceFactory + table_class = tables.ReleaseWorkspaceTable + + def test_row_count_with_no_objects(self): + table = self.table_class(self.model.objects.all()) + self.assertEqual(len(table.rows), 0) + + def test_row_count_with_one_object(self): + self.model_factory.create() + table = self.table_class(self.model.objects.all()) + self.assertEqual(len(table.rows), 1) + + def test_row_count_with_two_objects(self): + self.model_factory.create_batch(2) + table = self.table_class(self.model.objects.all()) + self.assertEqual(len(table.rows), 2) + + def test_number_workspaces(self): + self.model_factory.create() + release_workspace_1 = self.model_factory.create() + release_workspace_1.upload_workspaces.add( + factories.UploadWorkspaceFactory.create() + ) + release_workspace_2 = self.model_factory.create() + release_workspace_2.upload_workspaces.add( + factories.UploadWorkspaceFactory.create() + ) + release_workspace_2.upload_workspaces.add( + factories.UploadWorkspaceFactory.create() + ) + table = self.table_class(self.model.objects.filter(workspace_type="release")) + self.assertEqual(table.rows[0].get_cell("number_workspaces"), 0) + self.assertEqual(table.rows[1].get_cell("number_workspaces"), 1) + self.assertEqual(table.rows[2].get_cell("number_workspaces"), 2) + + class WorkspaceReportTableTest(TestCase): model = Workspace model_factory = factories.TemplateWorkspaceFactory diff --git a/gregor_django/gregor_anvil/tests/test_views.py b/gregor_django/gregor_anvil/tests/test_views.py index eeefdcdd..a3522eae 100644 --- a/gregor_django/gregor_anvil/tests/test_views.py +++ b/gregor_django/gregor_anvil/tests/test_views.py @@ -605,6 +605,29 @@ def test_returns_correct_object_case_insensitive(self): self.assertEqual(len(returned_ids), 1) self.assertEqual(returned_ids[0], workspace.pk) + def test_forwarded_consent_group(self): + """Queryset is filtered to consent groups matching the forwarded value if specified.""" + consent_group = factories.ConsentGroupFactory.create() + workspace = factories.UploadWorkspaceFactory.create( + workspace__name="test_1", consent_group=consent_group + ) + other_consent_group = factories.ConsentGroupFactory.create() + other_workspace = factories.UploadWorkspaceFactory.create( + workspace__name="test_2", consent_group=other_consent_group + ) + self.client.force_login(self.user) + response = self.client.get( + self.get_url(), + {"q": "test", "forward": json.dumps({"consent_group": consent_group.pk})}, + ) + returned_ids = [ + int(x["id"]) + for x in json.loads(response.content.decode("utf-8"))["results"] + ] + self.assertEqual(len(returned_ids), 1) + self.assertIn(workspace.pk, returned_ids) + self.assertNotIn(other_workspace.pk, returned_ids) + class ExampleWorkspaceListTest(TestCase): """Tests of the anvil_consortium_manager WorkspaceList view using the ExampleWorkspace adapter.""" @@ -858,6 +881,38 @@ def test_status_code(self): self.assertEqual(response.status_code, 200) +class ReleaseWorkspaceDetailTest(TestCase): + """Tests of the anvil_consortium_manager WorkspaceDetail view using the ReleaseWorkspaceAdapter.""" + + def setUp(self): + """Set up test class.""" + self.factory = RequestFactory() + # Create a user with both view and edit permission. + self.user = User.objects.create_user(username="test", password="test") + self.user.user_permissions.add( + Permission.objects.get( + codename=acm_models.AnVILProjectManagerAccess.VIEW_PERMISSION_CODENAME + ) + ) + self.object = factories.ReleaseWorkspaceFactory.create() + + def get_url(self, *args): + """Get the url for the view being tested.""" + return reverse("anvil_consortium_manager:workspaces:detail", args=args) + + def test_status_code(self): + """Response has a status code of 200.""" + upload_workspace = factories.UploadWorkspaceFactory.create() + self.object.upload_workspaces.add(upload_workspace) + self.client.force_login(self.user) + response = self.client.get( + self.get_url( + self.object.workspace.billing_project.name, self.object.workspace.name + ) + ) + self.assertEqual(response.status_code, 200) + + class WorkspaceReportTest(TestCase): def setUp(self): """Set up test class.""" diff --git a/gregor_django/gregor_anvil/views.py b/gregor_django/gregor_anvil/views.py index dc65c780..3bc3e5c3 100644 --- a/gregor_django/gregor_anvil/views.py +++ b/gregor_django/gregor_anvil/views.py @@ -51,11 +51,14 @@ class UploadWorkspaceAutocomplete( """View to provide autocompletion for UploadWorkspaces.""" def get_queryset(self): - # Filter out unathorized users, or does the auth mixin do that? qs = models.UploadWorkspace.objects.filter().order_by( "workspace__billing_project__name", "workspace__name" ) + consent_group = self.forwarded.get("consent_group", None) + if consent_group: + qs = qs.filter(consent_group=consent_group) + if self.q: qs = qs.filter(workspace__name__icontains=self.q) diff --git a/gregor_django/templates/gregor_anvil/releaseworkspace_detail.html b/gregor_django/templates/gregor_anvil/releaseworkspace_detail.html new file mode 100644 index 00000000..774772b6 --- /dev/null +++ b/gregor_django/templates/gregor_anvil/releaseworkspace_detail.html @@ -0,0 +1,36 @@ +{% extends "anvil_consortium_manager/workspace_detail.html" %} + +{% block workspace_data %} +
+
+
Consent group
{{ object.releaseworkspace.consent_group }}
+
dbGaP accession
{{ object.releaseworkspace.get_dbgap_accession }}
+
Upload workspaces
+ {% for upload_workspace in object.releaseworkspace.upload_workspaces.all %} + {{ upload_workspace }}
+ {% endfor %} +
+
+{% endblock workspace_data %} + +{% block after_panel %} +
+
+
+

+ +

+
+
+ {{ object.releaseworkspace.full_data_use_limitations }} +
+
+
+
+
+ +{{ block.super }} +{% endblock after_panel %} diff --git a/requirements/base.txt b/requirements/base.txt index 5890ba2b..4aa6cad7 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -21,7 +21,7 @@ django-dbbackup==4.0.1 # https://github.com/jazzband/django-dbbackup django-extensions==3.2.1 # https://github.com/django-extensions/django-extensions # anvil_consortium_manager -git+https://github.com/UW-GAC/django-anvil-consortium-manager.git@v0.13 +git+https://github.com/UW-GAC/django-anvil-consortium-manager.git@v0.14 # Simple history - model history tracking django-simple-history==3.1.1 # For tracking history