From c702c085811eb508098dfcac172e730396002fcb Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Mon, 4 Dec 2023 14:30:43 -0800 Subject: [PATCH 01/37] Add a new collab_anlaysis app This app will deal with collaborative analysis workspaces. --- primed/collab_analysis/__init__.py | 0 primed/collab_analysis/admin.py | 3 +++ primed/collab_analysis/apps.py | 6 ++++++ primed/collab_analysis/migrations/__init__.py | 0 primed/collab_analysis/models.py | 3 +++ primed/collab_analysis/tests.py | 3 +++ primed/collab_analysis/views.py | 3 +++ 7 files changed, 18 insertions(+) create mode 100644 primed/collab_analysis/__init__.py create mode 100644 primed/collab_analysis/admin.py create mode 100644 primed/collab_analysis/apps.py create mode 100644 primed/collab_analysis/migrations/__init__.py create mode 100644 primed/collab_analysis/models.py create mode 100644 primed/collab_analysis/tests.py create mode 100644 primed/collab_analysis/views.py diff --git a/primed/collab_analysis/__init__.py b/primed/collab_analysis/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/primed/collab_analysis/admin.py b/primed/collab_analysis/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/primed/collab_analysis/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/primed/collab_analysis/apps.py b/primed/collab_analysis/apps.py new file mode 100644 index 00000000..57e42384 --- /dev/null +++ b/primed/collab_analysis/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CollabAnalysisConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "primed.collab_analysis" diff --git a/primed/collab_analysis/migrations/__init__.py b/primed/collab_analysis/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/primed/collab_analysis/models.py b/primed/collab_analysis/models.py new file mode 100644 index 00000000..71a83623 --- /dev/null +++ b/primed/collab_analysis/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/primed/collab_analysis/tests.py b/primed/collab_analysis/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/primed/collab_analysis/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/primed/collab_analysis/views.py b/primed/collab_analysis/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/primed/collab_analysis/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. From a563f29c6b844cbb9e6a470cd16b563583f35e67 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Mon, 4 Dec 2023 16:02:32 -0800 Subject: [PATCH 02/37] Rename app to collaborative_analysis --- primed/{collab_analysis => collaborative_analysis}/__init__.py | 0 primed/{collab_analysis => collaborative_analysis}/admin.py | 0 primed/{collab_analysis => collaborative_analysis}/apps.py | 2 +- .../migrations/__init__.py | 0 primed/{collab_analysis => collaborative_analysis}/models.py | 0 primed/{collab_analysis => collaborative_analysis}/tests.py | 0 primed/{collab_analysis => collaborative_analysis}/views.py | 0 7 files changed, 1 insertion(+), 1 deletion(-) rename primed/{collab_analysis => collaborative_analysis}/__init__.py (100%) rename primed/{collab_analysis => collaborative_analysis}/admin.py (100%) rename primed/{collab_analysis => collaborative_analysis}/apps.py (75%) rename primed/{collab_analysis => collaborative_analysis}/migrations/__init__.py (100%) rename primed/{collab_analysis => collaborative_analysis}/models.py (100%) rename primed/{collab_analysis => collaborative_analysis}/tests.py (100%) rename primed/{collab_analysis => collaborative_analysis}/views.py (100%) diff --git a/primed/collab_analysis/__init__.py b/primed/collaborative_analysis/__init__.py similarity index 100% rename from primed/collab_analysis/__init__.py rename to primed/collaborative_analysis/__init__.py diff --git a/primed/collab_analysis/admin.py b/primed/collaborative_analysis/admin.py similarity index 100% rename from primed/collab_analysis/admin.py rename to primed/collaborative_analysis/admin.py diff --git a/primed/collab_analysis/apps.py b/primed/collaborative_analysis/apps.py similarity index 75% rename from primed/collab_analysis/apps.py rename to primed/collaborative_analysis/apps.py index 57e42384..d8b69a0e 100644 --- a/primed/collab_analysis/apps.py +++ b/primed/collaborative_analysis/apps.py @@ -3,4 +3,4 @@ class CollabAnalysisConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" - name = "primed.collab_analysis" + name = "primed.collaborative_analysis" diff --git a/primed/collab_analysis/migrations/__init__.py b/primed/collaborative_analysis/migrations/__init__.py similarity index 100% rename from primed/collab_analysis/migrations/__init__.py rename to primed/collaborative_analysis/migrations/__init__.py diff --git a/primed/collab_analysis/models.py b/primed/collaborative_analysis/models.py similarity index 100% rename from primed/collab_analysis/models.py rename to primed/collaborative_analysis/models.py diff --git a/primed/collab_analysis/tests.py b/primed/collaborative_analysis/tests.py similarity index 100% rename from primed/collab_analysis/tests.py rename to primed/collaborative_analysis/tests.py diff --git a/primed/collab_analysis/views.py b/primed/collaborative_analysis/views.py similarity index 100% rename from primed/collab_analysis/views.py rename to primed/collaborative_analysis/views.py From 2b545fe8e4e8a95cbbb37203600fca8fa2666e27 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Mon, 4 Dec 2023 16:08:20 -0800 Subject: [PATCH 03/37] Add collaborative_analysis to installed apps --- config/settings/base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/config/settings/base.py b/config/settings/base.py index 4821ea98..48a863fc 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -96,6 +96,7 @@ "primed.miscellaneous_workspaces", "primed.duo", "primed.cdsa", + "primed.collaborative_analysis", ] # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps From 2cf8fd37daf12f72cdf858b218f166e885d1f1fb Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Tue, 5 Dec 2023 11:43:13 -0800 Subject: [PATCH 04/37] Add a new CollaborativeAnalysisWorkspace model --- .../migrations/0001_initial.py | 161 ++++++++++++++++++ primed/collaborative_analysis/models.py | 26 ++- primed/collaborative_analysis/tests.py | 3 - .../collaborative_analysis/tests/__init__.py | 0 .../collaborative_analysis/tests/factories.py | 41 +++++ .../tests/test_models.py | 43 +++++ 6 files changed, 270 insertions(+), 4 deletions(-) create mode 100644 primed/collaborative_analysis/migrations/0001_initial.py delete mode 100644 primed/collaborative_analysis/tests.py create mode 100644 primed/collaborative_analysis/tests/__init__.py create mode 100644 primed/collaborative_analysis/tests/factories.py create mode 100644 primed/collaborative_analysis/tests/test_models.py diff --git a/primed/collaborative_analysis/migrations/0001_initial.py b/primed/collaborative_analysis/migrations/0001_initial.py new file mode 100644 index 00000000..42d22260 --- /dev/null +++ b/primed/collaborative_analysis/migrations/0001_initial.py @@ -0,0 +1,161 @@ +# Generated by Django 4.2.7 on 2023-12-05 00:05 + +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): + + initial = True + + dependencies = [ + ("anvil_consortium_manager", "0015_add_new_permissions"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="HistoricalCollaborativeAnalysisWorkspace", + 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" + ), + ), + ( + "proposal_id", + models.IntegerField( + blank=True, + help_text="The ID of the proposal that this workspace is associated with.", + 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, + ), + ), + ( + "custodian", + models.ForeignKey( + blank=True, + db_constraint=False, + help_text="The custodian for this workspace.", + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "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 collaborative analysis workspace", + "verbose_name_plural": "historical collaborative analysis workspaces", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name="CollaborativeAnalysisWorkspace", + 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" + ), + ), + ( + "proposal_id", + models.IntegerField( + blank=True, + help_text="The ID of the proposal that this workspace is associated with.", + null=True, + ), + ), + ( + "custodian", + models.ForeignKey( + help_text="The custodian for this workspace.", + on_delete=django.db.models.deletion.PROTECT, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "source_workspaces", + models.ManyToManyField( + help_text="Workspaces contributing data to this workspace.", + related_name="collaborative_analysis_workspaces", + to="anvil_consortium_manager.workspace", + ), + ), + ( + "workspace", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + to="anvil_consortium_manager.workspace", + ), + ), + ], + options={ + "get_latest_by": "modified", + "abstract": False, + }, + ), + ] diff --git a/primed/collaborative_analysis/models.py b/primed/collaborative_analysis/models.py index 71a83623..10732eee 100644 --- a/primed/collaborative_analysis/models.py +++ b/primed/collaborative_analysis/models.py @@ -1,3 +1,27 @@ +from anvil_consortium_manager.models import BaseWorkspaceData, Workspace +from django.conf import settings from django.db import models +from django_extensions.db.models import TimeStampedModel -# Create your models here. + +# Note that "RequesterModel" is not included, because we have the "custodian" tracked instead. +class CollaborativeAnalysisWorkspace(TimeStampedModel, BaseWorkspaceData): + + source_workspaces = models.ManyToManyField( + Workspace, + related_name="collaborative_analysis_workspaces", + help_text="Workspaces contributing data to this workspace.", + ) + proposal_id = models.IntegerField( + help_text="The ID of the proposal that this workspace is associated with.", + blank=True, + null=True, + ) + # Other options: + # manager, organizer, supervisor, overseer, controller, organizer, + # custodian, caretaker, warden, + custodian = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + help_text="The custodian for this workspace.", + ) diff --git a/primed/collaborative_analysis/tests.py b/primed/collaborative_analysis/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/primed/collaborative_analysis/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/primed/collaborative_analysis/tests/__init__.py b/primed/collaborative_analysis/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/primed/collaborative_analysis/tests/factories.py b/primed/collaborative_analysis/tests/factories.py new file mode 100644 index 00000000..e8086f37 --- /dev/null +++ b/primed/collaborative_analysis/tests/factories.py @@ -0,0 +1,41 @@ +from anvil_consortium_manager.tests.factories import ( + ManagedGroupFactory, + WorkspaceFactory, +) +from factory import SubFactory, post_generation +from factory.django import DjangoModelFactory + +from primed.users.tests.factories import UserFactory + +from .. import models + + +class CollaborativeAnalysisWorkspaceFactory(DjangoModelFactory): + class Meta: + model = models.CollaborativeAnalysisWorkspace + skip_postgeneration_save = True + + workspace = SubFactory(WorkspaceFactory, workspace_type="collab_analysis") + custodian = SubFactory(UserFactory) + + @post_generation + def authorization_domains(self, create, extracted, **kwargs): + # Add an authorization domain. + if not create: + # Simple build, do nothing. + return + + # Create an authorization domain. + auth_domain = ManagedGroupFactory.create() + self.workspace.authorization_domains.add(auth_domain) + + @post_generation + def source_workspaces(self, create, extracted, **kwargs): + # Make sure at least one is added. + print(create) + print(extracted) + if not create or not extracted: + # Simple build, do nothing. + return + + self.source_workspaces.add(*extracted) diff --git a/primed/collaborative_analysis/tests/test_models.py b/primed/collaborative_analysis/tests/test_models.py new file mode 100644 index 00000000..49d99e0f --- /dev/null +++ b/primed/collaborative_analysis/tests/test_models.py @@ -0,0 +1,43 @@ +from anvil_consortium_manager.tests.factories import WorkspaceFactory +from django.test import TestCase + +from primed.users.tests.factories import UserFactory + +from .. import models +from . import factories + + +class CollaborativeAnalysisWorkspaceTest(TestCase): + """Tests for the DataPrepWorkspace model.""" + + def test_model_saving(self): + """Creation using the model constructor and .save() works.""" + workspace = WorkspaceFactory.create() + user = UserFactory.create() + instance = models.CollaborativeAnalysisWorkspace( + workspace=workspace, + custodian=user, + ) + instance.save() + self.assertIsInstance(instance, models.CollaborativeAnalysisWorkspace) + + def test_str_method(self): + instance = factories.CollaborativeAnalysisWorkspaceFactory.create() + self.assertIsInstance(str(instance), str) + self.assertEqual(str(instance), str(instance.workspace)) + + def test_one_source_workspace(self): + source_workspace = WorkspaceFactory.create() + instance = factories.CollaborativeAnalysisWorkspaceFactory.create() + instance.source_workspaces.add(source_workspace) + self.assertEqual(len(instance.source_workspaces.all()), 1) + self.assertIn(source_workspace, instance.source_workspaces.all()) + + def test_two_source_workspaces(self): + source_workspace_1 = WorkspaceFactory.create() + source_workspace_2 = WorkspaceFactory.create() + instance = factories.CollaborativeAnalysisWorkspaceFactory.create() + instance.source_workspaces.add(source_workspace_1, source_workspace_2) + self.assertEqual(len(instance.source_workspaces.all()), 2) + self.assertIn(source_workspace_1, instance.source_workspaces.all()) + self.assertIn(source_workspace_2, instance.source_workspaces.all()) From 13f9a70a913a6774cda380cdc14da5d94ca6b8b8 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Tue, 5 Dec 2023 12:09:37 -0800 Subject: [PATCH 05/37] Add a purpose field to the CollaborativeAnalysis model --- .../migrations/0001_initial.py | 16 ++++++++++++++-- primed/collaborative_analysis/models.py | 4 ++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/primed/collaborative_analysis/migrations/0001_initial.py b/primed/collaborative_analysis/migrations/0001_initial.py index 42d22260..a66cdbdb 100644 --- a/primed/collaborative_analysis/migrations/0001_initial.py +++ b/primed/collaborative_analysis/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.7 on 2023-12-05 00:05 +# Generated by Django 4.2.7 on 2023-12-05 20:08 from django.conf import settings from django.db import migrations, models @@ -12,8 +12,8 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("anvil_consortium_manager", "0015_add_new_permissions"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("anvil_consortium_manager", "0015_add_new_permissions"), ] operations = [ @@ -38,6 +38,12 @@ class Migration(migrations.Migration): auto_now=True, verbose_name="modified" ), ), + ( + "purpose", + models.TextField( + blank=True, help_text="The intended purpose for this workspace." + ), + ), ( "proposal_id", models.IntegerField( @@ -121,6 +127,12 @@ class Migration(migrations.Migration): auto_now=True, verbose_name="modified" ), ), + ( + "purpose", + models.TextField( + blank=True, help_text="The intended purpose for this workspace." + ), + ), ( "proposal_id", models.IntegerField( diff --git a/primed/collaborative_analysis/models.py b/primed/collaborative_analysis/models.py index 10732eee..42cd2eb6 100644 --- a/primed/collaborative_analysis/models.py +++ b/primed/collaborative_analysis/models.py @@ -7,6 +7,10 @@ # Note that "RequesterModel" is not included, because we have the "custodian" tracked instead. class CollaborativeAnalysisWorkspace(TimeStampedModel, BaseWorkspaceData): + purpose = models.TextField( + help_text="The intended purpose for this workspace.", + blank=True, + ) source_workspaces = models.ManyToManyField( Workspace, related_name="collaborative_analysis_workspaces", From 4c46b51960418e573564d03efc052a8a1a1b817f Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Tue, 5 Dec 2023 12:10:19 -0800 Subject: [PATCH 06/37] Rearrange field definitions --- primed/collaborative_analysis/models.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/primed/collaborative_analysis/models.py b/primed/collaborative_analysis/models.py index 42cd2eb6..a95086e2 100644 --- a/primed/collaborative_analysis/models.py +++ b/primed/collaborative_analysis/models.py @@ -11,11 +11,6 @@ class CollaborativeAnalysisWorkspace(TimeStampedModel, BaseWorkspaceData): help_text="The intended purpose for this workspace.", blank=True, ) - source_workspaces = models.ManyToManyField( - Workspace, - related_name="collaborative_analysis_workspaces", - help_text="Workspaces contributing data to this workspace.", - ) proposal_id = models.IntegerField( help_text="The ID of the proposal that this workspace is associated with.", blank=True, @@ -29,3 +24,8 @@ class CollaborativeAnalysisWorkspace(TimeStampedModel, BaseWorkspaceData): on_delete=models.PROTECT, help_text="The custodian for this workspace.", ) + source_workspaces = models.ManyToManyField( + Workspace, + related_name="collaborative_analysis_workspaces", + help_text="Workspaces contributing data to this workspace.", + ) From dbd0af16927e6f08a64f4548cabed40caf277723 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Tue, 5 Dec 2023 14:38:03 -0800 Subject: [PATCH 07/37] Add a form for CollaborativeAnalysisWorkspaces --- primed/collaborative_analysis/forms.py | 53 ++++++ .../migrations/0001_initial.py | 8 +- primed/collaborative_analysis/models.py | 1 - .../tests/test_forms.py | 157 ++++++++++++++++++ 4 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 primed/collaborative_analysis/forms.py create mode 100644 primed/collaborative_analysis/tests/test_forms.py diff --git a/primed/collaborative_analysis/forms.py b/primed/collaborative_analysis/forms.py new file mode 100644 index 00000000..63daa1b1 --- /dev/null +++ b/primed/collaborative_analysis/forms.py @@ -0,0 +1,53 @@ +from anvil_consortium_manager.forms import Bootstrap5MediaFormMixin +from dal import autocomplete +from django import forms +from django.core.exceptions import ValidationError + +from . import models + + +class CollaborativeAnalysisWorkspaceForm(Bootstrap5MediaFormMixin, forms.ModelForm): + """Form for a dbGaPWorkspace object.""" + + class Meta: + model = models.CollaborativeAnalysisWorkspace + fields = ( + "purpose", + "proposal_id", + "source_workspaces", + "custodian", + "workspace", + ) + widgets = { + "dbgap_study_accession": autocomplete.ModelSelect2Multiple( + url="anvil:workspaces:autocomplete", + attrs={"data-theme": "bootstrap-5"}, + ), + } + # "data_use_modifiers": forms.CheckboxSelectMultiple, + # "requested_by": autocomplete.ModelSelect2( + # url="users:autocomplete", + # attrs={"data-theme": "bootstrap-5"}, + # ), + # "available_data": forms.CheckboxSelectMultiple, + # } + # help_texts = { + # "data_use_modifiers": """The DataUseModifiers associated with this study-consent group. + # --- represents a child modifier.""" + # } + + def clean(self): + """Custom checks: + + - workspace and source_workspace are different. + """ + cleaned_data = super().clean() + + # Workspace is not also a source_workspace. + workspace = cleaned_data.get("workspace", None) + source_workspaces = cleaned_data.get("source_workspaces", []) + if workspace and source_workspaces: + if workspace in source_workspaces: + raise ValidationError("source_workspaces cannot include workspace.") + + return cleaned_data diff --git a/primed/collaborative_analysis/migrations/0001_initial.py b/primed/collaborative_analysis/migrations/0001_initial.py index a66cdbdb..6eb7662c 100644 --- a/primed/collaborative_analysis/migrations/0001_initial.py +++ b/primed/collaborative_analysis/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.7 on 2023-12-05 20:08 +# Generated by Django 4.2.7 on 2023-12-05 22:20 from django.conf import settings from django.db import migrations, models @@ -12,8 +12,8 @@ class Migration(migrations.Migration): initial = True dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), ("anvil_consortium_manager", "0015_add_new_permissions"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ @@ -41,7 +41,7 @@ class Migration(migrations.Migration): ( "purpose", models.TextField( - blank=True, help_text="The intended purpose for this workspace." + help_text="The intended purpose for this workspace." ), ), ( @@ -130,7 +130,7 @@ class Migration(migrations.Migration): ( "purpose", models.TextField( - blank=True, help_text="The intended purpose for this workspace." + help_text="The intended purpose for this workspace." ), ), ( diff --git a/primed/collaborative_analysis/models.py b/primed/collaborative_analysis/models.py index a95086e2..b98ffd11 100644 --- a/primed/collaborative_analysis/models.py +++ b/primed/collaborative_analysis/models.py @@ -9,7 +9,6 @@ class CollaborativeAnalysisWorkspace(TimeStampedModel, BaseWorkspaceData): purpose = models.TextField( help_text="The intended purpose for this workspace.", - blank=True, ) proposal_id = models.IntegerField( help_text="The ID of the proposal that this workspace is associated with.", diff --git a/primed/collaborative_analysis/tests/test_forms.py b/primed/collaborative_analysis/tests/test_forms.py new file mode 100644 index 00000000..1104b166 --- /dev/null +++ b/primed/collaborative_analysis/tests/test_forms.py @@ -0,0 +1,157 @@ +from anvil_consortium_manager.tests.factories import WorkspaceFactory +from django.core.exceptions import NON_FIELD_ERRORS +from django.test import TestCase + +from primed.users.tests.factories import UserFactory + +from .. import forms + + +class CollaborativeAnalysisWorkspaceFormTest(TestCase): + """Tests for the CollaborativeAnalysisWorkspaceForm class.""" + + form_class = forms.CollaborativeAnalysisWorkspaceForm + + def setUp(self): + """Create a workspace for use in the form.""" + self.custodian = UserFactory.create() + self.workspace = WorkspaceFactory.create() + self.source_workspace = WorkspaceFactory.create() + + def test_valid(self): + """Form is valid with necessary input.""" + form_data = { + "purpose": "test", + "source_workspaces": [self.source_workspace], + "custodian": self.custodian, + "workspace": self.workspace, + } + form = self.form_class(data=form_data) + self.assertTrue(form.is_valid()) + + def test_valid_with_proposal_id(self): + form_data = { + "purpose": "test", + "proposal_id": 1, + "source_workspaces": [self.source_workspace], + "custodian": self.custodian, + "workspace": self.workspace, + } + form = self.form_class(data=form_data) + self.assertTrue(form.is_valid()) + + def test_invalid_with_proposal_id_blank(self): + form_data = { + "purpose": "test", + "proposal_id": 1, + "source_workspaces": [self.source_workspace], + "custodian": self.custodian, + "workspace": self.workspace, + } + form = self.form_class(data=form_data) + self.assertTrue(form.is_valid()) + + def test_invalid_with_proposal_id_character(self): + form_data = { + "purpose": "test", + "proposal_id": "a", + "source_workspaces": [self.source_workspace], + "custodian": self.custodian, + "workspace": self.workspace, + } + form = self.form_class(data=form_data) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertIn("proposal_id", form.errors) + self.assertEqual(len(form.errors["proposal_id"]), 1) + self.assertIn("whole number", form.errors["proposal_id"][0]) + + def test_valid_two_source_workspaces(self): + """Form is valid with necessary input.""" + source_workspace_2 = WorkspaceFactory.create() + form_data = { + "purpose": "test", + "source_workspaces": [self.source_workspace, source_workspace_2], + "custodian": self.custodian, + "workspace": self.workspace, + } + form = self.form_class(data=form_data) + self.assertTrue(form.is_valid()) + + def test_invalid_missing_purpose(self): + """Form is invalid when missing source_workspaces.""" + form_data = { + # "purpose": "test", + "source_workspaces": [self.source_workspace], + "custodian": self.custodian, + "workspace": self.workspace, + } + form = self.form_class(data=form_data) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertIn("purpose", form.errors) + self.assertEqual(len(form.errors["purpose"]), 1) + self.assertIn("required", form.errors["purpose"][0]) + + def test_invalid_missing_source_workspaces(self): + """Form is invalid when missing source_workspaces.""" + form_data = { + "purpose": "test", + # "source_workspaces": [self.source_workspace], + "custodian": self.custodian, + "workspace": self.workspace, + } + form = self.form_class(data=form_data) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertIn("source_workspaces", form.errors) + self.assertEqual(len(form.errors["source_workspaces"]), 1) + self.assertIn("required", form.errors["source_workspaces"][0]) + + def test_invalid_same_workspace_and_source_workspace(self): + """Form is invalid when missing source_workspaces.""" + form_data = { + "purpose": "test", + "source_workspaces": [self.workspace], + "custodian": self.custodian, + "workspace": self.workspace, + } + form = self.form_class(data=form_data) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertIn(NON_FIELD_ERRORS, form.errors) + self.assertEqual(len(form.errors[NON_FIELD_ERRORS]), 1) + self.assertIn("cannot include workspace", form.errors[NON_FIELD_ERRORS][0]) + + def test_invalid_custodian(self): + """Form is invalid when missing custodian.""" + form_data = { + "purpose": "test", + "source_workspaces": [self.source_workspace], + # "custodian": self.custodian, + "workspace": self.workspace, + } + form = self.form_class(data=form_data) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertIn("custodian", form.errors) + self.assertEqual(len(form.errors["custodian"]), 1) + self.assertIn("required", form.errors["custodian"][0]) + + def test_invalid_workspace(self): + """Form is invalid when missing workspace.""" + form_data = { + "purpose": "test", + "source_workspaces": [self.source_workspace], + "custodian": self.custodian, + # "workspace": self.workspace, + } + 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_source_workspace_types(self): + # pass From dd3caa59afb82156e970340a597bfa6020ccddcb Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Tue, 5 Dec 2023 14:40:44 -0800 Subject: [PATCH 08/37] Fix form widget for source_workspaces --- primed/collaborative_analysis/forms.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/primed/collaborative_analysis/forms.py b/primed/collaborative_analysis/forms.py index 63daa1b1..7b6da702 100644 --- a/primed/collaborative_analysis/forms.py +++ b/primed/collaborative_analysis/forms.py @@ -19,8 +19,8 @@ class Meta: "workspace", ) widgets = { - "dbgap_study_accession": autocomplete.ModelSelect2Multiple( - url="anvil:workspaces:autocomplete", + "source_workspaces": autocomplete.ModelSelect2Multiple( + url="anvil_consortium_manager:workspaces:autocomplete", attrs={"data-theme": "bootstrap-5"}, ), } From f7e30be76f50a0a68d1af563753c240de3161b20 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Tue, 5 Dec 2023 14:42:00 -0800 Subject: [PATCH 09/37] Add autocomplete for custodian in CollaborativeAnalysis form --- primed/collaborative_analysis/forms.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/primed/collaborative_analysis/forms.py b/primed/collaborative_analysis/forms.py index 7b6da702..65c4d944 100644 --- a/primed/collaborative_analysis/forms.py +++ b/primed/collaborative_analysis/forms.py @@ -23,6 +23,10 @@ class Meta: url="anvil_consortium_manager:workspaces:autocomplete", attrs={"data-theme": "bootstrap-5"}, ), + "custodian": autocomplete.ModelSelect2( + url="users:autocomplete", + attrs={"data-theme": "bootstrap-5"}, + ), } # "data_use_modifiers": forms.CheckboxSelectMultiple, # "requested_by": autocomplete.ModelSelect2( From 1b9fe6ab959a2b742827f7e4ac65e7b684d41700 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Tue, 5 Dec 2023 14:42:18 -0800 Subject: [PATCH 10/37] Remove commented out code --- primed/collaborative_analysis/forms.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/primed/collaborative_analysis/forms.py b/primed/collaborative_analysis/forms.py index 65c4d944..7c80f491 100644 --- a/primed/collaborative_analysis/forms.py +++ b/primed/collaborative_analysis/forms.py @@ -28,17 +28,6 @@ class Meta: attrs={"data-theme": "bootstrap-5"}, ), } - # "data_use_modifiers": forms.CheckboxSelectMultiple, - # "requested_by": autocomplete.ModelSelect2( - # url="users:autocomplete", - # attrs={"data-theme": "bootstrap-5"}, - # ), - # "available_data": forms.CheckboxSelectMultiple, - # } - # help_texts = { - # "data_use_modifiers": """The DataUseModifiers associated with this study-consent group. - # --- represents a child modifier.""" - # } def clean(self): """Custom checks: From 15bd439cc16e1887d8d69d5f2eb51b9d2bb28c0f Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Tue, 5 Dec 2023 14:44:22 -0800 Subject: [PATCH 11/37] Add and register a basic adapter for collab analysis workspaces This adapter has no customization other than the workspace data form yet. --- config/settings/base.py | 1 + primed/collaborative_analysis/adapters.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 primed/collaborative_analysis/adapters.py diff --git a/config/settings/base.py b/config/settings/base.py index 48a863fc..5d4b7f9c 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -377,6 +377,7 @@ "primed.miscellaneous_workspaces.adapters.SimulatedDataWorkspaceAdapter", "primed.miscellaneous_workspaces.adapters.ResourceWorkspaceAdapter", "primed.miscellaneous_workspaces.adapters.ConsortiumDevelWorkspaceAdapter", + "primed.collaborative_analysis.adapters.CollaborativeAnalysisWorkspaceAdapter", "primed.miscellaneous_workspaces.adapters.TemplateWorkspaceAdapter", "primed.miscellaneous_workspaces.adapters.DataPrepWorkspaceAdapter", ] diff --git a/primed/collaborative_analysis/adapters.py b/primed/collaborative_analysis/adapters.py new file mode 100644 index 00000000..47d1e56d --- /dev/null +++ b/primed/collaborative_analysis/adapters.py @@ -0,0 +1,23 @@ +from anvil_consortium_manager.adapters.workspace import BaseWorkspaceAdapter +from anvil_consortium_manager.forms import WorkspaceForm + +from primed.primed_anvil.tables import ( + DefaultWorkspaceStaffTable, + DefaultWorkspaceUserTable, +) + +from . import forms, models + + +class CollaborativeAnalysisWorkspaceAdapter(BaseWorkspaceAdapter): + """Adapter for CollaborativeAnalysisWorkspace.""" + + type = "collab_analysis" + name = "Collaborative Analysis workspace" + description = "Workspaces used for collaborative analyses" + list_table_class_staff_view = DefaultWorkspaceStaffTable + list_table_class_view = DefaultWorkspaceUserTable + workspace_form_class = WorkspaceForm + workspace_data_model = models.CollaborativeAnalysisWorkspace + workspace_data_form_class = forms.CollaborativeAnalysisWorkspaceForm + workspace_detail_template_name = "anvil_consortium_manager/workspace_detail.html" From f616aec0b57be851c0c0db0b74da5bf035ecdaa9 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Tue, 5 Dec 2023 15:43:59 -0800 Subject: [PATCH 12/37] Use a custom template for CollaborativeAnalysisWorkspaces --- primed/collaborative_analysis/adapters.py | 4 +- .../tests/test_views.py | 270 ++++++++++++++++++ ...collaborativeanalysisworkspace_detail.html | 18 ++ 3 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 primed/collaborative_analysis/tests/test_views.py create mode 100644 primed/templates/collaborative_analysis/collaborativeanalysisworkspace_detail.html diff --git a/primed/collaborative_analysis/adapters.py b/primed/collaborative_analysis/adapters.py index 47d1e56d..f174727f 100644 --- a/primed/collaborative_analysis/adapters.py +++ b/primed/collaborative_analysis/adapters.py @@ -20,4 +20,6 @@ class CollaborativeAnalysisWorkspaceAdapter(BaseWorkspaceAdapter): workspace_form_class = WorkspaceForm workspace_data_model = models.CollaborativeAnalysisWorkspace workspace_data_form_class = forms.CollaborativeAnalysisWorkspaceForm - workspace_detail_template_name = "anvil_consortium_manager/workspace_detail.html" + workspace_detail_template_name = ( + "collaborative_analysis/collaborativeanalysisworkspace_detail.html" + ) diff --git a/primed/collaborative_analysis/tests/test_views.py b/primed/collaborative_analysis/tests/test_views.py new file mode 100644 index 00000000..94e9331a --- /dev/null +++ b/primed/collaborative_analysis/tests/test_views.py @@ -0,0 +1,270 @@ +"""Tests for views related to the `collaborative_analysis` app.""" + +import responses +from anvil_consortium_manager.models import AnVILProjectManagerAccess, Workspace +from anvil_consortium_manager.tests.factories import BillingProjectFactory +from anvil_consortium_manager.tests.utils import AnVILAPIMockTestMixin +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission +from django.test import TestCase +from django.urls import reverse + +from primed.cdsa.tests.factories import CDSAWorkspaceFactory +from primed.dbgap.tests.factories import dbGaPWorkspaceFactory +from primed.miscellaneous_workspaces.tests.factories import OpenAccessWorkspaceFactory +from primed.users.tests.factories import UserFactory + +from .. import models +from . import factories + +User = get_user_model() + + +class CollaborativeAnalysisWorkspaceDetailTest(TestCase): + """Tests of the WorkspaceDetail view from ACM with this app's CollaborativeAnalysisWorkspace model.""" + + def setUp(self): + """Set up test class.""" + # 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=AnVILProjectManagerAccess.STAFF_VIEW_PERMISSION_CODENAME + ) + ) + + def test_status_code_with_user_permission(self): + """Returns successful response code.""" + obj = factories.CollaborativeAnalysisWorkspaceFactory.create() + self.client.force_login(self.user) + response = self.client.get(obj.workspace.get_absolute_url()) + self.assertEqual(response.status_code, 200) + + def test_links_to_source_workspace(self): + """Links to the source workspace appear on the detail page.""" + # TODO: Move this to a table in the context data when ACM allows. + obj = factories.CollaborativeAnalysisWorkspaceFactory.create() + dbgap_workspace = dbGaPWorkspaceFactory.create() + cdsa_workspace = CDSAWorkspaceFactory.create() + open_access_workspace = OpenAccessWorkspaceFactory.create() + obj.source_workspaces.add( + dbgap_workspace.workspace, + cdsa_workspace.workspace, + open_access_workspace.workspace, + ) + self.client.force_login(self.user) + response = self.client.get(obj.workspace.get_absolute_url()) + self.assertIn(dbgap_workspace.get_absolute_url(), response.content.decode()) + self.assertIn(cdsa_workspace.get_absolute_url(), response.content.decode()) + self.assertIn( + open_access_workspace.get_absolute_url(), response.content.decode() + ) + + def test_link_to_custodian(self): + """Links to the custodian's user profile appear on the detail page.""" + # TODO: Move this to a table in the context data when ACM allows. + custodian = UserFactory.create() + obj = factories.CollaborativeAnalysisWorkspaceFactory.create( + custodian=custodian + ) + self.client.force_login(self.user) + response = self.client.get(obj.workspace.get_absolute_url()) + self.assertIn(custodian.get_absolute_url(), response.content.decode()) + + +class CollaborativeAnalysisWorkspaceCreateTest(AnVILAPIMockTestMixin, TestCase): + """Tests of the WorkspaceCreate view from ACM with this app's CollaborativeAnalysisWorkspace model.""" + + api_success_code = 201 + + def setUp(self): + """Set up test class.""" + # The superclass uses the responses package to mock API responses. + super().setUp() + # 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=AnVILProjectManagerAccess.STAFF_VIEW_PERMISSION_CODENAME + ) + ) + self.user.user_permissions.add( + Permission.objects.get( + codename=AnVILProjectManagerAccess.STAFF_EDIT_PERMISSION_CODENAME + ) + ) + self.workspace_type = "collab_analysis" + self.custodian = UserFactory.create() + self.source_workspace = dbGaPWorkspaceFactory.create() + + def get_url(self, *args): + """Get the url for the view being tested.""" + return reverse("anvil_consortium_manager:workspaces:new", args=args) + + def test_creates_workspace(self): + """Posting valid data to the form creates a workspace data object when using a custom adapter.""" + billing_project = BillingProjectFactory.create(name="test-billing-project") + url = self.api_client.rawls_entry_point + "/api/workspaces" + json_data = { + "namespace": "test-billing-project", + "name": "test-workspace", + "attributes": {}, + } + self.anvil_response_mock.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_type), + { + "billing_project": billing_project.pk, + "name": "test-workspace", + # Workspace data form. + "workspacedata-TOTAL_FORMS": 1, + "workspacedata-INITIAL_FORMS": 0, + "workspacedata-MIN_NUM_FORMS": 1, + "workspacedata-MAX_NUM_FORMS": 1, + "workspacedata-0-custodian": self.custodian.pk, + "workspacedata-0-source_workspaces": [self.source_workspace.pk], + "workspacedata-0-purpose": "test", + }, + ) + self.assertEqual(response.status_code, 302) + # The workspace is created. + new_workspace = Workspace.objects.latest("pk") + # Workspace data is added. + self.assertEqual(models.CollaborativeAnalysisWorkspace.objects.count(), 1) + new_workspace_data = models.CollaborativeAnalysisWorkspace.objects.latest("pk") + self.assertEqual(new_workspace_data.workspace, new_workspace) + + +class CollaborativeAnalysisWorkspaceImportTest(AnVILAPIMockTestMixin, TestCase): + """Tests of the WorkspaceImport view from ACM with this app's CollaborativeAnalysisWorkspace model.""" + + api_success_code = 200 + + def setUp(self): + """Set up test class.""" + # The superclass uses the responses package to mock API responses. + super().setUp() + # 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=AnVILProjectManagerAccess.STAFF_VIEW_PERMISSION_CODENAME + ) + ) + self.user.user_permissions.add( + Permission.objects.get( + codename=AnVILProjectManagerAccess.STAFF_EDIT_PERMISSION_CODENAME + ) + ) + self.custodian = UserFactory.create() + self.source_workspace = dbGaPWorkspaceFactory.create() + self.workspace_type = "collab_analysis" + + def get_url(self, *args): + """Get the url for the view being tested.""" + return reverse("anvil_consortium_manager:workspaces:import", args=args) + + def get_api_url(self, billing_project_name, workspace_name): + """Return the Terra API url for a given billing project and workspace.""" + return ( + self.api_client.rawls_entry_point + + "/api/workspaces/" + + billing_project_name + + "/" + + workspace_name + ) + + def get_api_json_response( + self, billing_project, workspace, authorization_domains=[], access="OWNER" + ): + """Return a pared down version of the json response from the AnVIL API with only fields we need.""" + json_data = { + "accessLevel": access, + "owners": [], + "workspace": { + "authorizationDomain": [ + {"membersGroupName": x} for x in authorization_domains + ], + "name": workspace, + "namespace": billing_project, + "isLocked": False, + }, + } + return json_data + + def test_creates_workspace(self): + """Posting valid data to the form creates an UploadWorkspace object.""" + billing_project = BillingProjectFactory.create(name="billing-project") + workspace_name = "workspace" + # Available workspaces API call. + workspace_list_url = self.api_client.rawls_entry_point + "/api/workspaces" + self.anvil_response_mock.add( + responses.GET, + workspace_list_url, + match=[ + responses.matchers.query_param_matcher( + {"fields": "workspace.namespace,workspace.name,accessLevel"} + ) + ], + status=200, + json=[self.get_api_json_response(billing_project.name, workspace_name)], + ) + url = self.get_api_url(billing_project.name, workspace_name) + self.anvil_response_mock.add( + responses.GET, + url, + status=self.api_success_code, + json=self.get_api_json_response(billing_project.name, workspace_name), + ) + # ACL API call. + api_url_acl = ( + self.api_client.rawls_entry_point + + "/api/workspaces/" + + billing_project.name + + "/" + + workspace_name + + "/acl" + ) + self.anvil_response_mock.add( + responses.GET, + api_url_acl, + status=200, + json={ + "acl": { + self.service_account_email: { + "accessLevel": "OWNER", + "canCompute": True, + "canShare": True, + "pending": False, + } + } + }, + ) + self.client.force_login(self.user) + response = self.client.post( + self.get_url(self.workspace_type), + { + "workspace": billing_project.name + "/" + workspace_name, + # Workspace data form. + "workspacedata-TOTAL_FORMS": 1, + "workspacedata-INITIAL_FORMS": 0, + "workspacedata-MIN_NUM_FORMS": 1, + "workspacedata-MAX_NUM_FORMS": 1, + "workspacedata-0-custodian": self.custodian.pk, + "workspacedata-0-source_workspaces": [self.source_workspace.pk], + "workspacedata-0-purpose": "test", + }, + ) + self.assertEqual(response.status_code, 302) + # The workspace is created. + new_workspace = Workspace.objects.latest("pk") + # Workspace data is added. + self.assertEqual(models.CollaborativeAnalysisWorkspace.objects.count(), 1) + new_workspace_data = models.CollaborativeAnalysisWorkspace.objects.latest("pk") + self.assertEqual(new_workspace_data.workspace, new_workspace) diff --git a/primed/templates/collaborative_analysis/collaborativeanalysisworkspace_detail.html b/primed/templates/collaborative_analysis/collaborativeanalysisworkspace_detail.html new file mode 100644 index 00000000..477446bb --- /dev/null +++ b/primed/templates/collaborative_analysis/collaborativeanalysisworkspace_detail.html @@ -0,0 +1,18 @@ +{% extends "anvil_consortium_manager/workspace_detail.html" %} + +{% block workspace_data %} +
+
+
Custodian
+ + {{ workspace_data_object.custodian.name }} + +
+ +
Source workspaces
+ {% for workspace in workspace_data_object.source_workspaces.all %} +

{{ workspace }}

+ {% endfor %} +
+
+{% endblock workspace_data %} From 30fca54800857d1d8ce0035cba5b397136319b2c Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Tue, 5 Dec 2023 17:11:10 -0800 Subject: [PATCH 13/37] Add custom tables for CollaborativeAnalysisWorkspaces --- primed/collaborative_analysis/adapters.py | 11 +- primed/collaborative_analysis/tables.py | 55 +++++++++ .../tests/test_tables.py | 104 ++++++++++++++++++ 3 files changed, 162 insertions(+), 8 deletions(-) create mode 100644 primed/collaborative_analysis/tables.py create mode 100644 primed/collaborative_analysis/tests/test_tables.py diff --git a/primed/collaborative_analysis/adapters.py b/primed/collaborative_analysis/adapters.py index f174727f..0e439385 100644 --- a/primed/collaborative_analysis/adapters.py +++ b/primed/collaborative_analysis/adapters.py @@ -1,12 +1,7 @@ from anvil_consortium_manager.adapters.workspace import BaseWorkspaceAdapter from anvil_consortium_manager.forms import WorkspaceForm -from primed.primed_anvil.tables import ( - DefaultWorkspaceStaffTable, - DefaultWorkspaceUserTable, -) - -from . import forms, models +from . import forms, models, tables class CollaborativeAnalysisWorkspaceAdapter(BaseWorkspaceAdapter): @@ -15,8 +10,8 @@ class CollaborativeAnalysisWorkspaceAdapter(BaseWorkspaceAdapter): type = "collab_analysis" name = "Collaborative Analysis workspace" description = "Workspaces used for collaborative analyses" - list_table_class_staff_view = DefaultWorkspaceStaffTable - list_table_class_view = DefaultWorkspaceUserTable + list_table_class_staff_view = tables.CollaborativeAnalysisWorkspaceStaffTable + list_table_class_view = tables.CollaborativeAnalysisWorkspaceUserTable workspace_form_class = WorkspaceForm workspace_data_model = models.CollaborativeAnalysisWorkspace workspace_data_form_class = forms.CollaborativeAnalysisWorkspaceForm diff --git a/primed/collaborative_analysis/tables.py b/primed/collaborative_analysis/tables.py new file mode 100644 index 00000000..a17c5cb1 --- /dev/null +++ b/primed/collaborative_analysis/tables.py @@ -0,0 +1,55 @@ +import django_tables2 as tables +from anvil_consortium_manager.models import Workspace + + +class CollaborativeAnalysisWorkspaceStaffTable(tables.Table): + """Class to render a table of Workspace objects with CollaborativeAnalysisWorkspace data.""" + + name = tables.columns.Column(linkify=True) + billing_project = tables.Column(linkify=True) + collaborativeanalysisworkspace__custodian = tables.Column(linkify=True) + number_source_workspaces = tables.columns.Column( + accessor="pk", + verbose_name="Number of source workspaces", + orderable=False, + ) + + class Meta: + model = Workspace + fields = ( + "name", + "billing_project", + "collaborativeanalysisworkspace__custodian", + "number_source_workspaces", + ) + order_by = ("name",) + + def render_number_source_workspaces(self, record): + """Render the number of source workspaces.""" + return record.collaborativeanalysisworkspace.source_workspaces.count() + + +class CollaborativeAnalysisWorkspaceUserTable(tables.Table): + """Class to render a table of Workspace objects with CollaborativeAnalysisWorkspace data.""" + + name = tables.columns.Column(linkify=True) + billing_project = tables.Column() + number_source_workspaces = tables.columns.Column( + accessor="pk", + verbose_name="Number of source workspaces", + orderable=False, + ) + + class Meta: + model = Workspace + fields = ( + "name", + "billing_project", + "collaborativeanalysisworkspace__custodian", + "number_source_workspaces", + ) + order_by = ("name",) + + def render_number_source_workspaces(self, record): + """Render the number of source workspaces.""" + return record.collaborativeanalysisworkspace.source_workspaces.count() diff --git a/primed/collaborative_analysis/tests/test_tables.py b/primed/collaborative_analysis/tests/test_tables.py new file mode 100644 index 00000000..f8482f99 --- /dev/null +++ b/primed/collaborative_analysis/tests/test_tables.py @@ -0,0 +1,104 @@ +"""Tests for the tables in the `collaborative_analysis` app.""" + +from anvil_consortium_manager.models import Workspace +from anvil_consortium_manager.tests.factories import WorkspaceFactory +from django.test import TestCase + +from .. import tables +from . import factories + + +class CollaborativeAnalysisWorkspaceStaffTableTest(TestCase): + model = Workspace + model_factory = factories.CollaborativeAnalysisWorkspaceFactory + table_class = tables.CollaborativeAnalysisWorkspaceStaffTable + + 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_source_workspaces_zero(self): + """Table shows correct count for number of source_workspaces.""" + self.model_factory.create() + table = self.table_class(self.model.objects.all()) + self.assertEqual(table.rows[0].get_cell("number_source_workspaces"), 0) + + def test_number_source_workspaces_one(self): + """Table shows correct count for number of source_workspaces.""" + source_workspace = WorkspaceFactory.create() + obj = self.model_factory.create() + obj.source_workspaces.add(source_workspace) + table = self.table_class( + self.model.objects.filter(workspace_type="collab_analysis") + ) + self.assertEqual(table.rows[0].get_cell("number_source_workspaces"), 1) + + def test_number_source_workspaces_two(self): + """Table shows correct count for number of source_workspaces.""" + source_workspace_1 = WorkspaceFactory.create() + source_workspace_2 = WorkspaceFactory.create() + obj = self.model_factory.create() + obj.source_workspaces.add(source_workspace_1) + obj.source_workspaces.add(source_workspace_2) + table = self.table_class( + self.model.objects.filter(workspace_type="collab_analysis") + ) + self.assertEqual(table.rows[0].get_cell("number_source_workspaces"), 2) + + +class CollaborativeAnalysisWorkspaceUserTableTest(TestCase): + model = Workspace + model_factory = factories.CollaborativeAnalysisWorkspaceFactory + table_class = tables.CollaborativeAnalysisWorkspaceUserTable + + 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_source_workspaces_zero(self): + """Table shows correct count for number of source_workspaces.""" + self.model_factory.create() + table = self.table_class(self.model.objects.all()) + self.assertEqual(table.rows[0].get_cell("number_source_workspaces"), 0) + + def test_number_source_workspaces_one(self): + """Table shows correct count for number of source_workspaces.""" + source_workspace = WorkspaceFactory.create() + obj = self.model_factory.create() + obj.source_workspaces.add(source_workspace) + table = self.table_class( + self.model.objects.filter(workspace_type="collab_analysis") + ) + self.assertEqual(table.rows[0].get_cell("number_source_workspaces"), 1) + + def test_number_source_workspaces_two(self): + """Table shows correct count for number of source_workspaces.""" + source_workspace_1 = WorkspaceFactory.create() + source_workspace_2 = WorkspaceFactory.create() + obj = self.model_factory.create() + obj.source_workspaces.add(source_workspace_1) + obj.source_workspaces.add(source_workspace_2) + table = self.table_class( + self.model.objects.filter(workspace_type="collab_analysis") + ) + self.assertEqual(table.rows[0].get_cell("number_source_workspaces"), 2) From 6df653e132ff869a3840fd1dbe67824e9047e06b Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Wed, 6 Dec 2023 16:44:49 -0800 Subject: [PATCH 14/37] Add an analyst_group foreign key to CollabAnalysisWorkspaces The analyst_group is a group on AnVIL that contains the set of people who are specified by the analyst as writers. Update the workspace data form, the template, and tests to account for this group. Only show the group in the template to users with StaffView permission. --- primed/collaborative_analysis/forms.py | 7 +++- .../migrations/0001_initial.py | 22 +++++++++- primed/collaborative_analysis/models.py | 7 +++- .../collaborative_analysis/tests/factories.py | 1 + .../tests/test_forms.py | 42 +++++++++++++------ .../tests/test_models.py | 7 +++- .../tests/test_views.py | 39 +++++++++++++++-- ...collaborativeanalysisworkspace_detail.html | 7 ++++ 8 files changed, 112 insertions(+), 20 deletions(-) diff --git a/primed/collaborative_analysis/forms.py b/primed/collaborative_analysis/forms.py index 7c80f491..5356a155 100644 --- a/primed/collaborative_analysis/forms.py +++ b/primed/collaborative_analysis/forms.py @@ -12,10 +12,11 @@ class CollaborativeAnalysisWorkspaceForm(Bootstrap5MediaFormMixin, forms.ModelFo class Meta: model = models.CollaborativeAnalysisWorkspace fields = ( + "custodian", "purpose", "proposal_id", "source_workspaces", - "custodian", + "analyst_group", "workspace", ) widgets = { @@ -27,6 +28,10 @@ class Meta: url="users:autocomplete", attrs={"data-theme": "bootstrap-5"}, ), + "analyst_group": autocomplete.ModelSelect2( + url="anvil_consortium_manager:managed_groups:autocomplete", + attrs={"data-theme": "bootstrap-5"}, + ), } def clean(self): diff --git a/primed/collaborative_analysis/migrations/0001_initial.py b/primed/collaborative_analysis/migrations/0001_initial.py index 6eb7662c..fde5f548 100644 --- a/primed/collaborative_analysis/migrations/0001_initial.py +++ b/primed/collaborative_analysis/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.7 on 2023-12-05 22:20 +# Generated by Django 4.2.7 on 2023-12-07 00:17 from django.conf import settings from django.db import migrations, models @@ -62,6 +62,18 @@ class Migration(migrations.Migration): max_length=1, ), ), + ( + "analyst_group", + models.ForeignKey( + blank=True, + db_constraint=False, + help_text="The AnVIL group containing analysts for this workspace.", + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="anvil_consortium_manager.managedgroup", + ), + ), ( "custodian", models.ForeignKey( @@ -141,6 +153,14 @@ class Migration(migrations.Migration): null=True, ), ), + ( + "analyst_group", + models.ForeignKey( + help_text="The AnVIL group containing analysts for this workspace.", + on_delete=django.db.models.deletion.PROTECT, + to="anvil_consortium_manager.managedgroup", + ), + ), ( "custodian", models.ForeignKey( diff --git a/primed/collaborative_analysis/models.py b/primed/collaborative_analysis/models.py index b98ffd11..edcac496 100644 --- a/primed/collaborative_analysis/models.py +++ b/primed/collaborative_analysis/models.py @@ -1,4 +1,4 @@ -from anvil_consortium_manager.models import BaseWorkspaceData, Workspace +from anvil_consortium_manager.models import BaseWorkspaceData, ManagedGroup, Workspace from django.conf import settings from django.db import models from django_extensions.db.models import TimeStampedModel @@ -28,3 +28,8 @@ class CollaborativeAnalysisWorkspace(TimeStampedModel, BaseWorkspaceData): related_name="collaborative_analysis_workspaces", help_text="Workspaces contributing data to this workspace.", ) + analyst_group = models.ForeignKey( + ManagedGroup, + on_delete=models.PROTECT, + help_text="The AnVIL group containing analysts for this workspace.", + ) diff --git a/primed/collaborative_analysis/tests/factories.py b/primed/collaborative_analysis/tests/factories.py index e8086f37..a2dd70f9 100644 --- a/primed/collaborative_analysis/tests/factories.py +++ b/primed/collaborative_analysis/tests/factories.py @@ -17,6 +17,7 @@ class Meta: workspace = SubFactory(WorkspaceFactory, workspace_type="collab_analysis") custodian = SubFactory(UserFactory) + analyst_group = SubFactory(ManagedGroupFactory) @post_generation def authorization_domains(self, create, extracted, **kwargs): diff --git a/primed/collaborative_analysis/tests/test_forms.py b/primed/collaborative_analysis/tests/test_forms.py index 1104b166..ededf2fb 100644 --- a/primed/collaborative_analysis/tests/test_forms.py +++ b/primed/collaborative_analysis/tests/test_forms.py @@ -1,4 +1,7 @@ -from anvil_consortium_manager.tests.factories import WorkspaceFactory +from anvil_consortium_manager.tests.factories import ( + ManagedGroupFactory, + WorkspaceFactory, +) from django.core.exceptions import NON_FIELD_ERRORS from django.test import TestCase @@ -17,12 +20,14 @@ def setUp(self): self.custodian = UserFactory.create() self.workspace = WorkspaceFactory.create() self.source_workspace = WorkspaceFactory.create() + self.analyst_group = ManagedGroupFactory.create() def test_valid(self): """Form is valid with necessary input.""" form_data = { "purpose": "test", "source_workspaces": [self.source_workspace], + "analyst_group": self.analyst_group, "custodian": self.custodian, "workspace": self.workspace, } @@ -34,17 +39,7 @@ def test_valid_with_proposal_id(self): "purpose": "test", "proposal_id": 1, "source_workspaces": [self.source_workspace], - "custodian": self.custodian, - "workspace": self.workspace, - } - form = self.form_class(data=form_data) - self.assertTrue(form.is_valid()) - - def test_invalid_with_proposal_id_blank(self): - form_data = { - "purpose": "test", - "proposal_id": 1, - "source_workspaces": [self.source_workspace], + "analyst_group": self.analyst_group, "custodian": self.custodian, "workspace": self.workspace, } @@ -55,6 +50,7 @@ def test_invalid_with_proposal_id_character(self): form_data = { "purpose": "test", "proposal_id": "a", + "analyst_group": self.analyst_group, "source_workspaces": [self.source_workspace], "custodian": self.custodian, "workspace": self.workspace, @@ -73,6 +69,7 @@ def test_valid_two_source_workspaces(self): "purpose": "test", "source_workspaces": [self.source_workspace, source_workspace_2], "custodian": self.custodian, + "analyst_group": self.analyst_group, "workspace": self.workspace, } form = self.form_class(data=form_data) @@ -84,6 +81,7 @@ def test_invalid_missing_purpose(self): # "purpose": "test", "source_workspaces": [self.source_workspace], "custodian": self.custodian, + "analyst_group": self.analyst_group, "workspace": self.workspace, } form = self.form_class(data=form_data) @@ -99,6 +97,7 @@ def test_invalid_missing_source_workspaces(self): "purpose": "test", # "source_workspaces": [self.source_workspace], "custodian": self.custodian, + "analyst_group": self.analyst_group, "workspace": self.workspace, } form = self.form_class(data=form_data) @@ -114,6 +113,7 @@ def test_invalid_same_workspace_and_source_workspace(self): "purpose": "test", "source_workspaces": [self.workspace], "custodian": self.custodian, + "analyst_group": self.analyst_group, "workspace": self.workspace, } form = self.form_class(data=form_data) @@ -129,6 +129,7 @@ def test_invalid_custodian(self): "purpose": "test", "source_workspaces": [self.source_workspace], # "custodian": self.custodian, + "analyst_group": self.analyst_group, "workspace": self.workspace, } form = self.form_class(data=form_data) @@ -138,12 +139,29 @@ def test_invalid_custodian(self): self.assertEqual(len(form.errors["custodian"]), 1) self.assertIn("required", form.errors["custodian"][0]) + def test_invalid_analyst_group(self): + """Form is invalid when missing custodian.""" + form_data = { + "purpose": "test", + "source_workspaces": [self.source_workspace], + "custodian": self.custodian, + # "analyst_group": self.analyst_group, + "workspace": self.workspace, + } + form = self.form_class(data=form_data) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertIn("analyst_group", form.errors) + self.assertEqual(len(form.errors["analyst_group"]), 1) + self.assertIn("required", form.errors["analyst_group"][0]) + def test_invalid_workspace(self): """Form is invalid when missing workspace.""" form_data = { "purpose": "test", "source_workspaces": [self.source_workspace], "custodian": self.custodian, + "analyst_group": self.analyst_group, # "workspace": self.workspace, } form = self.form_class(data=form_data) diff --git a/primed/collaborative_analysis/tests/test_models.py b/primed/collaborative_analysis/tests/test_models.py index 49d99e0f..20031d89 100644 --- a/primed/collaborative_analysis/tests/test_models.py +++ b/primed/collaborative_analysis/tests/test_models.py @@ -1,4 +1,7 @@ -from anvil_consortium_manager.tests.factories import WorkspaceFactory +from anvil_consortium_manager.tests.factories import ( + ManagedGroupFactory, + WorkspaceFactory, +) from django.test import TestCase from primed.users.tests.factories import UserFactory @@ -14,9 +17,11 @@ def test_model_saving(self): """Creation using the model constructor and .save() works.""" workspace = WorkspaceFactory.create() user = UserFactory.create() + group = ManagedGroupFactory.create() instance = models.CollaborativeAnalysisWorkspace( workspace=workspace, custodian=user, + analyst_group=group, ) instance.save() self.assertIsInstance(instance, models.CollaborativeAnalysisWorkspace) diff --git a/primed/collaborative_analysis/tests/test_views.py b/primed/collaborative_analysis/tests/test_views.py index 94e9331a..40f56b79 100644 --- a/primed/collaborative_analysis/tests/test_views.py +++ b/primed/collaborative_analysis/tests/test_views.py @@ -2,7 +2,10 @@ import responses from anvil_consortium_manager.models import AnVILProjectManagerAccess, Workspace -from anvil_consortium_manager.tests.factories import BillingProjectFactory +from anvil_consortium_manager.tests.factories import ( + BillingProjectFactory, + ManagedGroupFactory, +) from anvil_consortium_manager.tests.utils import AnVILAPIMockTestMixin from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission @@ -25,11 +28,10 @@ class CollaborativeAnalysisWorkspaceDetailTest(TestCase): def setUp(self): """Set up test class.""" - # 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=AnVILProjectManagerAccess.STAFF_VIEW_PERMISSION_CODENAME + codename=AnVILProjectManagerAccess.VIEW_PERMISSION_CODENAME ) ) @@ -62,7 +64,6 @@ def test_links_to_source_workspace(self): def test_link_to_custodian(self): """Links to the custodian's user profile appear on the detail page.""" - # TODO: Move this to a table in the context data when ACM allows. custodian = UserFactory.create() obj = factories.CollaborativeAnalysisWorkspaceFactory.create( custodian=custodian @@ -71,6 +72,32 @@ def test_link_to_custodian(self): response = self.client.get(obj.workspace.get_absolute_url()) self.assertIn(custodian.get_absolute_url(), response.content.decode()) + def test_link_to_analyst_group_staff_view(self): + """Links to the analyst group's detail page appear on the detail page for staff_viewers.""" + user = User.objects.create_user( + username="test-staff-view", password="test-staff-view" + ) + user.user_permissions.add( + Permission.objects.get( + codename=AnVILProjectManagerAccess.STAFF_VIEW_PERMISSION_CODENAME + ) + ) + obj = factories.CollaborativeAnalysisWorkspaceFactory.create() + self.client.force_login(user) + response = self.client.get(obj.workspace.get_absolute_url()) + self.assertIn(obj.analyst_group.get_absolute_url(), response.content.decode()) + self.assertIn(obj.analyst_group.name, response.content.decode()) + + def test_link_to_analyst_group_view(self): + """Links to the analyst group's detail page do not appear on the detail page for viewers.""" + obj = factories.CollaborativeAnalysisWorkspaceFactory.create() + self.client.force_login(self.user) + response = self.client.get(obj.workspace.get_absolute_url()) + self.assertNotIn( + obj.analyst_group.get_absolute_url(), response.content.decode() + ) + self.assertNotIn(obj.analyst_group.name, response.content.decode()) + class CollaborativeAnalysisWorkspaceCreateTest(AnVILAPIMockTestMixin, TestCase): """Tests of the WorkspaceCreate view from ACM with this app's CollaborativeAnalysisWorkspace model.""" @@ -96,6 +123,7 @@ def setUp(self): self.workspace_type = "collab_analysis" self.custodian = UserFactory.create() self.source_workspace = dbGaPWorkspaceFactory.create() + self.analyst_group = ManagedGroupFactory.create() def get_url(self, *args): """Get the url for the view being tested.""" @@ -130,6 +158,7 @@ def test_creates_workspace(self): "workspacedata-0-custodian": self.custodian.pk, "workspacedata-0-source_workspaces": [self.source_workspace.pk], "workspacedata-0-purpose": "test", + "workspacedata-0-analyst_group": self.analyst_group.pk, }, ) self.assertEqual(response.status_code, 302) @@ -165,6 +194,7 @@ def setUp(self): self.custodian = UserFactory.create() self.source_workspace = dbGaPWorkspaceFactory.create() self.workspace_type = "collab_analysis" + self.analyst_group = ManagedGroupFactory.create() def get_url(self, *args): """Get the url for the view being tested.""" @@ -259,6 +289,7 @@ def test_creates_workspace(self): "workspacedata-0-custodian": self.custodian.pk, "workspacedata-0-source_workspaces": [self.source_workspace.pk], "workspacedata-0-purpose": "test", + "workspacedata-0-analyst_group": self.analyst_group.pk, }, ) self.assertEqual(response.status_code, 302) diff --git a/primed/templates/collaborative_analysis/collaborativeanalysisworkspace_detail.html b/primed/templates/collaborative_analysis/collaborativeanalysisworkspace_detail.html index 477446bb..adee8f10 100644 --- a/primed/templates/collaborative_analysis/collaborativeanalysisworkspace_detail.html +++ b/primed/templates/collaborative_analysis/collaborativeanalysisworkspace_detail.html @@ -14,5 +14,12 @@

{{ workspace }}

{% endfor %} + {% if perms.anvil_consortium_manager.anvil_consortium_manager_staff_view %} +
Analyst group
+ + {{ workspace_data_object.analyst_group.name }} + +
+ {% endif %} {% endblock workspace_data %} From 921ded10b9db5754bef7e0efa3035221633ba041 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Thu, 7 Dec 2023 10:21:58 -0800 Subject: [PATCH 15/37] Add dataclasses for CollaborativeAnalsyisWorkspace audit results These dataclasses are intended to hold the result for a given account and workspace when the audit is run. --- primed/collaborative_analysis/audit.py | 67 +++++++++++++++++++ .../tests/test_audit.py | 49 ++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 primed/collaborative_analysis/audit.py create mode 100644 primed/collaborative_analysis/tests/test_audit.py diff --git a/primed/collaborative_analysis/audit.py b/primed/collaborative_analysis/audit.py new file mode 100644 index 00000000..f3d67df9 --- /dev/null +++ b/primed/collaborative_analysis/audit.py @@ -0,0 +1,67 @@ +from dataclasses import dataclass + +from anvil_consortium_manager.models import Account +from django.urls import reverse + +from . import models + + +@dataclass +class AccessAuditResult: + """Base class to hold the result of an access audit for a CollaborativeAnalysisWorkspace.""" + + workspace: models.CollaborativeAnalysisWorkspace + account: Account + note: str + + def get_action_url(self): + """The URL that handles the action needed.""" + return None + + def get_action(self): + """An indicator of what action needs to be taken.""" + return None + + +@dataclass +class VerifiedAccess(AccessAuditResult): + """Audit results class for when an account has verified access.""" + + +@dataclass +class VerifiedNoAccess(AccessAuditResult): + """Audit results class for when an account has verified no access.""" + + +@dataclass +class GrantAccess(AccessAuditResult): + """Audit results class for when an account should be granted access.""" + + def get_action(self): + return "Grant access" + + def get_action_url(self): + return reverse( + "anvil_consortium_manager:managed_groups:member_accounts:new_by_account", + args=[ + self.workspace.workspace.authorization_domains.first(), + self.account, + ], + ) + + +@dataclass +class RemoveAccess(AccessAuditResult): + """Audit results class for when access for an account should be removed.""" + + def get_action(self): + return "Remove access" + + def get_action_url(self): + return reverse( + "anvil_consortium_manager:managed_groups:member_accounts:delete", + args=[ + self.workspace.workspace.authorization_domains.first(), + self.account, + ], + ) diff --git a/primed/collaborative_analysis/tests/test_audit.py b/primed/collaborative_analysis/tests/test_audit.py new file mode 100644 index 00000000..51c446dc --- /dev/null +++ b/primed/collaborative_analysis/tests/test_audit.py @@ -0,0 +1,49 @@ +from anvil_consortium_manager.tests.factories import AccountFactory +from django.test import TestCase +from django.urls import reverse + +from .. import audit +from . import factories + + +class WorkspaceAccessAuditResultTest(TestCase): + def setUp(self): + super().setUp() + + def test_verified_access(self): + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + account = AccountFactory.create() + instance = audit.VerifiedAccess( + workspace=workspace, account=account, note="test" + ) + self.assertIsNone(instance.get_action_url()) + + def test_verified_no_access(self): + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + account = AccountFactory.create() + instance = audit.VerifiedNoAccess( + workspace=workspace, account=account, note="test" + ) + self.assertIsNone(instance.get_action_url()) + + def test_grant_access(self): + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + account = AccountFactory.create() + instance = audit.VerifiedAccess( + workspace=workspace, account=account, note="test" + ) + expected_url = reverse( + "anvil_consortium_manager:managed_groups:member_accounts:new_by_account", + args=[workspace.analyst_group, account], + ) + self.assertEqual(instance.get_action_url(), expected_url) + + def test_remove_access(self): + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + account = AccountFactory.create() + instance = audit.RemoveAccess(workspace=workspace, account=account, note="test") + expected_url = reverse( + "anvil_consortium_manager:managed_groups:member_accounts:delete", + args=[workspace.analyst_group, account], + ) + self.assertEqual(instance.get_action_url(), expected_url) From 1bd29432773b643a65da201eeb9c57b43952a8ed Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Thu, 7 Dec 2023 10:33:41 -0800 Subject: [PATCH 16/37] Add skeleton audit for CollaborativeAnalysisWorkspaces --- primed/collaborative_analysis/audit.py | 26 ++++++++++++++++ .../tests/test_audit.py | 31 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/primed/collaborative_analysis/audit.py b/primed/collaborative_analysis/audit.py index f3d67df9..c1098e43 100644 --- a/primed/collaborative_analysis/audit.py +++ b/primed/collaborative_analysis/audit.py @@ -65,3 +65,29 @@ def get_action_url(self): self.account, ], ) + + +class CollaborativeAnalysisWorkspaceAccessAudit: + """Class to audit access to a CollaborativeAnalysisWorkspace.""" + + def __init__(self): + self.verified = [] + self.needs_action = [] + self.errors = [] + + def _audit_workspace(self, workspace): + """Audit access to a single CollaborativeAnalysisWorkspace.""" + # Cases to consider: + # - analyst is in all relevant source auth domains, and is in the workspace auth domain. + # - analyst is in some but not all relevant source auth domains, and is in the workspace auth domain.. + # - analyst is in none of the relevant source auth domains, and is in the workspace auth domain.. + # - analyst is in all relevant source auth domains, and is not in the workspace auth domain. + # - analyst is in some but not all relevant source auth domains, and is not in the workspace auth domain. + # - analyst is in none of the relevant source auth domains, and is not in the workspace auth domain. + # - an account is in the workspace auth domain, but is not in the analyst group. + + def run_audit(self): + """Run an audit on all CollaborativeAnalysisWorkspaces.""" + for workspace in models.CollaborativeAnalysisWorkspace.objects.all(): + self._audit_workspace(workspace) + self.completed = True diff --git a/primed/collaborative_analysis/tests/test_audit.py b/primed/collaborative_analysis/tests/test_audit.py index 51c446dc..7da38886 100644 --- a/primed/collaborative_analysis/tests/test_audit.py +++ b/primed/collaborative_analysis/tests/test_audit.py @@ -47,3 +47,34 @@ def test_remove_access(self): args=[workspace.analyst_group, account], ) self.assertEqual(instance.get_action_url(), expected_url) + + +class CollaborativeAnalysisWorkspaceAccessAudit: + """Tests for the CollaborativeAnalysisWorkspaceAccessAudit class.""" + + def test_completed(self): + """completed is updated properly.""" + collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() + self.assertFalse(collab_audit.completed) + collab_audit.run_audit() + self.assertTrue(collab_audit.completed) + + def test_no_workspaces(self): + """Audit works if there are no source workspaces.""" + collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() + collab_audit.run_audit() + self.assertEqual(len(collab_audit.verified), 0) + self.assertEqual(len(collab_audit.needs_action), 0) + self.assertEqual(len(collab_audit.errors), 0) + + def test_one_workspaces_no_analysts(self): + """Audit works if there are analysts workspaces in the analyst group for a workspace.""" + factories.CollaborativeAnalysisWorkspaceFactory.create() + collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() + collab_audit.run_audit() + self.assertEqual(len(collab_audit.verified), 0) + self.assertEqual(len(collab_audit.needs_action), 0) + self.assertEqual(len(collab_audit.errors), 0) + + # def test_one(self): + # self.fail() From 469b54d240e1b17faddcffa7e6a65b4b056aab05 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Thu, 7 Dec 2023 12:41:16 -0800 Subject: [PATCH 17/37] Start writing audit functions with simple checks More to be added later. --- primed/collaborative_analysis/audit.py | 62 ++++++++ .../collaborative_analysis/tests/factories.py | 4 +- .../tests/test_audit.py | 148 +++++++++++++++++- 3 files changed, 208 insertions(+), 6 deletions(-) diff --git a/primed/collaborative_analysis/audit.py b/primed/collaborative_analysis/audit.py index c1098e43..f26dd999 100644 --- a/primed/collaborative_analysis/audit.py +++ b/primed/collaborative_analysis/audit.py @@ -70,13 +70,26 @@ def get_action_url(self): class CollaborativeAnalysisWorkspaceAccessAudit: """Class to audit access to a CollaborativeAnalysisWorkspace.""" + # Allowed reasons for access. + IN_SOURCE_AUTH_DOMAINS = "Account is in all source auth domains for this workspace." + + # Allowed reasons for no access. + NOT_IN_SOURCE_AUTH_DOMAINS = ( + "Account is not in all source auth domains for this workspace." + ) + def __init__(self): self.verified = [] self.needs_action = [] self.errors = [] + self.completed = False def _audit_workspace(self, workspace): """Audit access to a single CollaborativeAnalysisWorkspace.""" + pass + + def _audit_workspace_and_account(self, collaborative_analysis_workspace, account): + """Audit access for a specific CollaborativeAnalysisWorkspace and account.""" # Cases to consider: # - analyst is in all relevant source auth domains, and is in the workspace auth domain. # - analyst is in some but not all relevant source auth domains, and is in the workspace auth domain.. @@ -86,6 +99,55 @@ def _audit_workspace(self, workspace): # - analyst is in none of the relevant source auth domains, and is not in the workspace auth domain. # - an account is in the workspace auth domain, but is not in the analyst group. + # Check whether access is allowed. Start by assuming yes; set to false if the account should not have access. + access_allowed = True + account_groups = account.get_all_groups() + source_workspace = collaborative_analysis_workspace.source_workspaces.first() + for source_auth_domain in source_workspace.authorization_domains.all(): + if source_auth_domain not in account_groups: + access_allowed = False + break + # Check whether the account is in the auth domain of the collab workspace. + in_auth_domain = ( + collaborative_analysis_workspace.workspace.authorization_domains.first() + in account_groups + ) + # Determine the audit result. + print(access_allowed) + print(in_auth_domain) + if access_allowed and in_auth_domain: + self.verified.append( + VerifiedAccess( + workspace=collaborative_analysis_workspace, + account=account, + note=self.IN_SOURCE_AUTH_DOMAINS, + ) + ) + elif access_allowed and not in_auth_domain: + self.needs_action.append( + GrantAccess( + workspace=collaborative_analysis_workspace, + account=account, + note=self.IN_SOURCE_AUTH_DOMAINS, + ) + ) + elif not access_allowed and in_auth_domain: + self.needs_action.append( + RemoveAccess( + workspace=collaborative_analysis_workspace, + account=account, + note=self.NOT_IN_SOURCE_AUTH_DOMAINS, + ) + ) + else: + self.verified.append( + VerifiedNoAccess( + workspace=collaborative_analysis_workspace, + account=account, + note=self.NOT_IN_SOURCE_AUTH_DOMAINS, + ) + ) + def run_audit(self): """Run an audit on all CollaborativeAnalysisWorkspaces.""" for workspace in models.CollaborativeAnalysisWorkspace.objects.all(): diff --git a/primed/collaborative_analysis/tests/factories.py b/primed/collaborative_analysis/tests/factories.py index a2dd70f9..72904693 100644 --- a/primed/collaborative_analysis/tests/factories.py +++ b/primed/collaborative_analysis/tests/factories.py @@ -28,13 +28,11 @@ def authorization_domains(self, create, extracted, **kwargs): # Create an authorization domain. auth_domain = ManagedGroupFactory.create() + print(auth_domain) self.workspace.authorization_domains.add(auth_domain) @post_generation def source_workspaces(self, create, extracted, **kwargs): - # Make sure at least one is added. - print(create) - print(extracted) if not create or not extracted: # Simple build, do nothing. return diff --git a/primed/collaborative_analysis/tests/test_audit.py b/primed/collaborative_analysis/tests/test_audit.py index 7da38886..78493e67 100644 --- a/primed/collaborative_analysis/tests/test_audit.py +++ b/primed/collaborative_analysis/tests/test_audit.py @@ -1,7 +1,12 @@ -from anvil_consortium_manager.tests.factories import AccountFactory +from anvil_consortium_manager.tests.factories import ( + AccountFactory, + GroupAccountMembershipFactory, +) from django.test import TestCase from django.urls import reverse +from primed.dbgap.tests.factories import dbGaPWorkspaceFactory + from .. import audit from . import factories @@ -49,7 +54,7 @@ def test_remove_access(self): self.assertEqual(instance.get_action_url(), expected_url) -class CollaborativeAnalysisWorkspaceAccessAudit: +class CollaborativeAnalysisWorkspaceAccessAudit(TestCase): """Tests for the CollaborativeAnalysisWorkspaceAccessAudit class.""" def test_completed(self): @@ -76,5 +81,142 @@ def test_one_workspaces_no_analysts(self): self.assertEqual(len(collab_audit.needs_action), 0) self.assertEqual(len(collab_audit.errors), 0) - # def test_one(self): + def test_analyst_in_collab_auth_domain_in_source_auth_domain(self): + # Create accounts. + account = AccountFactory.create() + # Set up workspace. + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + # Set up source workspaces. + source_workspace = dbGaPWorkspaceFactory.create() + workspace.source_workspaces.add(source_workspace.workspace) + # Analyst group membership. + GroupAccountMembershipFactory.create( + group=workspace.analyst_group, account=account + ) + # Source workspace auth domains membership. + GroupAccountMembershipFactory.create( + group=source_workspace.workspace.authorization_domains.first(), + account=account, + ) + # CollaborativeAnalysisWorkspace auth domain membership. + GroupAccountMembershipFactory.create( + group=workspace.workspace.authorization_domains.first(), account=account + ) + # Set up audit + collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() + # Run audit + collab_audit._audit_workspace_and_account(workspace, account) + self.assertEqual(len(collab_audit.verified), 1) + self.assertEqual(len(collab_audit.needs_action), 0) + self.assertEqual(len(collab_audit.errors), 0) + record = collab_audit.verified[0] + self.assertIsInstance(record, audit.VerifiedAccess) + self.assertEqual(record.workspace, workspace) + self.assertEqual(record.account, account) + self.assertEqual(record.note, collab_audit.IN_SOURCE_AUTH_DOMAINS) + + def test_analyst_in_collab_auth_domain_not_in_source_auth_domain(self): + # Create accounts. + account = AccountFactory.create() + # Set up workspace. + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + # Set up source workspaces. + source_workspace = dbGaPWorkspaceFactory.create() + workspace.source_workspaces.add(source_workspace.workspace) + # Analyst group membership. + GroupAccountMembershipFactory.create( + group=workspace.analyst_group, account=account + ) + # Source workspace auth domains membership. + # GroupAccountMembershipFactory.create( + # group=source_workspace.workspace.authorization_domains.first(), account=account + # ) + # CollaborativeAnalysisWorkspace auth domain membership. + GroupAccountMembershipFactory.create( + group=workspace.workspace.authorization_domains.first(), account=account + ) + # Set up audit + collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() + # Run audit + collab_audit._audit_workspace_and_account(workspace, account) + self.assertEqual(len(collab_audit.verified), 0) + self.assertEqual(len(collab_audit.needs_action), 1) + self.assertEqual(len(collab_audit.errors), 0) + record = collab_audit.needs_action[0] + self.assertIsInstance(record, audit.RemoveAccess) + self.assertEqual(record.workspace, workspace) + self.assertEqual(record.account, account) + self.assertEqual(record.note, collab_audit.NOT_IN_SOURCE_AUTH_DOMAINS) + + def test_analyst_not_in_collab_auth_domain_in_source_auth_domain(self): + # Create accounts. + account = AccountFactory.create() + # Set up workspace. + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + # Set up source workspaces. + source_workspace = dbGaPWorkspaceFactory.create() + workspace.source_workspaces.add(source_workspace.workspace) + # Analyst group membership. + GroupAccountMembershipFactory.create( + group=workspace.analyst_group, account=account + ) + # Source workspace auth domains membership. + GroupAccountMembershipFactory.create( + group=source_workspace.workspace.authorization_domains.first(), + account=account, + ) + # CollaborativeAnalysisWorkspace auth domain membership. + # GroupAccountMembershipFactory.create( + # group=workspace.workspace.authorization_domains.first(), account=account + # ) + # Set up audit + collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() + # Run audit + collab_audit._audit_workspace_and_account(workspace, account) + self.assertEqual(len(collab_audit.verified), 0) + self.assertEqual(len(collab_audit.needs_action), 1) + self.assertEqual(len(collab_audit.errors), 0) + record = collab_audit.needs_action[0] + self.assertIsInstance(record, audit.GrantAccess) + self.assertEqual(record.workspace, workspace) + self.assertEqual(record.account, account) + self.assertEqual(record.note, collab_audit.IN_SOURCE_AUTH_DOMAINS) + + def test_analyst_not_in_collab_auth_domain_not_in_source_auth_domain(self): + # Create accounts. + account = AccountFactory.create() + # Set up workspace. + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + # Set up source workspaces. + source_workspace = dbGaPWorkspaceFactory.create() + workspace.source_workspaces.add(source_workspace.workspace) + # Analyst group membership. + GroupAccountMembershipFactory.create( + group=workspace.analyst_group, account=account + ) + # Source workspace auth domains membership. + # GroupAccountMembershipFactory.create( + # group=source_workspace.workspace.authorization_domains.first(), account=account + # ) + # CollaborativeAnalysisWorkspace auth domain membership. + # GroupAccountMembershipFactory.create( + # group=workspace.workspace.authorization_domains.first(), account=account + # ) + # Set up audit + collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() + # Run audit + collab_audit._audit_workspace_and_account(workspace, account) + self.assertEqual(len(collab_audit.verified), 1) + self.assertEqual(len(collab_audit.needs_action), 0) + self.assertEqual(len(collab_audit.errors), 0) + record = collab_audit.verified[0] + self.assertIsInstance(record, audit.VerifiedNoAccess) + self.assertEqual(record.workspace, workspace) + self.assertEqual(record.account, account) + self.assertEqual(record.note, collab_audit.NOT_IN_SOURCE_AUTH_DOMAINS) + + # def test_source_workspace_two_auth_domains(self): + # self.fail() + + # def test_workspace_has_no_source_workspaces(self): # self.fail() From 1388c75f79487776d8832f57295646acd0c21fc4 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Wed, 13 Dec 2023 17:05:21 -0800 Subject: [PATCH 18/37] Finish unit tests for the main auditing method Finish writing the unit tests for the _audit_workspace_and_account method. Modify the method as necessary to pass the tests. --- primed/collaborative_analysis/audit.py | 36 +- .../tests/test_audit.py | 575 +++++++++++++++++- 2 files changed, 584 insertions(+), 27 deletions(-) diff --git a/primed/collaborative_analysis/audit.py b/primed/collaborative_analysis/audit.py index f26dd999..59546751 100644 --- a/primed/collaborative_analysis/audit.py +++ b/primed/collaborative_analysis/audit.py @@ -10,7 +10,7 @@ class AccessAuditResult: """Base class to hold the result of an access audit for a CollaborativeAnalysisWorkspace.""" - workspace: models.CollaborativeAnalysisWorkspace + collaborative_analysis_workspace: models.CollaborativeAnalysisWorkspace account: Account note: str @@ -44,8 +44,8 @@ def get_action_url(self): return reverse( "anvil_consortium_manager:managed_groups:member_accounts:new_by_account", args=[ - self.workspace.workspace.authorization_domains.first(), - self.account, + self.collaborative_analysis_workspace.workspace.authorization_domains.first().name, + self.account.uuid, ], ) @@ -61,8 +61,8 @@ def get_action_url(self): return reverse( "anvil_consortium_manager:managed_groups:member_accounts:delete", args=[ - self.workspace.workspace.authorization_domains.first(), - self.account, + self.collaborative_analysis_workspace.workspace.authorization_domains.first().name, + self.account.uuid, ], ) @@ -102,11 +102,19 @@ def _audit_workspace_and_account(self, collaborative_analysis_workspace, account # Check whether access is allowed. Start by assuming yes; set to false if the account should not have access. access_allowed = True account_groups = account.get_all_groups() - source_workspace = collaborative_analysis_workspace.source_workspaces.first() - for source_auth_domain in source_workspace.authorization_domains.all(): - if source_auth_domain not in account_groups: - access_allowed = False - break + # Loop over all source workspaces. + for ( + source_workspace + ) in collaborative_analysis_workspace.source_workspaces.all(): + # Loop over all auth domains for that source workspace. + for source_auth_domain in source_workspace.authorization_domains.all(): + # If the user is not in the auth domain, they are not allowed to have access to the collab workspace. + # If so, break out of the loop - it is not necessary to check membership of the remaining auth domains. + # Note that this only breaks out of the inner loop. + # It would be more efficient to break out of the outer loop as well. + if source_auth_domain not in account_groups: + access_allowed = False + break # Check whether the account is in the auth domain of the collab workspace. in_auth_domain = ( collaborative_analysis_workspace.workspace.authorization_domains.first() @@ -118,7 +126,7 @@ def _audit_workspace_and_account(self, collaborative_analysis_workspace, account if access_allowed and in_auth_domain: self.verified.append( VerifiedAccess( - workspace=collaborative_analysis_workspace, + collaborative_analysis_workspace=collaborative_analysis_workspace, account=account, note=self.IN_SOURCE_AUTH_DOMAINS, ) @@ -126,7 +134,7 @@ def _audit_workspace_and_account(self, collaborative_analysis_workspace, account elif access_allowed and not in_auth_domain: self.needs_action.append( GrantAccess( - workspace=collaborative_analysis_workspace, + collaborative_analysis_workspace=collaborative_analysis_workspace, account=account, note=self.IN_SOURCE_AUTH_DOMAINS, ) @@ -134,7 +142,7 @@ def _audit_workspace_and_account(self, collaborative_analysis_workspace, account elif not access_allowed and in_auth_domain: self.needs_action.append( RemoveAccess( - workspace=collaborative_analysis_workspace, + collaborative_analysis_workspace=collaborative_analysis_workspace, account=account, note=self.NOT_IN_SOURCE_AUTH_DOMAINS, ) @@ -142,7 +150,7 @@ def _audit_workspace_and_account(self, collaborative_analysis_workspace, account else: self.verified.append( VerifiedNoAccess( - workspace=collaborative_analysis_workspace, + collaborative_analysis_workspace=collaborative_analysis_workspace, account=account, note=self.NOT_IN_SOURCE_AUTH_DOMAINS, ) diff --git a/primed/collaborative_analysis/tests/test_audit.py b/primed/collaborative_analysis/tests/test_audit.py index 78493e67..dfa9c5cf 100644 --- a/primed/collaborative_analysis/tests/test_audit.py +++ b/primed/collaborative_analysis/tests/test_audit.py @@ -1,10 +1,13 @@ from anvil_consortium_manager.tests.factories import ( AccountFactory, GroupAccountMembershipFactory, + WorkspaceAuthorizationDomainFactory, + WorkspaceFactory, ) from django.test import TestCase from django.urls import reverse +from primed.cdsa.tests.factories import CDSAWorkspaceFactory from primed.dbgap.tests.factories import dbGaPWorkspaceFactory from .. import audit @@ -19,7 +22,7 @@ def test_verified_access(self): workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() account = AccountFactory.create() instance = audit.VerifiedAccess( - workspace=workspace, account=account, note="test" + collaborative_analysis_workspace=workspace, account=account, note="test" ) self.assertIsNone(instance.get_action_url()) @@ -27,29 +30,31 @@ def test_verified_no_access(self): workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() account = AccountFactory.create() instance = audit.VerifiedNoAccess( - workspace=workspace, account=account, note="test" + collaborative_analysis_workspace=workspace, account=account, note="test" ) self.assertIsNone(instance.get_action_url()) def test_grant_access(self): workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() account = AccountFactory.create() - instance = audit.VerifiedAccess( - workspace=workspace, account=account, note="test" + instance = audit.GrantAccess( + collaborative_analysis_workspace=workspace, account=account, note="test" ) expected_url = reverse( "anvil_consortium_manager:managed_groups:member_accounts:new_by_account", - args=[workspace.analyst_group, account], + args=[workspace.workspace.authorization_domains.first().name, account.uuid], ) self.assertEqual(instance.get_action_url(), expected_url) def test_remove_access(self): workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() account = AccountFactory.create() - instance = audit.RemoveAccess(workspace=workspace, account=account, note="test") + instance = audit.RemoveAccess( + collaborative_analysis_workspace=workspace, account=account, note="test" + ) expected_url = reverse( "anvil_consortium_manager:managed_groups:member_accounts:delete", - args=[workspace.analyst_group, account], + args=[workspace.workspace.authorization_domains.first().name, account.uuid], ) self.assertEqual(instance.get_action_url(), expected_url) @@ -111,7 +116,7 @@ def test_analyst_in_collab_auth_domain_in_source_auth_domain(self): self.assertEqual(len(collab_audit.errors), 0) record = collab_audit.verified[0] self.assertIsInstance(record, audit.VerifiedAccess) - self.assertEqual(record.workspace, workspace) + self.assertEqual(record.collaborative_analysis_workspace, workspace) self.assertEqual(record.account, account) self.assertEqual(record.note, collab_audit.IN_SOURCE_AUTH_DOMAINS) @@ -144,7 +149,7 @@ def test_analyst_in_collab_auth_domain_not_in_source_auth_domain(self): self.assertEqual(len(collab_audit.errors), 0) record = collab_audit.needs_action[0] self.assertIsInstance(record, audit.RemoveAccess) - self.assertEqual(record.workspace, workspace) + self.assertEqual(record.collaborative_analysis_workspace, workspace) self.assertEqual(record.account, account) self.assertEqual(record.note, collab_audit.NOT_IN_SOURCE_AUTH_DOMAINS) @@ -178,7 +183,7 @@ def test_analyst_not_in_collab_auth_domain_in_source_auth_domain(self): self.assertEqual(len(collab_audit.errors), 0) record = collab_audit.needs_action[0] self.assertIsInstance(record, audit.GrantAccess) - self.assertEqual(record.workspace, workspace) + self.assertEqual(record.collaborative_analysis_workspace, workspace) self.assertEqual(record.account, account) self.assertEqual(record.note, collab_audit.IN_SOURCE_AUTH_DOMAINS) @@ -211,12 +216,556 @@ def test_analyst_not_in_collab_auth_domain_not_in_source_auth_domain(self): self.assertEqual(len(collab_audit.errors), 0) record = collab_audit.verified[0] self.assertIsInstance(record, audit.VerifiedNoAccess) - self.assertEqual(record.workspace, workspace) + self.assertEqual(record.collaborative_analysis_workspace, workspace) self.assertEqual(record.account, account) self.assertEqual(record.note, collab_audit.NOT_IN_SOURCE_AUTH_DOMAINS) - # def test_source_workspace_two_auth_domains(self): - # self.fail() + def test_analyst_in_collab_auth_domain_two_source_auth_domains_in_both(self): + # Create accounts. + account = AccountFactory.create() + # Set up workspace. + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + # Set up source workspaces. + source_workspace = dbGaPWorkspaceFactory.create() + workspace.source_workspaces.add(source_workspace.workspace) + source_auth_domain_2 = WorkspaceAuthorizationDomainFactory.create( + workspace=source_workspace.workspace + ) + # Analyst group membership. + GroupAccountMembershipFactory.create( + group=workspace.analyst_group, account=account + ) + # Source workspace auth domains membership. + GroupAccountMembershipFactory.create( + group=source_workspace.workspace.authorization_domains.all()[0], + account=account, + ) + GroupAccountMembershipFactory.create( + group=source_auth_domain_2.group, + account=account, + ) + # CollaborativeAnalysisWorkspace auth domain membership. + GroupAccountMembershipFactory.create( + group=workspace.workspace.authorization_domains.first(), account=account + ) + # Set up audit + collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() + # Run audit + collab_audit._audit_workspace_and_account(workspace, account) + self.assertEqual(len(collab_audit.verified), 1) + self.assertEqual(len(collab_audit.needs_action), 0) + self.assertEqual(len(collab_audit.errors), 0) + record = collab_audit.verified[0] + self.assertIsInstance(record, audit.VerifiedAccess) + self.assertEqual(record.collaborative_analysis_workspace, workspace) + self.assertEqual(record.account, account) + self.assertEqual(record.note, collab_audit.IN_SOURCE_AUTH_DOMAINS) + + def test_analyst_in_collab_auth_domain_two_source_auth_domains_in_one(self): + # Create accounts. + account = AccountFactory.create() + # Set up workspace. + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + # Set up source workspaces. + source_workspace = dbGaPWorkspaceFactory.create() + workspace.source_workspaces.add(source_workspace.workspace) + WorkspaceAuthorizationDomainFactory.create(workspace=source_workspace.workspace) + # add an extra auth doamin + WorkspaceAuthorizationDomainFactory.create(workspace=source_workspace.workspace) + # Analyst group membership. + GroupAccountMembershipFactory.create( + group=workspace.analyst_group, account=account + ) + # Source workspace auth domains membership. + GroupAccountMembershipFactory.create( + group=source_workspace.workspace.authorization_domains.first(), + account=account, + ) + # GroupAccountMembershipFactory.create( + # group=source_auth_domain_2.group, + # account=account, + # ) + # CollaborativeAnalysisWorkspace auth domain membership. + GroupAccountMembershipFactory.create( + group=workspace.workspace.authorization_domains.first(), account=account + ) + # Set up audit + collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() + # Run audit + collab_audit._audit_workspace_and_account(workspace, account) + self.assertEqual(len(collab_audit.verified), 0) + self.assertEqual(len(collab_audit.needs_action), 1) + self.assertEqual(len(collab_audit.errors), 0) + record = collab_audit.needs_action[0] + self.assertIsInstance(record, audit.RemoveAccess) + self.assertEqual(record.collaborative_analysis_workspace, workspace) + self.assertEqual(record.account, account) + self.assertEqual(record.note, collab_audit.NOT_IN_SOURCE_AUTH_DOMAINS) + + def test_analyst_in_collab_auth_domain_two_source_auth_domains_in_neither(self): + # Create accounts. + account = AccountFactory.create() + # Set up workspace. + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + # Set up source workspaces. + source_workspace = dbGaPWorkspaceFactory.create() + workspace.source_workspaces.add(source_workspace.workspace) + WorkspaceAuthorizationDomainFactory.create(workspace=source_workspace.workspace) + # add an extra auth doamin + WorkspaceAuthorizationDomainFactory.create(workspace=source_workspace.workspace) + # Analyst group membership. + GroupAccountMembershipFactory.create( + group=workspace.analyst_group, account=account + ) + # Source workspace auth domains membership. + # GroupAccountMembershipFactory.create( + # group=source_workspace.workspace.authorization_domains.first(), + # account=account, + # ) + # GroupAccountMembershipFactory.create( + # group=source_auth_domain_2.group, + # account=account, + # ) + # CollaborativeAnalysisWorkspace auth domain membership. + GroupAccountMembershipFactory.create( + group=workspace.workspace.authorization_domains.first(), account=account + ) + # Set up audit + collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() + # Run audit + collab_audit._audit_workspace_and_account(workspace, account) + self.assertEqual(len(collab_audit.verified), 0) + self.assertEqual(len(collab_audit.needs_action), 1) + self.assertEqual(len(collab_audit.errors), 0) + record = collab_audit.needs_action[0] + self.assertIsInstance(record, audit.RemoveAccess) + self.assertEqual(record.collaborative_analysis_workspace, workspace) + self.assertEqual(record.account, account) + self.assertEqual(record.note, collab_audit.NOT_IN_SOURCE_AUTH_DOMAINS) + + def test_analyst_not_in_collab_auth_domain_two_source_auth_domains_in_both(self): + # Create accounts. + account = AccountFactory.create() + # Set up workspace. + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + # Set up source workspaces. + source_workspace = dbGaPWorkspaceFactory.create() + workspace.source_workspaces.add(source_workspace.workspace) + source_auth_domain_1 = source_workspace.workspace.authorization_domains.first() + source_auth_domain_2 = WorkspaceAuthorizationDomainFactory.create( + workspace=source_workspace.workspace + ) + # Analyst group membership. + GroupAccountMembershipFactory.create( + group=workspace.analyst_group, account=account + ) + # Source workspace auth domains membership. + GroupAccountMembershipFactory.create( + group=source_auth_domain_1, + account=account, + ) + GroupAccountMembershipFactory.create( + group=source_auth_domain_2.group, + account=account, + ) + # CollaborativeAnalysisWorkspace auth domain membership. + # GroupAccountMembershipFactory.create( + # group=workspace.workspace.authorization_domains.first(), account=account + # ) + # Set up audit + collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() + # Run audit + collab_audit._audit_workspace_and_account(workspace, account) + self.assertEqual(len(collab_audit.verified), 0) + self.assertEqual(len(collab_audit.needs_action), 1) + self.assertEqual(len(collab_audit.errors), 0) + record = collab_audit.needs_action[0] + self.assertIsInstance(record, audit.GrantAccess) + self.assertEqual(record.collaborative_analysis_workspace, workspace) + self.assertEqual(record.account, account) + self.assertEqual(record.note, collab_audit.IN_SOURCE_AUTH_DOMAINS) + + def test_analyst_not_in_collab_auth_domain_two_source_auth_domains_in_one(self): + # Create accounts. + account = AccountFactory.create() + # Set up workspace. + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + # Set up source workspaces. + source_workspace = dbGaPWorkspaceFactory.create() + workspace.source_workspaces.add(source_workspace.workspace) + source_auth_domain_2 = WorkspaceAuthorizationDomainFactory.create( + workspace=source_workspace.workspace + ) + # Analyst group membership. + GroupAccountMembershipFactory.create( + group=workspace.analyst_group, account=account + ) + # Source workspace auth domains membership. + # GroupAccountMembershipFactory.create( + # group=source_auth_domain_1, + # account=account, + # ) + GroupAccountMembershipFactory.create( + group=source_auth_domain_2.group, + account=account, + ) + # CollaborativeAnalysisWorkspace auth domain membership. + # GroupAccountMembershipFactory.create( + # group=workspace.workspace.authorization_domains.first(), account=account + # ) + # Set up audit + collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() + # Run audit + collab_audit._audit_workspace_and_account(workspace, account) + self.assertEqual(len(collab_audit.verified), 1) + self.assertEqual(len(collab_audit.needs_action), 0) + self.assertEqual(len(collab_audit.errors), 0) + record = collab_audit.verified[0] + self.assertIsInstance(record, audit.VerifiedNoAccess) + self.assertEqual(record.collaborative_analysis_workspace, workspace) + self.assertEqual(record.account, account) + self.assertEqual(record.note, collab_audit.NOT_IN_SOURCE_AUTH_DOMAINS) + + def test_analyst_not_in_collab_auth_domain_two_source_auth_domains_in_neither(self): + # Create accounts. + account = AccountFactory.create() + # Set up workspace. + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + # Set up source workspaces. + source_workspace = dbGaPWorkspaceFactory.create() + workspace.source_workspaces.add(source_workspace.workspace) + WorkspaceAuthorizationDomainFactory.create(workspace=source_workspace.workspace) + # Analyst group membership. + GroupAccountMembershipFactory.create( + group=workspace.analyst_group, account=account + ) + # Source workspace auth domains membership. + # GroupAccountMembershipFactory.create( + # group=source_auth_domain_1, + # account=account, + # ) + # GroupAccountMembershipFactory.create( + # group=source_auth_domain_2.group, + # account=account, + # ) + # CollaborativeAnalysisWorkspace auth domain membership. + # GroupAccountMembershipFactory.create( + # group=workspace.workspace.authorization_domains.first(), account=account + # ) + # Set up audit + collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() + # Run audit + collab_audit._audit_workspace_and_account(workspace, account) + self.assertEqual(len(collab_audit.verified), 1) + self.assertEqual(len(collab_audit.needs_action), 0) + self.assertEqual(len(collab_audit.errors), 0) + record = collab_audit.verified[0] + self.assertIsInstance(record, audit.VerifiedNoAccess) + self.assertEqual(record.collaborative_analysis_workspace, workspace) + self.assertEqual(record.account, account) + self.assertEqual(record.note, collab_audit.NOT_IN_SOURCE_AUTH_DOMAINS) + + def test_in_collab_auth_domain_no_source_workspaces(self): + # Create accounts. + account = AccountFactory.create() + # Set up workspace. + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + # Set up source workspaces. + source_workspace = WorkspaceFactory.create() + workspace.source_workspaces.add(source_workspace) + # Analyst group membership. + GroupAccountMembershipFactory.create( + group=workspace.analyst_group, account=account + ) + # Source workspace auth domains membership. + # CollaborativeAnalysisWorkspace auth domain membership. + GroupAccountMembershipFactory.create( + group=workspace.workspace.authorization_domains.first(), account=account + ) + # Set up audit + collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() + # Run audit + collab_audit._audit_workspace_and_account(workspace, account) + self.assertEqual(len(collab_audit.verified), 1) + self.assertEqual(len(collab_audit.needs_action), 0) + self.assertEqual(len(collab_audit.errors), 0) + record = collab_audit.verified[0] + self.assertIsInstance(record, audit.VerifiedAccess) + self.assertEqual(record.collaborative_analysis_workspace, workspace) + self.assertEqual(record.account, account) + self.assertEqual(record.note, collab_audit.IN_SOURCE_AUTH_DOMAINS) + + def test_not_in_collab_auth_domain_no_source_workspaces(self): + # Create accounts. + account = AccountFactory.create() + # Set up workspace. + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + # Set up source workspaces. + source_workspace = WorkspaceFactory.create() + workspace.source_workspaces.add(source_workspace) + # Analyst group membership. + GroupAccountMembershipFactory.create( + group=workspace.analyst_group, account=account + ) + # Source workspace auth domains membership. + # CollaborativeAnalysisWorkspace auth domain membership. + # GroupAccountMembershipFactory.create( + # group=workspace.workspace.authorization_domains.first(), account=account + # ) + # Set up audit + collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() + # Run audit + collab_audit._audit_workspace_and_account(workspace, account) + self.assertEqual(len(collab_audit.verified), 0) + self.assertEqual(len(collab_audit.needs_action), 1) + self.assertEqual(len(collab_audit.errors), 0) + record = collab_audit.needs_action[0] + self.assertIsInstance(record, audit.GrantAccess) + self.assertEqual(record.collaborative_analysis_workspace, workspace) + self.assertEqual(record.account, account) + self.assertEqual(record.note, collab_audit.IN_SOURCE_AUTH_DOMAINS) + + def test_in_collab_auth_domain_two_source_workspaces_in_both_auth_domains(self): + # Create accounts. + account = AccountFactory.create() + # Set up workspace. + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + # Set up source workspaces. + source_workspace_1 = dbGaPWorkspaceFactory.create() + workspace.source_workspaces.add(source_workspace_1.workspace) + source_workspace_2 = CDSAWorkspaceFactory.create() + workspace.source_workspaces.add(source_workspace_2.workspace) + # Analyst group membership. + GroupAccountMembershipFactory.create( + group=workspace.analyst_group, account=account + ) + # Source workspace auth domains membership. + GroupAccountMembershipFactory.create( + group=source_workspace_1.workspace.authorization_domains.first(), + account=account, + ) + GroupAccountMembershipFactory.create( + group=source_workspace_2.workspace.authorization_domains.first(), + account=account, + ) + # CollaborativeAnalysisWorkspace auth domain membership. + GroupAccountMembershipFactory.create( + group=workspace.workspace.authorization_domains.first(), account=account + ) + # Set up audit + collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() + # Run audit + collab_audit._audit_workspace_and_account(workspace, account) + self.assertEqual(len(collab_audit.verified), 1) + self.assertEqual(len(collab_audit.needs_action), 0) + self.assertEqual(len(collab_audit.errors), 0) + record = collab_audit.verified[0] + self.assertIsInstance(record, audit.VerifiedAccess) + self.assertEqual(record.collaborative_analysis_workspace, workspace) + self.assertEqual(record.account, account) + self.assertEqual(record.note, collab_audit.IN_SOURCE_AUTH_DOMAINS) + + def test_in_collab_auth_domain_two_source_workspaces_in_one_auth_domains(self): + # Create accounts. + account = AccountFactory.create() + # Set up workspace. + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + # Set up source workspaces. + source_workspace_1 = dbGaPWorkspaceFactory.create() + workspace.source_workspaces.add(source_workspace_1.workspace) + source_workspace_2 = CDSAWorkspaceFactory.create() + workspace.source_workspaces.add(source_workspace_2.workspace) + # Analyst group membership. + GroupAccountMembershipFactory.create( + group=workspace.analyst_group, account=account + ) + # Source workspace auth domains membership. + GroupAccountMembershipFactory.create( + group=source_workspace_1.workspace.authorization_domains.first(), + account=account, + ) + # GroupAccountMembershipFactory.create( + # group=source_workspace_2.workspace.authorization_domains.first(), + # account=account, + # ) + # CollaborativeAnalysisWorkspace auth domain membership. + GroupAccountMembershipFactory.create( + group=workspace.workspace.authorization_domains.first(), account=account + ) + # Set up audit + collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() + # Run audit + collab_audit._audit_workspace_and_account(workspace, account) + self.assertEqual(len(collab_audit.verified), 0) + self.assertEqual(len(collab_audit.needs_action), 1) + self.assertEqual(len(collab_audit.errors), 0) + record = collab_audit.needs_action[0] + self.assertIsInstance(record, audit.RemoveAccess) + self.assertEqual(record.collaborative_analysis_workspace, workspace) + self.assertEqual(record.account, account) + self.assertEqual(record.note, collab_audit.NOT_IN_SOURCE_AUTH_DOMAINS) + + def test_in_collab_auth_domain_two_source_workspaces_in_neither_auth_domains(self): + # Create accounts. + account = AccountFactory.create() + # Set up workspace. + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + # Set up source workspaces. + source_workspace_1 = dbGaPWorkspaceFactory.create() + workspace.source_workspaces.add(source_workspace_1.workspace) + source_workspace_2 = CDSAWorkspaceFactory.create() + workspace.source_workspaces.add(source_workspace_2.workspace) + # Analyst group membership. + GroupAccountMembershipFactory.create( + group=workspace.analyst_group, account=account + ) + # Source workspace auth domains membership. + # GroupAccountMembershipFactory.create( + # group=source_workspace_1.workspace.authorization_domains.first(), + # account=account, + # ) + # GroupAccountMembershipFactory.create( + # group=source_workspace_2.workspace.authorization_domains.first(), + # account=account, + # ) + # CollaborativeAnalysisWorkspace auth domain membership. + GroupAccountMembershipFactory.create( + group=workspace.workspace.authorization_domains.first(), account=account + ) + # Set up audit + collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() + # Run audit + collab_audit._audit_workspace_and_account(workspace, account) + self.assertEqual(len(collab_audit.verified), 0) + self.assertEqual(len(collab_audit.needs_action), 1) + self.assertEqual(len(collab_audit.errors), 0) + record = collab_audit.needs_action[0] + self.assertIsInstance(record, audit.RemoveAccess) + self.assertEqual(record.collaborative_analysis_workspace, workspace) + self.assertEqual(record.account, account) + self.assertEqual(record.note, collab_audit.NOT_IN_SOURCE_AUTH_DOMAINS) + + def test_not_in_collab_auth_domain_two_source_workspaces_in_both_auth_domains(self): + # Create accounts. + account = AccountFactory.create() + # Set up workspace. + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + # Set up source workspaces. + source_workspace_1 = dbGaPWorkspaceFactory.create() + workspace.source_workspaces.add(source_workspace_1.workspace) + source_workspace_2 = CDSAWorkspaceFactory.create() + workspace.source_workspaces.add(source_workspace_2.workspace) + # Analyst group membership. + GroupAccountMembershipFactory.create( + group=workspace.analyst_group, account=account + ) + # Source workspace auth domains membership. + GroupAccountMembershipFactory.create( + group=source_workspace_1.workspace.authorization_domains.first(), + account=account, + ) + GroupAccountMembershipFactory.create( + group=source_workspace_2.workspace.authorization_domains.first(), + account=account, + ) + # CollaborativeAnalysisWorkspace auth domain membership. + # GroupAccountMembershipFactory.create( + # group=workspace.workspace.authorization_domains.first(), account=account + # ) + # Set up audit + collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() + # Run audit + collab_audit._audit_workspace_and_account(workspace, account) + self.assertEqual(len(collab_audit.verified), 0) + self.assertEqual(len(collab_audit.needs_action), 1) + self.assertEqual(len(collab_audit.errors), 0) + record = collab_audit.needs_action[0] + self.assertIsInstance(record, audit.GrantAccess) + self.assertEqual(record.collaborative_analysis_workspace, workspace) + self.assertEqual(record.account, account) + self.assertEqual(record.note, collab_audit.IN_SOURCE_AUTH_DOMAINS) + + def test_not_in_collab_auth_domain_two_source_workspaces_in_one_auth_domains(self): + # Create accounts. + account = AccountFactory.create() + # Set up workspace. + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + # Set up source workspaces. + source_workspace_1 = dbGaPWorkspaceFactory.create() + workspace.source_workspaces.add(source_workspace_1.workspace) + source_workspace_2 = CDSAWorkspaceFactory.create() + workspace.source_workspaces.add(source_workspace_2.workspace) + # Analyst group membership. + GroupAccountMembershipFactory.create( + group=workspace.analyst_group, account=account + ) + # Source workspace auth domains membership. + GroupAccountMembershipFactory.create( + group=source_workspace_1.workspace.authorization_domains.first(), + account=account, + ) + # GroupAccountMembershipFactory.create( + # group=source_workspace_2.workspace.authorization_domains.first(), + # account=account, + # ) + # CollaborativeAnalysisWorkspace auth domain membership. + # GroupAccountMembershipFactory.create( + # group=workspace.workspace.authorization_domains.first(), account=account + # ) + # Set up audit + collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() + # Run audit + collab_audit._audit_workspace_and_account(workspace, account) + self.assertEqual(len(collab_audit.verified), 1) + self.assertEqual(len(collab_audit.needs_action), 0) + self.assertEqual(len(collab_audit.errors), 0) + record = collab_audit.verified[0] + self.assertIsInstance(record, audit.VerifiedNoAccess) + self.assertEqual(record.collaborative_analysis_workspace, workspace) + self.assertEqual(record.account, account) + self.assertEqual(record.note, collab_audit.NOT_IN_SOURCE_AUTH_DOMAINS) + + def test_not_in_collab_auth_domain_two_source_workspaces_in_neither_auth_domains( + self, + ): + # Create accounts. + account = AccountFactory.create() + # Set up workspace. + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + # Set up source workspaces. + source_workspace_1 = dbGaPWorkspaceFactory.create() + workspace.source_workspaces.add(source_workspace_1.workspace) + source_workspace_2 = CDSAWorkspaceFactory.create() + workspace.source_workspaces.add(source_workspace_2.workspace) + # Analyst group membership. + GroupAccountMembershipFactory.create( + group=workspace.analyst_group, account=account + ) + # Source workspace auth domains membership. + # GroupAccountMembershipFactory.create( + # group=source_workspace_1.workspace.authorization_domains.first(), + # account=account, + # ) + # GroupAccountMembershipFactory.create( + # group=source_workspace_2.workspace.authorization_domains.first(), + # account=account, + # ) + # CollaborativeAnalysisWorkspace auth domain membership. + # GroupAccountMembershipFactory.create( + # group=workspace.workspace.authorization_domains.first(), account=account + # ) + # Set up audit + collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() + # Run audit + collab_audit._audit_workspace_and_account(workspace, account) + self.assertEqual(len(collab_audit.verified), 1) + self.assertEqual(len(collab_audit.needs_action), 0) + self.assertEqual(len(collab_audit.errors), 0) + record = collab_audit.verified[0] + self.assertIsInstance(record, audit.VerifiedNoAccess) + self.assertEqual(record.collaborative_analysis_workspace, workspace) + self.assertEqual(record.account, account) + self.assertEqual(record.note, collab_audit.NOT_IN_SOURCE_AUTH_DOMAINS) # def test_workspace_has_no_source_workspaces(self): # self.fail() From 259f927ce6ec44efdfc22ec1013c3295ce13876f Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Thu, 14 Dec 2023 14:32:24 -0800 Subject: [PATCH 19/37] Add tests for the _audit_workspace method --- primed/collaborative_analysis/audit.py | 33 +++++++- .../tests/test_audit.py | 83 ++++++++++++++++++- 2 files changed, 112 insertions(+), 4 deletions(-) diff --git a/primed/collaborative_analysis/audit.py b/primed/collaborative_analysis/audit.py index 59546751..5ee8b5e0 100644 --- a/primed/collaborative_analysis/audit.py +++ b/primed/collaborative_analysis/audit.py @@ -1,6 +1,6 @@ from dataclasses import dataclass -from anvil_consortium_manager.models import Account +from anvil_consortium_manager.models import Account, GroupAccountMembership from django.urls import reverse from . import models @@ -77,6 +77,7 @@ class CollaborativeAnalysisWorkspaceAccessAudit: NOT_IN_SOURCE_AUTH_DOMAINS = ( "Account is not in all source auth domains for this workspace." ) + NOT_IN_ANALYST_GROUP = "Account is not in the analyst group for this workspace." def __init__(self): self.verified = [] @@ -86,7 +87,35 @@ def __init__(self): def _audit_workspace(self, workspace): """Audit access to a single CollaborativeAnalysisWorkspace.""" - pass + # Loop over analyst accounts for this workspace. + # In the loop, run the _audit_workspace_and_account method. + # Any remainig accounts in the auth domain that are not in the analyst group should be *errors*. + analyst_group = workspace.analyst_group + analyst_memberships = GroupAccountMembership.objects.filter(group=analyst_group) + # Get a list of accounts in the auth domain. + auth_domain_membership = [ + x.account + for x in GroupAccountMembership.objects.filter( + group=workspace.workspace.authorization_domains.first() + ) + ] + for membership in analyst_memberships: + self._audit_workspace_and_account(workspace, membership.account) + try: + auth_domain_membership.remove(membership.account) + except ValueError: + # This is fine - this happens if the account is not in the auth domain. + pass + + # Loop over remaining accounts in the auth domain. + for account in auth_domain_membership: + self.errors.append( + RemoveAccess( + collaborative_analysis_workspace=workspace, + account=account, + note=self.NOT_IN_ANALYST_GROUP, + ) + ) def _audit_workspace_and_account(self, collaborative_analysis_workspace, account): """Audit access for a specific CollaborativeAnalysisWorkspace and account.""" diff --git a/primed/collaborative_analysis/tests/test_audit.py b/primed/collaborative_analysis/tests/test_audit.py index dfa9c5cf..1404bf1e 100644 --- a/primed/collaborative_analysis/tests/test_audit.py +++ b/primed/collaborative_analysis/tests/test_audit.py @@ -767,5 +767,84 @@ def test_not_in_collab_auth_domain_two_source_workspaces_in_neither_auth_domains self.assertEqual(record.account, account) self.assertEqual(record.note, collab_audit.NOT_IN_SOURCE_AUTH_DOMAINS) - # def test_workspace_has_no_source_workspaces(self): - # self.fail() + def test_two_workspaces(self): + # Create a workspace with an analyst that needs access. + workspace_1 = factories.CollaborativeAnalysisWorkspaceFactory.create() + analyst_1 = AccountFactory.create() + GroupAccountMembershipFactory.create( + group=workspace_1.analyst_group, account=analyst_1 + ) + # Create a workspace with an analyst that has access. + workspace_2 = factories.CollaborativeAnalysisWorkspaceFactory.create() + analyst_2 = AccountFactory.create() + GroupAccountMembershipFactory.create( + group=workspace_2.analyst_group, account=analyst_2 + ) + GroupAccountMembershipFactory.create( + group=workspace_2.workspace.authorization_domains.first(), account=analyst_2 + ) + collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() + collab_audit.run_audit() + self.assertEqual(len(collab_audit.verified), 1) + self.assertEqual(len(collab_audit.needs_action), 1) + self.assertEqual(len(collab_audit.errors), 0) + record = collab_audit.verified[0] + self.assertIsInstance(record, audit.VerifiedAccess) + self.assertEqual(record.collaborative_analysis_workspace, workspace_2) + self.assertEqual(record.account, analyst_2) + self.assertEqual(record.note, collab_audit.IN_SOURCE_AUTH_DOMAINS) + record = collab_audit.needs_action[0] + self.assertIsInstance(record, audit.GrantAccess) + self.assertEqual(record.collaborative_analysis_workspace, workspace_1) + self.assertEqual(record.account, analyst_1) + self.assertEqual(record.note, collab_audit.IN_SOURCE_AUTH_DOMAINS) + + def test_two_analysts(self): + # Create an analyst that needs access. + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + analyst_1 = AccountFactory.create() + GroupAccountMembershipFactory.create( + group=workspace.analyst_group, account=analyst_1 + ) + # Create an analyst that has access. + analyst_2 = AccountFactory.create() + GroupAccountMembershipFactory.create( + group=workspace.analyst_group, account=analyst_2 + ) + GroupAccountMembershipFactory.create( + group=workspace.workspace.authorization_domains.first(), account=analyst_2 + ) + collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() + collab_audit.run_audit() + self.assertEqual(len(collab_audit.verified), 1) + self.assertEqual(len(collab_audit.needs_action), 1) + self.assertEqual(len(collab_audit.errors), 0) + record = collab_audit.verified[0] + self.assertIsInstance(record, audit.VerifiedAccess) + self.assertEqual(record.collaborative_analysis_workspace, workspace) + self.assertEqual(record.account, analyst_2) + self.assertEqual(record.note, collab_audit.IN_SOURCE_AUTH_DOMAINS) + record = collab_audit.needs_action[0] + self.assertIsInstance(record, audit.GrantAccess) + self.assertEqual(record.collaborative_analysis_workspace, workspace) + self.assertEqual(record.account, analyst_1) + self.assertEqual(record.note, collab_audit.IN_SOURCE_AUTH_DOMAINS) + + def test_not_in_analyst_group(self): + # Create an analyst that needs access. + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + # Create an analyst that has access but is not in the analyst group. + analyst = AccountFactory.create() + GroupAccountMembershipFactory.create( + group=workspace.workspace.authorization_domains.first(), account=analyst + ) + collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() + collab_audit.run_audit() + self.assertEqual(len(collab_audit.verified), 0) + self.assertEqual(len(collab_audit.needs_action), 0) + self.assertEqual(len(collab_audit.errors), 1) + record = collab_audit.errors[0] + self.assertIsInstance(record, audit.RemoveAccess) + self.assertEqual(record.collaborative_analysis_workspace, workspace) + self.assertEqual(record.account, analyst) + self.assertEqual(record.note, collab_audit.NOT_IN_ANALYST_GROUP) From ba358d1477040afa49dbe39993b2d289184920c8 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Thu, 14 Dec 2023 14:54:33 -0800 Subject: [PATCH 20/37] Handle checking groups for collaborative analysis audits In addition to checking analyst auth domain membership, also check that there are no groups in the auth domain. Make an exception for PRIMED_CC_ADMINS, which we expect to be in all of the groups in the app. TBD how to handle PRIMED member/writer groups. To facilitate this update, change the AccessAuditResult.account attribute name to AccessAuditResult.member and the type to be either an Account or ManagedGroup. --- primed/collaborative_analysis/audit.py | 86 ++++++-- .../tests/test_audit.py | 204 ++++++++++++------ 2 files changed, 205 insertions(+), 85 deletions(-) diff --git a/primed/collaborative_analysis/audit.py b/primed/collaborative_analysis/audit.py index 5ee8b5e0..139330fb 100644 --- a/primed/collaborative_analysis/audit.py +++ b/primed/collaborative_analysis/audit.py @@ -1,6 +1,12 @@ from dataclasses import dataclass - -from anvil_consortium_manager.models import Account, GroupAccountMembership +from typing import Union + +from anvil_consortium_manager.models import ( + Account, + GroupAccountMembership, + GroupGroupMembership, + ManagedGroup, +) from django.urls import reverse from . import models @@ -11,7 +17,7 @@ class AccessAuditResult: """Base class to hold the result of an access audit for a CollaborativeAnalysisWorkspace.""" collaborative_analysis_workspace: models.CollaborativeAnalysisWorkspace - account: Account + member: Union[Account, ManagedGroup] note: str def get_action_url(self): @@ -41,13 +47,22 @@ def get_action(self): return "Grant access" def get_action_url(self): - return reverse( - "anvil_consortium_manager:managed_groups:member_accounts:new_by_account", - args=[ - self.collaborative_analysis_workspace.workspace.authorization_domains.first().name, - self.account.uuid, - ], - ) + if isinstance(self.member, Account): + return reverse( + "anvil_consortium_manager:managed_groups:member_accounts:new_by_account", + args=[ + self.collaborative_analysis_workspace.workspace.authorization_domains.first().name, + self.member.uuid, + ], + ) + else: + return reverse( + "anvil_consortium_manager:managed_groups:member_groups:new_by_child", + args=[ + self.collaborative_analysis_workspace.workspace.authorization_domains.first().name, + self.member.name, + ], + ) @dataclass @@ -58,13 +73,22 @@ def get_action(self): return "Remove access" def get_action_url(self): - return reverse( - "anvil_consortium_manager:managed_groups:member_accounts:delete", - args=[ - self.collaborative_analysis_workspace.workspace.authorization_domains.first().name, - self.account.uuid, - ], - ) + if isinstance(self.member, Account): + return reverse( + "anvil_consortium_manager:managed_groups:member_accounts:delete", + args=[ + self.collaborative_analysis_workspace.workspace.authorization_domains.first().name, + self.member.uuid, + ], + ) + else: + return reverse( + "anvil_consortium_manager:managed_groups:member_groups:delete", + args=[ + self.collaborative_analysis_workspace.workspace.authorization_domains.first().name, + self.member.name, + ], + ) class CollaborativeAnalysisWorkspaceAccessAudit: @@ -79,6 +103,9 @@ class CollaborativeAnalysisWorkspaceAccessAudit: ) NOT_IN_ANALYST_GROUP = "Account is not in the analyst group for this workspace." + # Errors. + UNEXPECTED_GROUP_ACCESS = "Unexpected group added to the auth domain." + def __init__(self): self.verified = [] self.needs_action = [] @@ -112,11 +139,26 @@ def _audit_workspace(self, workspace): self.errors.append( RemoveAccess( collaborative_analysis_workspace=workspace, - account=account, + member=account, note=self.NOT_IN_ANALYST_GROUP, ) ) + # Check that no groups have access. + group_memberships = GroupGroupMembership.objects.filter( + parent_group=workspace.workspace.authorization_domains.first() + ) + for membership in group_memberships: + # Ignore PRIMED admins group. + if membership.child_group.name != "PRIMED_CC_ADMINS": + self.errors.append( + RemoveAccess( + collaborative_analysis_workspace=workspace, + member=membership.child_group, + note=self.UNEXPECTED_GROUP_ACCESS, + ) + ) + def _audit_workspace_and_account(self, collaborative_analysis_workspace, account): """Audit access for a specific CollaborativeAnalysisWorkspace and account.""" # Cases to consider: @@ -156,7 +198,7 @@ def _audit_workspace_and_account(self, collaborative_analysis_workspace, account self.verified.append( VerifiedAccess( collaborative_analysis_workspace=collaborative_analysis_workspace, - account=account, + member=account, note=self.IN_SOURCE_AUTH_DOMAINS, ) ) @@ -164,7 +206,7 @@ def _audit_workspace_and_account(self, collaborative_analysis_workspace, account self.needs_action.append( GrantAccess( collaborative_analysis_workspace=collaborative_analysis_workspace, - account=account, + member=account, note=self.IN_SOURCE_AUTH_DOMAINS, ) ) @@ -172,7 +214,7 @@ def _audit_workspace_and_account(self, collaborative_analysis_workspace, account self.needs_action.append( RemoveAccess( collaborative_analysis_workspace=collaborative_analysis_workspace, - account=account, + member=account, note=self.NOT_IN_SOURCE_AUTH_DOMAINS, ) ) @@ -180,7 +222,7 @@ def _audit_workspace_and_account(self, collaborative_analysis_workspace, account self.verified.append( VerifiedNoAccess( collaborative_analysis_workspace=collaborative_analysis_workspace, - account=account, + member=account, note=self.NOT_IN_SOURCE_AUTH_DOMAINS, ) ) diff --git a/primed/collaborative_analysis/tests/test_audit.py b/primed/collaborative_analysis/tests/test_audit.py index 1404bf1e..493ae8e8 100644 --- a/primed/collaborative_analysis/tests/test_audit.py +++ b/primed/collaborative_analysis/tests/test_audit.py @@ -1,6 +1,8 @@ from anvil_consortium_manager.tests.factories import ( AccountFactory, GroupAccountMembershipFactory, + GroupGroupMembershipFactory, + ManagedGroupFactory, WorkspaceAuthorizationDomainFactory, WorkspaceFactory, ) @@ -18,27 +20,27 @@ class WorkspaceAccessAuditResultTest(TestCase): def setUp(self): super().setUp() - def test_verified_access(self): + def test_account_verified_access(self): workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() account = AccountFactory.create() instance = audit.VerifiedAccess( - collaborative_analysis_workspace=workspace, account=account, note="test" + collaborative_analysis_workspace=workspace, member=account, note="test" ) self.assertIsNone(instance.get_action_url()) - def test_verified_no_access(self): + def test_account_verified_no_access(self): workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() account = AccountFactory.create() instance = audit.VerifiedNoAccess( - collaborative_analysis_workspace=workspace, account=account, note="test" + collaborative_analysis_workspace=workspace, member=account, note="test" ) self.assertIsNone(instance.get_action_url()) - def test_grant_access(self): + def test_account_grant_access(self): workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() account = AccountFactory.create() instance = audit.GrantAccess( - collaborative_analysis_workspace=workspace, account=account, note="test" + collaborative_analysis_workspace=workspace, member=account, note="test" ) expected_url = reverse( "anvil_consortium_manager:managed_groups:member_accounts:new_by_account", @@ -46,11 +48,11 @@ def test_grant_access(self): ) self.assertEqual(instance.get_action_url(), expected_url) - def test_remove_access(self): + def test_account_remove_access(self): workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() account = AccountFactory.create() instance = audit.RemoveAccess( - collaborative_analysis_workspace=workspace, account=account, note="test" + collaborative_analysis_workspace=workspace, member=account, note="test" ) expected_url = reverse( "anvil_consortium_manager:managed_groups:member_accounts:delete", @@ -58,6 +60,46 @@ def test_remove_access(self): ) self.assertEqual(instance.get_action_url(), expected_url) + def test_group_verified_access(self): + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + group = ManagedGroupFactory.create() + instance = audit.VerifiedAccess( + collaborative_analysis_workspace=workspace, member=group, note="test" + ) + self.assertIsNone(instance.get_action_url()) + + def test_group_verified_no_access(self): + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + group = ManagedGroupFactory.create() + instance = audit.VerifiedNoAccess( + collaborative_analysis_workspace=workspace, member=group, note="test" + ) + self.assertIsNone(instance.get_action_url()) + + def test_group_grant_access(self): + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + group = ManagedGroupFactory.create() + instance = audit.GrantAccess( + collaborative_analysis_workspace=workspace, member=group, note="test" + ) + expected_url = reverse( + "anvil_consortium_manager:managed_groups:member_groups:new_by_child", + args=[workspace.workspace.authorization_domains.first().name, group.name], + ) + self.assertEqual(instance.get_action_url(), expected_url) + + def test_group_remove_access(self): + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + group = ManagedGroupFactory.create() + instance = audit.RemoveAccess( + collaborative_analysis_workspace=workspace, member=group, note="test" + ) + expected_url = reverse( + "anvil_consortium_manager:managed_groups:member_groups:delete", + args=[workspace.workspace.authorization_domains.first().name, group.name], + ) + self.assertEqual(instance.get_action_url(), expected_url) + class CollaborativeAnalysisWorkspaceAccessAudit(TestCase): """Tests for the CollaborativeAnalysisWorkspaceAccessAudit class.""" @@ -117,7 +159,7 @@ def test_analyst_in_collab_auth_domain_in_source_auth_domain(self): record = collab_audit.verified[0] self.assertIsInstance(record, audit.VerifiedAccess) self.assertEqual(record.collaborative_analysis_workspace, workspace) - self.assertEqual(record.account, account) + self.assertEqual(record.member, account) self.assertEqual(record.note, collab_audit.IN_SOURCE_AUTH_DOMAINS) def test_analyst_in_collab_auth_domain_not_in_source_auth_domain(self): @@ -150,7 +192,7 @@ def test_analyst_in_collab_auth_domain_not_in_source_auth_domain(self): record = collab_audit.needs_action[0] self.assertIsInstance(record, audit.RemoveAccess) self.assertEqual(record.collaborative_analysis_workspace, workspace) - self.assertEqual(record.account, account) + self.assertEqual(record.member, account) self.assertEqual(record.note, collab_audit.NOT_IN_SOURCE_AUTH_DOMAINS) def test_analyst_not_in_collab_auth_domain_in_source_auth_domain(self): @@ -184,7 +226,7 @@ def test_analyst_not_in_collab_auth_domain_in_source_auth_domain(self): record = collab_audit.needs_action[0] self.assertIsInstance(record, audit.GrantAccess) self.assertEqual(record.collaborative_analysis_workspace, workspace) - self.assertEqual(record.account, account) + self.assertEqual(record.member, account) self.assertEqual(record.note, collab_audit.IN_SOURCE_AUTH_DOMAINS) def test_analyst_not_in_collab_auth_domain_not_in_source_auth_domain(self): @@ -217,7 +259,7 @@ def test_analyst_not_in_collab_auth_domain_not_in_source_auth_domain(self): record = collab_audit.verified[0] self.assertIsInstance(record, audit.VerifiedNoAccess) self.assertEqual(record.collaborative_analysis_workspace, workspace) - self.assertEqual(record.account, account) + self.assertEqual(record.member, account) self.assertEqual(record.note, collab_audit.NOT_IN_SOURCE_AUTH_DOMAINS) def test_analyst_in_collab_auth_domain_two_source_auth_domains_in_both(self): @@ -258,7 +300,7 @@ def test_analyst_in_collab_auth_domain_two_source_auth_domains_in_both(self): record = collab_audit.verified[0] self.assertIsInstance(record, audit.VerifiedAccess) self.assertEqual(record.collaborative_analysis_workspace, workspace) - self.assertEqual(record.account, account) + self.assertEqual(record.member, account) self.assertEqual(record.note, collab_audit.IN_SOURCE_AUTH_DOMAINS) def test_analyst_in_collab_auth_domain_two_source_auth_domains_in_one(self): @@ -299,7 +341,7 @@ def test_analyst_in_collab_auth_domain_two_source_auth_domains_in_one(self): record = collab_audit.needs_action[0] self.assertIsInstance(record, audit.RemoveAccess) self.assertEqual(record.collaborative_analysis_workspace, workspace) - self.assertEqual(record.account, account) + self.assertEqual(record.member, account) self.assertEqual(record.note, collab_audit.NOT_IN_SOURCE_AUTH_DOMAINS) def test_analyst_in_collab_auth_domain_two_source_auth_domains_in_neither(self): @@ -340,7 +382,7 @@ def test_analyst_in_collab_auth_domain_two_source_auth_domains_in_neither(self): record = collab_audit.needs_action[0] self.assertIsInstance(record, audit.RemoveAccess) self.assertEqual(record.collaborative_analysis_workspace, workspace) - self.assertEqual(record.account, account) + self.assertEqual(record.member, account) self.assertEqual(record.note, collab_audit.NOT_IN_SOURCE_AUTH_DOMAINS) def test_analyst_not_in_collab_auth_domain_two_source_auth_domains_in_both(self): @@ -382,7 +424,7 @@ def test_analyst_not_in_collab_auth_domain_two_source_auth_domains_in_both(self) record = collab_audit.needs_action[0] self.assertIsInstance(record, audit.GrantAccess) self.assertEqual(record.collaborative_analysis_workspace, workspace) - self.assertEqual(record.account, account) + self.assertEqual(record.member, account) self.assertEqual(record.note, collab_audit.IN_SOURCE_AUTH_DOMAINS) def test_analyst_not_in_collab_auth_domain_two_source_auth_domains_in_one(self): @@ -423,7 +465,7 @@ def test_analyst_not_in_collab_auth_domain_two_source_auth_domains_in_one(self): record = collab_audit.verified[0] self.assertIsInstance(record, audit.VerifiedNoAccess) self.assertEqual(record.collaborative_analysis_workspace, workspace) - self.assertEqual(record.account, account) + self.assertEqual(record.member, account) self.assertEqual(record.note, collab_audit.NOT_IN_SOURCE_AUTH_DOMAINS) def test_analyst_not_in_collab_auth_domain_two_source_auth_domains_in_neither(self): @@ -462,7 +504,7 @@ def test_analyst_not_in_collab_auth_domain_two_source_auth_domains_in_neither(se record = collab_audit.verified[0] self.assertIsInstance(record, audit.VerifiedNoAccess) self.assertEqual(record.collaborative_analysis_workspace, workspace) - self.assertEqual(record.account, account) + self.assertEqual(record.member, account) self.assertEqual(record.note, collab_audit.NOT_IN_SOURCE_AUTH_DOMAINS) def test_in_collab_auth_domain_no_source_workspaces(self): @@ -492,7 +534,7 @@ def test_in_collab_auth_domain_no_source_workspaces(self): record = collab_audit.verified[0] self.assertIsInstance(record, audit.VerifiedAccess) self.assertEqual(record.collaborative_analysis_workspace, workspace) - self.assertEqual(record.account, account) + self.assertEqual(record.member, account) self.assertEqual(record.note, collab_audit.IN_SOURCE_AUTH_DOMAINS) def test_not_in_collab_auth_domain_no_source_workspaces(self): @@ -522,7 +564,7 @@ def test_not_in_collab_auth_domain_no_source_workspaces(self): record = collab_audit.needs_action[0] self.assertIsInstance(record, audit.GrantAccess) self.assertEqual(record.collaborative_analysis_workspace, workspace) - self.assertEqual(record.account, account) + self.assertEqual(record.member, account) self.assertEqual(record.note, collab_audit.IN_SOURCE_AUTH_DOMAINS) def test_in_collab_auth_domain_two_source_workspaces_in_both_auth_domains(self): @@ -562,7 +604,7 @@ def test_in_collab_auth_domain_two_source_workspaces_in_both_auth_domains(self): record = collab_audit.verified[0] self.assertIsInstance(record, audit.VerifiedAccess) self.assertEqual(record.collaborative_analysis_workspace, workspace) - self.assertEqual(record.account, account) + self.assertEqual(record.member, account) self.assertEqual(record.note, collab_audit.IN_SOURCE_AUTH_DOMAINS) def test_in_collab_auth_domain_two_source_workspaces_in_one_auth_domains(self): @@ -602,7 +644,7 @@ def test_in_collab_auth_domain_two_source_workspaces_in_one_auth_domains(self): record = collab_audit.needs_action[0] self.assertIsInstance(record, audit.RemoveAccess) self.assertEqual(record.collaborative_analysis_workspace, workspace) - self.assertEqual(record.account, account) + self.assertEqual(record.member, account) self.assertEqual(record.note, collab_audit.NOT_IN_SOURCE_AUTH_DOMAINS) def test_in_collab_auth_domain_two_source_workspaces_in_neither_auth_domains(self): @@ -642,7 +684,7 @@ def test_in_collab_auth_domain_two_source_workspaces_in_neither_auth_domains(sel record = collab_audit.needs_action[0] self.assertIsInstance(record, audit.RemoveAccess) self.assertEqual(record.collaborative_analysis_workspace, workspace) - self.assertEqual(record.account, account) + self.assertEqual(record.member, account) self.assertEqual(record.note, collab_audit.NOT_IN_SOURCE_AUTH_DOMAINS) def test_not_in_collab_auth_domain_two_source_workspaces_in_both_auth_domains(self): @@ -682,7 +724,7 @@ def test_not_in_collab_auth_domain_two_source_workspaces_in_both_auth_domains(se record = collab_audit.needs_action[0] self.assertIsInstance(record, audit.GrantAccess) self.assertEqual(record.collaborative_analysis_workspace, workspace) - self.assertEqual(record.account, account) + self.assertEqual(record.member, account) self.assertEqual(record.note, collab_audit.IN_SOURCE_AUTH_DOMAINS) def test_not_in_collab_auth_domain_two_source_workspaces_in_one_auth_domains(self): @@ -722,7 +764,7 @@ def test_not_in_collab_auth_domain_two_source_workspaces_in_one_auth_domains(sel record = collab_audit.verified[0] self.assertIsInstance(record, audit.VerifiedNoAccess) self.assertEqual(record.collaborative_analysis_workspace, workspace) - self.assertEqual(record.account, account) + self.assertEqual(record.member, account) self.assertEqual(record.note, collab_audit.NOT_IN_SOURCE_AUTH_DOMAINS) def test_not_in_collab_auth_domain_two_source_workspaces_in_neither_auth_domains( @@ -764,41 +806,9 @@ def test_not_in_collab_auth_domain_two_source_workspaces_in_neither_auth_domains record = collab_audit.verified[0] self.assertIsInstance(record, audit.VerifiedNoAccess) self.assertEqual(record.collaborative_analysis_workspace, workspace) - self.assertEqual(record.account, account) + self.assertEqual(record.member, account) self.assertEqual(record.note, collab_audit.NOT_IN_SOURCE_AUTH_DOMAINS) - def test_two_workspaces(self): - # Create a workspace with an analyst that needs access. - workspace_1 = factories.CollaborativeAnalysisWorkspaceFactory.create() - analyst_1 = AccountFactory.create() - GroupAccountMembershipFactory.create( - group=workspace_1.analyst_group, account=analyst_1 - ) - # Create a workspace with an analyst that has access. - workspace_2 = factories.CollaborativeAnalysisWorkspaceFactory.create() - analyst_2 = AccountFactory.create() - GroupAccountMembershipFactory.create( - group=workspace_2.analyst_group, account=analyst_2 - ) - GroupAccountMembershipFactory.create( - group=workspace_2.workspace.authorization_domains.first(), account=analyst_2 - ) - collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() - collab_audit.run_audit() - self.assertEqual(len(collab_audit.verified), 1) - self.assertEqual(len(collab_audit.needs_action), 1) - self.assertEqual(len(collab_audit.errors), 0) - record = collab_audit.verified[0] - self.assertIsInstance(record, audit.VerifiedAccess) - self.assertEqual(record.collaborative_analysis_workspace, workspace_2) - self.assertEqual(record.account, analyst_2) - self.assertEqual(record.note, collab_audit.IN_SOURCE_AUTH_DOMAINS) - record = collab_audit.needs_action[0] - self.assertIsInstance(record, audit.GrantAccess) - self.assertEqual(record.collaborative_analysis_workspace, workspace_1) - self.assertEqual(record.account, analyst_1) - self.assertEqual(record.note, collab_audit.IN_SOURCE_AUTH_DOMAINS) - def test_two_analysts(self): # Create an analyst that needs access. workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() @@ -815,19 +825,19 @@ def test_two_analysts(self): group=workspace.workspace.authorization_domains.first(), account=analyst_2 ) collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() - collab_audit.run_audit() + collab_audit._audit_workspace(workspace) self.assertEqual(len(collab_audit.verified), 1) self.assertEqual(len(collab_audit.needs_action), 1) self.assertEqual(len(collab_audit.errors), 0) record = collab_audit.verified[0] self.assertIsInstance(record, audit.VerifiedAccess) self.assertEqual(record.collaborative_analysis_workspace, workspace) - self.assertEqual(record.account, analyst_2) + self.assertEqual(record.member, analyst_2) self.assertEqual(record.note, collab_audit.IN_SOURCE_AUTH_DOMAINS) record = collab_audit.needs_action[0] self.assertIsInstance(record, audit.GrantAccess) self.assertEqual(record.collaborative_analysis_workspace, workspace) - self.assertEqual(record.account, analyst_1) + self.assertEqual(record.member, analyst_1) self.assertEqual(record.note, collab_audit.IN_SOURCE_AUTH_DOMAINS) def test_not_in_analyst_group(self): @@ -839,12 +849,80 @@ def test_not_in_analyst_group(self): group=workspace.workspace.authorization_domains.first(), account=analyst ) collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() - collab_audit.run_audit() + collab_audit._audit_workspace(workspace) self.assertEqual(len(collab_audit.verified), 0) self.assertEqual(len(collab_audit.needs_action), 0) self.assertEqual(len(collab_audit.errors), 1) record = collab_audit.errors[0] self.assertIsInstance(record, audit.RemoveAccess) self.assertEqual(record.collaborative_analysis_workspace, workspace) - self.assertEqual(record.account, analyst) + self.assertEqual(record.member, analyst) self.assertEqual(record.note, collab_audit.NOT_IN_ANALYST_GROUP) + + def test_unexpected_group_in_auth_domain(self): + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + # Add a group to the auth domain. + group = ManagedGroupFactory.create() + GroupGroupMembershipFactory.create( + parent_group=workspace.workspace.authorization_domains.first(), + child_group=group, + ) + collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() + collab_audit._audit_workspace(workspace) + self.assertEqual(len(collab_audit.verified), 0) + self.assertEqual(len(collab_audit.needs_action), 0) + self.assertEqual(len(collab_audit.errors), 1) + record = collab_audit.errors[0] + self.assertIsInstance(record, audit.RemoveAccess) + self.assertEqual(record.collaborative_analysis_workspace, workspace) + self.assertEqual(record.member, group) + self.assertEqual(record.note, collab_audit.UNEXPECTED_GROUP_ACCESS) + + def test_no_errors_for_primed_admins_group(self): + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + # Add a group to the auth domain. + group = ManagedGroupFactory.create(name="PRIMED_CC_ADMINS") + GroupGroupMembershipFactory.create( + parent_group=workspace.workspace.authorization_domains.first(), + child_group=group, + ) + collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() + collab_audit._audit_workspace(workspace) + self.assertEqual(len(collab_audit.verified), 0) + self.assertEqual(len(collab_audit.needs_action), 0) + self.assertEqual(len(collab_audit.errors), 0) + + def test_dcc_access(self): + self.fail("How do we handle DCC member access?") + + def test_two_workspaces(self): + # Create a workspace with an analyst that needs access. + workspace_1 = factories.CollaborativeAnalysisWorkspaceFactory.create() + analyst_1 = AccountFactory.create() + GroupAccountMembershipFactory.create( + group=workspace_1.analyst_group, account=analyst_1 + ) + # Create a workspace with an analyst that has access. + workspace_2 = factories.CollaborativeAnalysisWorkspaceFactory.create() + analyst_2 = AccountFactory.create() + GroupAccountMembershipFactory.create( + group=workspace_2.analyst_group, account=analyst_2 + ) + GroupAccountMembershipFactory.create( + group=workspace_2.workspace.authorization_domains.first(), account=analyst_2 + ) + collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() + collab_audit.run_audit() + self.assertEqual(len(collab_audit.verified), 1) + self.assertEqual(len(collab_audit.needs_action), 1) + self.assertEqual(len(collab_audit.errors), 0) + record = collab_audit.verified[0] + self.assertIsInstance(record, audit.VerifiedAccess) + self.assertEqual(record.collaborative_analysis_workspace, workspace_2) + self.assertEqual(record.member, analyst_2) + self.assertEqual(record.note, collab_audit.IN_SOURCE_AUTH_DOMAINS) + record = collab_audit.needs_action[0] + self.assertIsInstance(record, audit.GrantAccess) + self.assertEqual(record.collaborative_analysis_workspace, workspace_1) + self.assertEqual(record.member, analyst_1) + self.assertEqual(record.note, collab_audit.IN_SOURCE_AUTH_DOMAINS) From 9661173ab90b0cd131b813cd171f2e8114f46cb4 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Thu, 14 Dec 2023 15:05:24 -0800 Subject: [PATCH 21/37] Allow audit to only run on a specific workspace queryset --- primed/collaborative_analysis/audit.py | 14 +++++-- .../tests/test_audit.py | 42 +++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/primed/collaborative_analysis/audit.py b/primed/collaborative_analysis/audit.py index 139330fb..21fb3e1c 100644 --- a/primed/collaborative_analysis/audit.py +++ b/primed/collaborative_analysis/audit.py @@ -106,7 +106,15 @@ class CollaborativeAnalysisWorkspaceAccessAudit: # Errors. UNEXPECTED_GROUP_ACCESS = "Unexpected group added to the auth domain." - def __init__(self): + def __init__(self, queryset=None): + """Initialize the audit. + + Args: + queryset: A queryset of CollaborativeAnalysisWorkspaces to audit. + """ + if queryset is None: + queryset = models.CollaborativeAnalysisWorkspace.objects.all() + self.queryset = queryset self.verified = [] self.needs_action = [] self.errors = [] @@ -228,7 +236,7 @@ def _audit_workspace_and_account(self, collaborative_analysis_workspace, account ) def run_audit(self): - """Run an audit on all CollaborativeAnalysisWorkspaces.""" - for workspace in models.CollaborativeAnalysisWorkspace.objects.all(): + """Run the audit on the set of workspaces.""" + for workspace in self.queryset: self._audit_workspace(workspace) self.completed = True diff --git a/primed/collaborative_analysis/tests/test_audit.py b/primed/collaborative_analysis/tests/test_audit.py index 493ae8e8..72cab949 100644 --- a/primed/collaborative_analysis/tests/test_audit.py +++ b/primed/collaborative_analysis/tests/test_audit.py @@ -926,3 +926,45 @@ def test_two_workspaces(self): self.assertEqual(record.collaborative_analysis_workspace, workspace_1) self.assertEqual(record.member, analyst_1) self.assertEqual(record.note, collab_audit.IN_SOURCE_AUTH_DOMAINS) + + def test_queryset(self): + """Audit only runs on the specified queryset of workspaces.""" + # Create a workspace with an analyst that needs access. + workspace_1 = factories.CollaborativeAnalysisWorkspaceFactory.create() + analyst_1 = AccountFactory.create() + GroupAccountMembershipFactory.create( + group=workspace_1.analyst_group, account=analyst_1 + ) + # Create a workspace with an analyst that has access. + workspace_2 = factories.CollaborativeAnalysisWorkspaceFactory.create() + analyst_2 = AccountFactory.create() + GroupAccountMembershipFactory.create( + group=workspace_2.analyst_group, account=analyst_2 + ) + GroupAccountMembershipFactory.create( + group=workspace_2.workspace.authorization_domains.first(), account=analyst_2 + ) + collab_audit_1 = audit.CollaborativeAnalysisWorkspaceAccessAudit( + queryset=[workspace_1] + ) + collab_audit_1.run_audit() + self.assertEqual(len(collab_audit_1.verified), 0) + self.assertEqual(len(collab_audit_1.needs_action), 1) + self.assertEqual(len(collab_audit_1.errors), 0) + record = collab_audit_1.needs_action[0] + self.assertIsInstance(record, audit.GrantAccess) + self.assertEqual(record.collaborative_analysis_workspace, workspace_1) + self.assertEqual(record.member, analyst_1) + self.assertEqual(record.note, collab_audit_1.IN_SOURCE_AUTH_DOMAINS) + collab_audit_2 = audit.CollaborativeAnalysisWorkspaceAccessAudit( + queryset=[workspace_2] + ) + collab_audit_2.run_audit() + self.assertEqual(len(collab_audit_2.verified), 1) + self.assertEqual(len(collab_audit_2.needs_action), 0) + self.assertEqual(len(collab_audit_2.errors), 0) + record = collab_audit_2.verified[0] + self.assertIsInstance(record, audit.VerifiedAccess) + self.assertEqual(record.collaborative_analysis_workspace, workspace_2) + self.assertEqual(record.member, analyst_2) + self.assertEqual(record.note, collab_audit_2.IN_SOURCE_AUTH_DOMAINS) From 27c7d16488d9aff3923030866cfd01f2c667e26b Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Thu, 14 Dec 2023 16:02:34 -0800 Subject: [PATCH 22/37] Script to add test data for collab analysis workspaces --- add_collaborative_analysis_example_data.py | 117 +++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 add_collaborative_analysis_example_data.py diff --git a/add_collaborative_analysis_example_data.py b/add_collaborative_analysis_example_data.py new file mode 100644 index 00000000..550e9317 --- /dev/null +++ b/add_collaborative_analysis_example_data.py @@ -0,0 +1,117 @@ +"""Temporary script to create some example data for the collaborative_analysis app. + +Run with: python manage.py shell < add_collaborative_analysis_example_data.py +""" + +from anvil_consortium_manager.tests.factories import ( + AccountFactory, + BillingProjectFactory, + GroupAccountMembershipFactory, + GroupGroupMembershipFactory, + ManagedGroupFactory, +) + +from primed.cdsa.tests.factories import CDSAWorkspaceFactory +from primed.collaborative_analysis.tests.factories import ( + CollaborativeAnalysisWorkspaceFactory, +) +from primed.dbgap.tests.factories import dbGaPWorkspaceFactory +from primed.miscellaneous_workspaces.tests.factories import OpenAccessWorkspaceFactory + +billing_project = BillingProjectFactory.create(name="test_collab") + +# Create some collaborative analysis workspace. +# Workspace 1 +collaborative_analysis_workspace_1 = CollaborativeAnalysisWorkspaceFactory.create( + workspace__name="collab_1_open_access", workspace__billing_project=billing_project +) +source_workspace_1_1 = OpenAccessWorkspaceFactory.create( + workspace__name="source_1_open_access", workspace__billing_project=billing_project +) +collaborative_analysis_workspace_1.source_workspaces.add(source_workspace_1_1.workspace) + +# Workspace 2 +collaborative_analysis_workspace_2 = CollaborativeAnalysisWorkspaceFactory.create( + workspace__name="collab_2_dbgap_and_cdsa", + workspace__billing_project=billing_project, +) +source_workspace_2_1 = dbGaPWorkspaceFactory.create( + workspace__name="source_2_dbgap", workspace__billing_project=billing_project +) +collaborative_analysis_workspace_2.source_workspaces.add(source_workspace_2_1.workspace) +source_workspace_2_2 = CDSAWorkspaceFactory.create( + workspace__name="source_2_cdsa", workspace__billing_project=billing_project +) +collaborative_analysis_workspace_2.source_workspaces.add(source_workspace_2_2.workspace) + +# Workspace 3 - with an error +collaborative_analysis_workspace_3 = CollaborativeAnalysisWorkspaceFactory.create( + workspace__name="collab_3_with_error", workspace__billing_project=billing_project +) +source_workspace_3_1 = OpenAccessWorkspaceFactory.create( + workspace__name="source_3_open_access", workspace__billing_project=billing_project +) +collaborative_analysis_workspace_3.source_workspaces.add(source_workspace_3_1.workspace) + + +# Add accounts to the auth domains. +account_1 = AccountFactory.create( + user__name="Adrienne", verified=True, email="adrienne@example.com" +) +account_2 = AccountFactory.create( + user__name="Ben", verified=True, email="ben@example.com" +) +account_3 = AccountFactory.create( + user__name="Matt", verified=True, email="matt@example.com" +) +account_4 = AccountFactory.create( + user__name="Stephanie", verified=True, email="stephanie@example.com" +) + +# Set up collab analysis workspace one +# analyst group +GroupAccountMembershipFactory.create( + account=account_1, group=collaborative_analysis_workspace_1.analyst_group +) +GroupAccountMembershipFactory.create( + account=account_2, group=collaborative_analysis_workspace_1.analyst_group +) +# auth domains +GroupAccountMembershipFactory.create( + account=account_1, + group=collaborative_analysis_workspace_1.workspace.authorization_domains.first(), +) + +# Set up collab analysis workspace two +# analyst group +GroupAccountMembershipFactory.create( + account=account_3, group=collaborative_analysis_workspace_2.analyst_group +) +GroupAccountMembershipFactory.create( + account=account_4, group=collaborative_analysis_workspace_2.analyst_group +) +# auth domains +GroupAccountMembershipFactory.create( + account=account_3, + group=collaborative_analysis_workspace_2.workspace.authorization_domains.first(), +) +GroupAccountMembershipFactory.create( + account=account_3, + group=source_workspace_2_1.workspace.authorization_domains.first(), +) +GroupAccountMembershipFactory.create( + account=account_3, + group=source_workspace_2_2.workspace.authorization_domains.first(), +) +GroupAccountMembershipFactory.create( + account=account_4, + group=source_workspace_2_1.workspace.authorization_domains.first(), +) + +# Set up collab analysis workspace three +# Managed group to show an error +managed_group = ManagedGroupFactory.create(name="test-error") +GroupGroupMembershipFactory.create( + parent_group=collaborative_analysis_workspace_3.workspace.authorization_domains.first(), + child_group=managed_group, +) From 139f61d69148bbacc02a0be72302771ad3eab709 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Tue, 23 Jan 2024 16:01:33 -0800 Subject: [PATCH 23/37] Create related objects with similar names in workspace factories When using a factory to create various types of workspaces (e.g., dbGaPWorkspaceFactory, CDSAWorkspaceFactory, ...), set the name of any related object using the name of the workspace. Examples: the auth domain for that workspace, an associated writers group, etc. This mimics how we create them for real. --- primed/cdsa/tests/factories.py | 4 +++- primed/collaborative_analysis/tests/factories.py | 13 ++++++++++--- primed/dbgap/tests/factories.py | 4 +++- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/primed/cdsa/tests/factories.py b/primed/cdsa/tests/factories.py index e71482ef..95077a3d 100644 --- a/primed/cdsa/tests/factories.py +++ b/primed/cdsa/tests/factories.py @@ -121,7 +121,9 @@ def authorization_domains(self, create, extracted, **kwargs): return # Create an authorization domain. - auth_domain = ManagedGroupFactory.create() + auth_domain = ManagedGroupFactory.create( + name="auth_{}".format(self.workspace.name) + ) self.workspace.authorization_domains.add(auth_domain) class Meta: diff --git a/primed/collaborative_analysis/tests/factories.py b/primed/collaborative_analysis/tests/factories.py index 72904693..30175a73 100644 --- a/primed/collaborative_analysis/tests/factories.py +++ b/primed/collaborative_analysis/tests/factories.py @@ -2,7 +2,7 @@ ManagedGroupFactory, WorkspaceFactory, ) -from factory import SubFactory, post_generation +from factory import LazyAttribute, SubFactory, post_generation from factory.django import DjangoModelFactory from primed.users.tests.factories import UserFactory @@ -17,7 +17,12 @@ class Meta: workspace = SubFactory(WorkspaceFactory, workspace_type="collab_analysis") custodian = SubFactory(UserFactory) - analyst_group = SubFactory(ManagedGroupFactory) + analyst_group = SubFactory( + ManagedGroupFactory, + name=LazyAttribute( + lambda o: "analysts_{}".format(o.factory_parent.workspace.name) + ), + ) @post_generation def authorization_domains(self, create, extracted, **kwargs): @@ -27,7 +32,9 @@ def authorization_domains(self, create, extracted, **kwargs): return # Create an authorization domain. - auth_domain = ManagedGroupFactory.create() + auth_domain = ManagedGroupFactory.create( + name="auth_{}".format(self.workspace.name) + ) print(auth_domain) self.workspace.authorization_domains.add(auth_domain) diff --git a/primed/dbgap/tests/factories.py b/primed/dbgap/tests/factories.py index 02f9bbd8..80cf97f9 100644 --- a/primed/dbgap/tests/factories.py +++ b/primed/dbgap/tests/factories.py @@ -89,7 +89,9 @@ def authorization_domains(self, create, extracted, **kwargs): return # Create an authorization domain. - auth_domain = ManagedGroupFactory.create() + auth_domain = ManagedGroupFactory.create( + name="auth_{}".format(self.workspace.name) + ) self.workspace.authorization_domains.add(auth_domain) From d44a29f587e36a313901543e21ba512755060b66 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Wed, 24 Jan 2024 13:15:44 -0800 Subject: [PATCH 24/37] Add an audit view for collaborative analysis workspaces This view shows the results of an access audit for collaborative analysis workspaces. Add classes to the audit source file that enable showing this website (e.g., tables, etc.). Add a url and a template. Note that we haven't figured out how to handle the DCC writers group yet, so there is a test that fails with a note about that. --- config/urls.py | 6 + primed/collaborative_analysis/audit.py | 50 +++ .../tests/test_audit.py | 89 +++++ .../tests/test_views.py | 357 +++++++++++++++++- primed/collaborative_analysis/urls.py | 21 ++ primed/collaborative_analysis/views.py | 47 ++- .../collaborativeanalysisworkspace_audit.html | 53 +++ 7 files changed, 620 insertions(+), 3 deletions(-) create mode 100644 primed/collaborative_analysis/urls.py create mode 100644 primed/templates/collaborative_analysis/collaborativeanalysisworkspace_audit.html diff --git a/config/urls.py b/config/urls.py index 83ada9ff..139e04ec 100644 --- a/config/urls.py +++ b/config/urls.py @@ -26,6 +26,12 @@ path("dbgap/", include("primed.dbgap.urls", namespace="dbgap")), path("duo/", include("primed.duo.urls", namespace="duo")), path("cdsa/", include("primed.cdsa.urls", namespace="cdsa")), + path( + "collaborative_analysis/", + include( + "primed.collaborative_analysis.urls", namespace="collaborative_analysis" + ), + ), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/primed/collaborative_analysis/audit.py b/primed/collaborative_analysis/audit.py index 21fb3e1c..2aa44496 100644 --- a/primed/collaborative_analysis/audit.py +++ b/primed/collaborative_analysis/audit.py @@ -1,6 +1,7 @@ from dataclasses import dataclass from typing import Union +import django_tables2 as tables from anvil_consortium_manager.models import ( Account, GroupAccountMembership, @@ -8,6 +9,7 @@ ManagedGroup, ) from django.urls import reverse +from django.utils.safestring import mark_safe from . import models @@ -28,6 +30,17 @@ def get_action(self): """An indicator of what action needs to be taken.""" return None + def get_table_dictionary(self): + """Return a dictionary that can be used to populate an instance of `SignedAgreementAccessAuditTable`.""" + row = { + "workspace": self.collaborative_analysis_workspace, + "member": self.member, + "note": self.note, + "action": self.get_action(), + "action_url": self.get_action_url(), + } + return row + @dataclass class VerifiedAccess(AccessAuditResult): @@ -91,6 +104,25 @@ def get_action_url(self): ) +class AccessAuditResultsTable(tables.Table): + """A table to show results from a CollaborativeAnalysisWorkspaceAccessAudit instance.""" + + workspace = tables.Column(linkify=True) + member = tables.Column(linkify=True) + note = tables.Column() + action = tables.Column() + + class Meta: + attrs = {"class": "table align-middle"} + + def render_action(self, record, value): + return mark_safe( + """{}""".format( + record["action_url"], value + ) + ) + + class CollaborativeAnalysisWorkspaceAccessAudit: """Class to audit access to a CollaborativeAnalysisWorkspace.""" @@ -106,6 +138,8 @@ class CollaborativeAnalysisWorkspaceAccessAudit: # Errors. UNEXPECTED_GROUP_ACCESS = "Unexpected group added to the auth domain." + results_table_class = AccessAuditResultsTable + def __init__(self, queryset=None): """Initialize the audit. @@ -240,3 +274,19 @@ def run_audit(self): for workspace in self.queryset: self._audit_workspace(workspace) self.completed = True + + def get_verified_table(self): + """Return a table of verified results.""" + return self.results_table_class( + [x.get_table_dictionary() for x in self.verified] + ) + + def get_needs_action_table(self): + """Return a table of results where action is needed.""" + return self.results_table_class( + [x.get_table_dictionary() for x in self.needs_action] + ) + + def get_errors_table(self): + """Return a table of audit errors.""" + return self.results_table_class([x.get_table_dictionary() for x in self.errors]) diff --git a/primed/collaborative_analysis/tests/test_audit.py b/primed/collaborative_analysis/tests/test_audit.py index 72cab949..1a1383c1 100644 --- a/primed/collaborative_analysis/tests/test_audit.py +++ b/primed/collaborative_analysis/tests/test_audit.py @@ -968,3 +968,92 @@ def test_queryset(self): self.assertEqual(record.collaborative_analysis_workspace, workspace_2) self.assertEqual(record.member, analyst_2) self.assertEqual(record.note, collab_audit_2.IN_SOURCE_AUTH_DOMAINS) + + +class AccessAuditResultsTableTest(TestCase): + """Tests for the `AccessAuditResultsTable` table.""" + + def test_no_rows(self): + """Table works with no rows.""" + table = audit.AccessAuditResultsTable([]) + self.assertIsInstance(table, audit.AccessAuditResultsTable) + self.assertEqual(len(table.rows), 0) + + def test_one_row_account(self): + """Table works with one row with an account member.""" + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + member_account = AccountFactory.create() + data = [ + { + "workspace": workspace, + "member": member_account, + "note": "a note", + "action": "", + "action_url": "", + } + ] + table = audit.AccessAuditResultsTable(data) + self.assertIsInstance(table, audit.AccessAuditResultsTable) + self.assertEqual(len(table.rows), 1) + + def test_one_row_group(self): + """Table works with one row with a group member.""" + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + member_group = ManagedGroupFactory.create() + data = [ + { + "workspace": workspace, + "member": member_group, + "note": "a note", + "action": "", + "action_url": "", + } + ] + table = audit.AccessAuditResultsTable(data) + self.assertIsInstance(table, audit.AccessAuditResultsTable) + self.assertEqual(len(table.rows), 1) + + def test_two_rows(self): + """Table works with two rows.""" + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + member_account = AccountFactory.create() + member_group = ManagedGroupFactory.create() + data = [ + { + "workspace": workspace, + "member": member_account, + "note": "a note", + "action": "", + "action_url": "", + }, + { + "workspace": workspace, + "member": member_group, + "note": "a note", + "action": "", + "action_url": "", + }, + ] + table = audit.AccessAuditResultsTable(data) + self.assertIsInstance(table, audit.AccessAuditResultsTable) + self.assertEqual(len(table.rows), 2) + + def test_render_action(self): + """Render action works as expected for grant access types.""" + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + member_group = ManagedGroupFactory.create() + data = [ + { + "workspace": workspace, + "member": member_group, + "note": "a note", + "action": "Grant", + "action_url": "foo", + } + ] + + table = audit.AccessAuditResultsTable(data) + self.assertIsInstance(table, audit.AccessAuditResultsTable) + self.assertEqual(len(table.rows), 1) + self.assertIn("foo", table.rows[0].get_cell("action")) + self.assertIn("Grant", table.rows[0].get_cell("action")) diff --git a/primed/collaborative_analysis/tests/test_views.py b/primed/collaborative_analysis/tests/test_views.py index 40f56b79..6af7e7b9 100644 --- a/primed/collaborative_analysis/tests/test_views.py +++ b/primed/collaborative_analysis/tests/test_views.py @@ -3,13 +3,20 @@ import responses from anvil_consortium_manager.models import AnVILProjectManagerAccess, Workspace from anvil_consortium_manager.tests.factories import ( + AccountFactory, BillingProjectFactory, + GroupAccountMembershipFactory, + GroupGroupMembershipFactory, ManagedGroupFactory, ) from anvil_consortium_manager.tests.utils import AnVILAPIMockTestMixin +from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission -from django.test import TestCase +from django.core.exceptions import PermissionDenied +from django.http import Http404 +from django.shortcuts import resolve_url +from django.test import RequestFactory, TestCase from django.urls import reverse from primed.cdsa.tests.factories import CDSAWorkspaceFactory @@ -17,7 +24,7 @@ from primed.miscellaneous_workspaces.tests.factories import OpenAccessWorkspaceFactory from primed.users.tests.factories import UserFactory -from .. import models +from .. import audit, models, views from . import factories User = get_user_model() @@ -299,3 +306,349 @@ def test_creates_workspace(self): self.assertEqual(models.CollaborativeAnalysisWorkspace.objects.count(), 1) new_workspace_data = models.CollaborativeAnalysisWorkspace.objects.latest("pk") self.assertEqual(new_workspace_data.workspace, new_workspace) + + +class CollaborativeAnalysisWorkspaceAuditTest(TestCase): + """Tests for the CollaborativeAnalysisWorkspaceAuditTest view.""" + + 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=AnVILProjectManagerAccess.STAFF_VIEW_PERMISSION_CODENAME + ) + ) + self.collaborative_analysis_workspace = ( + factories.CollaborativeAnalysisWorkspaceFactory.create() + ) + + def get_url(self, *args): + """Get the url for the view being tested.""" + return reverse( + "collaborative_analysis:workspaces:audit", + args=args, + ) + + def get_view(self): + """Return the view being tested.""" + return views.WorkspaceAudit.as_view() + + def test_view_redirect_not_logged_in(self): + "View redirects to login view when user is not logged in." + # Need a client for redirects. + response = self.client.get(self.get_url("foo", "bar")) + self.assertRedirects( + response, + resolve_url(settings.LOGIN_URL) + "?next=" + self.get_url("foo", "bar"), + ) + + def test_status_code_with_user_permission_view(self): + """Returns successful response code if the user has view permission.""" + request = self.factory.get( + self.get_url( + self.collaborative_analysis_workspace.workspace.billing_project.name, + self.collaborative_analysis_workspace.workspace.name, + ) + ) + request.user = self.user + response = self.get_view()( + request, + billing_project_slug=self.collaborative_analysis_workspace.workspace.billing_project.name, + workspace_slug=self.collaborative_analysis_workspace.workspace.name, + ) + self.assertEqual(response.status_code, 200) + + 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.collaborative_analysis_workspace.workspace.billing_project.name, + self.collaborative_analysis_workspace.workspace.name, + ) + ) + request.user = user_no_perms + with self.assertRaises(PermissionDenied): + self.get_view()( + request, + billing_project_slug=self.collaborative_analysis_workspace.workspace.billing_project.name, + workspace_slug=self.collaborative_analysis_workspace.workspace.name, + ) + + def test_invalid_billing_project_name(self): + """Raises a 404 error with an invalid object dbgap_application_pk.""" + request = self.factory.get( + self.get_url("foo", self.collaborative_analysis_workspace.workspace.name) + ) + request.user = self.user + with self.assertRaises(Http404): + self.get_view()( + request, + billing_project_slug="foo", + workspace_slug=self.collaborative_analysis_workspace.workspace.name, + ) + + def test_invalid_workspace_name(self): + """Raises a 404 error with an invalid object dbgap_application_pk.""" + request = self.factory.get( + self.get_url(self.collaborative_analysis_workspace.workspace.name, "foo") + ) + request.user = self.user + with self.assertRaises(Http404): + self.get_view()( + request, + billing_project_slug=self.collaborative_analysis_workspace.workspace.billing_project.name, + workspace_slug="foo", + ) + + def test_context_data_access_audit(self): + """The data_access_audit exists in the context.""" + self.client.force_login(self.user) + response = self.client.get( + self.get_url( + self.collaborative_analysis_workspace.workspace.billing_project.name, + self.collaborative_analysis_workspace.workspace.name, + ) + ) + self.assertIn("data_access_audit", response.context_data) + self.assertIsInstance( + response.context_data["data_access_audit"], + audit.CollaborativeAnalysisWorkspaceAccessAudit, + ) + self.assertTrue(response.context_data["data_access_audit"].completed) + qs = response.context_data["data_access_audit"].queryset + self.assertEqual(len(qs), 1) + self.assertIn(self.collaborative_analysis_workspace, qs) + + def test_context_verified_table_access(self): + """verified_table shows a record when audit has one account with verified access.""" + # Create accounts. + account = AccountFactory.create() + # Set up source workspaces. + source_workspace = dbGaPWorkspaceFactory.create() + self.collaborative_analysis_workspace.source_workspaces.add( + source_workspace.workspace + ) + # Analyst group membership. + GroupAccountMembershipFactory.create( + group=self.collaborative_analysis_workspace.analyst_group, account=account + ) + # Source workspace auth domains membership. + GroupAccountMembershipFactory.create( + group=source_workspace.workspace.authorization_domains.first(), + account=account, + ) + # CollaborativeAnalysisWorkspace auth domain membership. + GroupAccountMembershipFactory.create( + group=self.collaborative_analysis_workspace.workspace.authorization_domains.first(), + account=account, + ) + # Check the table in the context. + self.client.force_login(self.user) + response = self.client.get( + self.get_url( + self.collaborative_analysis_workspace.workspace.billing_project.name, + self.collaborative_analysis_workspace.workspace.name, + ) + ) + self.assertIn("verified_table", response.context_data) + table = response.context_data["verified_table"] + self.assertIsInstance( + table, + audit.AccessAuditResultsTable, + ) + self.assertEqual(len(table.rows), 1) + self.assertEqual( + table.rows[0].get_cell_value("workspace"), + self.collaborative_analysis_workspace, + ) + self.assertEqual(table.rows[0].get_cell_value("member"), account) + self.assertEqual( + table.rows[0].get_cell_value("note"), + audit.CollaborativeAnalysisWorkspaceAccessAudit.IN_SOURCE_AUTH_DOMAINS, + ) + self.assertIsNone(table.rows[0].get_cell_value("action")) + + def test_context_verified_table_no_access(self): + """verified_table shows a record when audit has one account with verified no access.""" + # Create accounts. + account = AccountFactory.create() + # Set up source workspaces. + source_workspace = dbGaPWorkspaceFactory.create() + self.collaborative_analysis_workspace.source_workspaces.add( + source_workspace.workspace + ) + # Analyst group membership. + GroupAccountMembershipFactory.create( + group=self.collaborative_analysis_workspace.analyst_group, account=account + ) + # Source workspace auth domains membership. + # GroupAccountMembershipFactory.create( + # group=source_workspace.workspace.authorization_domains.first(), + # account=account, + # ) + # CollaborativeAnalysisWorkspace auth domain membership. + # GroupAccountMembershipFactory.create( + # group=self.collaborative_analysis_workspace.workspace.authorization_domains.first(), account=account + # ) + # Check the table in the context. + self.client.force_login(self.user) + response = self.client.get( + self.get_url( + self.collaborative_analysis_workspace.workspace.billing_project.name, + self.collaborative_analysis_workspace.workspace.name, + ) + ) + self.assertIn("verified_table", response.context_data) + table = response.context_data["verified_table"] + self.assertIsInstance( + table, + audit.AccessAuditResultsTable, + ) + self.assertEqual(len(table.rows), 1) + self.assertEqual( + table.rows[0].get_cell_value("workspace"), + self.collaborative_analysis_workspace, + ) + self.assertEqual(table.rows[0].get_cell_value("member"), account) + self.assertEqual( + table.rows[0].get_cell_value("note"), + audit.CollaborativeAnalysisWorkspaceAccessAudit.NOT_IN_SOURCE_AUTH_DOMAINS, + ) + self.assertIsNone(table.rows[0].get_cell_value("action")) + + def test_context_needs_action_table_grant(self): + """needs_action_table shows a record when audit finds that access needs to be granted.""" + # Create accounts. + account = AccountFactory.create() + # Set up source workspaces. + source_workspace = dbGaPWorkspaceFactory.create() + self.collaborative_analysis_workspace.source_workspaces.add( + source_workspace.workspace + ) + # Analyst group membership. + GroupAccountMembershipFactory.create( + group=self.collaborative_analysis_workspace.analyst_group, account=account + ) + # Source workspace auth domains membership. + GroupAccountMembershipFactory.create( + group=source_workspace.workspace.authorization_domains.first(), + account=account, + ) + # CollaborativeAnalysisWorkspace auth domain membership. + # GroupAccountMembershipFactory.create( + # group=self.collaborative_analysis_workspace.workspace.authorization_domains.first(), account=account + # ) + # Check the table in the context. + self.client.force_login(self.user) + response = self.client.get( + self.get_url( + self.collaborative_analysis_workspace.workspace.billing_project.name, + self.collaborative_analysis_workspace.workspace.name, + ) + ) + self.assertIn("needs_action_table", response.context_data) + table = response.context_data["needs_action_table"] + self.assertIsInstance( + table, + audit.AccessAuditResultsTable, + ) + self.assertEqual(len(table.rows), 1) + self.assertEqual( + table.rows[0].get_cell_value("workspace"), + self.collaborative_analysis_workspace, + ) + self.assertEqual(table.rows[0].get_cell_value("member"), account) + self.assertEqual( + table.rows[0].get_cell_value("note"), + audit.CollaborativeAnalysisWorkspaceAccessAudit.IN_SOURCE_AUTH_DOMAINS, + ) + self.assertIsNotNone(table.rows[0].get_cell_value("action")) + + def test_context_needs_action_table_remoe(self): + """needs_action_table shows a record when audit finds that access needs to be removed.""" + # Create accounts. + account = AccountFactory.create() + # Set up source workspaces. + source_workspace = dbGaPWorkspaceFactory.create() + self.collaborative_analysis_workspace.source_workspaces.add( + source_workspace.workspace + ) + # Analyst group membership. + GroupAccountMembershipFactory.create( + group=self.collaborative_analysis_workspace.analyst_group, account=account + ) + # Source workspace auth domains membership. + # GroupAccountMembershipFactory.create( + # group=source_workspace.workspace.authorization_domains.first(), + # account=account, + # ) + # CollaborativeAnalysisWorkspace auth domain membership. + GroupAccountMembershipFactory.create( + group=self.collaborative_analysis_workspace.workspace.authorization_domains.first(), + account=account, + ) + # Check the table in the context. + self.client.force_login(self.user) + response = self.client.get( + self.get_url( + self.collaborative_analysis_workspace.workspace.billing_project.name, + self.collaborative_analysis_workspace.workspace.name, + ) + ) + self.assertIn("needs_action_table", response.context_data) + table = response.context_data["needs_action_table"] + self.assertIsInstance( + table, + audit.AccessAuditResultsTable, + ) + self.assertEqual(len(table.rows), 1) + self.assertEqual( + table.rows[0].get_cell_value("workspace"), + self.collaborative_analysis_workspace, + ) + self.assertEqual(table.rows[0].get_cell_value("member"), account) + self.assertEqual( + table.rows[0].get_cell_value("note"), + audit.CollaborativeAnalysisWorkspaceAccessAudit.NOT_IN_SOURCE_AUTH_DOMAINS, + ) + self.assertIsNotNone(table.rows[0].get_cell_value("action")) + + def test_context_error_table_group_in_auth_domain(self): + """error shows a record when audit finds a group in the auth domain.""" + # Create accounts. + group = ManagedGroupFactory.create() + GroupGroupMembershipFactory.create( + parent_group=self.collaborative_analysis_workspace.workspace.authorization_domains.first(), + child_group=group, + ) + # Check the table in the context. + self.client.force_login(self.user) + response = self.client.get( + self.get_url( + self.collaborative_analysis_workspace.workspace.billing_project.name, + self.collaborative_analysis_workspace.workspace.name, + ) + ) + self.assertIn("errors_table", response.context_data) + table = response.context_data["errors_table"] + self.assertIsInstance( + table, + audit.AccessAuditResultsTable, + ) + self.assertEqual(len(table.rows), 1) + self.assertEqual( + table.rows[0].get_cell_value("workspace"), + self.collaborative_analysis_workspace, + ) + self.assertEqual(table.rows[0].get_cell_value("member"), group) + self.assertEqual( + table.rows[0].get_cell_value("note"), + audit.CollaborativeAnalysisWorkspaceAccessAudit.UNEXPECTED_GROUP_ACCESS, + ) + self.assertIsNotNone(table.rows[0].get_cell_value("action")) diff --git a/primed/collaborative_analysis/urls.py b/primed/collaborative_analysis/urls.py new file mode 100644 index 00000000..31735ff0 --- /dev/null +++ b/primed/collaborative_analysis/urls.py @@ -0,0 +1,21 @@ +from django.urls import include, path + +from . import views + +app_name = "collaborative_analysis" + + +collaborative_analysis_workspace_patterns = ( + [ + path( + "//audit/", + views.WorkspaceAudit.as_view(), + name="audit", + ), + ], + "workspaces", +) + +urlpatterns = [ + path("workspaces/", include(collaborative_analysis_workspace_patterns)), +] diff --git a/primed/collaborative_analysis/views.py b/primed/collaborative_analysis/views.py index 91ea44a2..abc1124e 100644 --- a/primed/collaborative_analysis/views.py +++ b/primed/collaborative_analysis/views.py @@ -1,3 +1,48 @@ -from django.shortcuts import render +from anvil_consortium_manager.auth import AnVILConsortiumManagerStaffViewRequired +from django.http import Http404 +from django.utils.translation import gettext_lazy as _ +from django.views.generic import DetailView + +from . import audit, models + # Create your views here. +class WorkspaceAudit(AnVILConsortiumManagerStaffViewRequired, DetailView): + """View to show audit results for a `CollaborativeAnalysisWorkspace`.""" + + model = models.CollaborativeAnalysisWorkspace + template_name = "collaborative_analysis/collaborativeanalysisworkspace_audit.html" + + def get_object(self, queryset=None): + """Return the object the view is displaying.""" + if queryset is None: + queryset = self.get_queryset() + # Filter the queryset based on kwargs. + billing_project_slug = self.kwargs.get("billing_project_slug", None) + workspace_slug = self.kwargs.get("workspace_slug", None) + queryset = queryset.filter( + workspace__billing_project__name=billing_project_slug, + workspace__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_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # Run the audit + data_access_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit( + queryset=[self.object] + ) + data_access_audit.run_audit() + context["verified_table"] = data_access_audit.get_verified_table() + context["errors_table"] = data_access_audit.get_errors_table() + context["needs_action_table"] = data_access_audit.get_needs_action_table() + context["data_access_audit"] = data_access_audit + return context diff --git a/primed/templates/collaborative_analysis/collaborativeanalysisworkspace_audit.html b/primed/templates/collaborative_analysis/collaborativeanalysisworkspace_audit.html new file mode 100644 index 00000000..394609db --- /dev/null +++ b/primed/templates/collaborative_analysis/collaborativeanalysisworkspace_audit.html @@ -0,0 +1,53 @@ +{% extends "anvil_consortium_manager/base.html" %} + +{% block title %}dbGaP workspace audit{% endblock %} + + +{% block content %} + +

Collaborative analysis workspace audit

+ +
+ +
+ +

Audit results

+ +
+

+ To have access to a Collaborative Analysis Workspace, an account must meet both of the following criteria: + +

    +
  • Be in the analyst group associated with the workspace
  • +
  • Be in the auth domain for all source workspaces
  • +
+

+

The audit result categories are explained below. +

    + +
  • Verified includes the following:
  • +
      +
    • An account in the analyst group is in the auth domain for this workspace and is in all auth domains for all source workspaces.
    • +
    • An account in the analyst group is not in the auth domain for this workspace and is not in all auth domains for all source workspaces.
    • +
    + +
  • Needs action includes the following:
  • +
      +
    • An account in the analyst group is not in the auth domain for this workspace and is in all auth domains for all source workspaces.
    • +
    • An account in the analyst group is in the auth domain for this workspace and is not in all auth domains for all source workspaces.
    • +
    + +
  • Errors
  • +
      +
    • A group is unexpectedly in the auth domain.
    • +
    +
+

+

Any errors should be reported!

+
+ +{% include "__audit_tables.html" with verified_table=verified_table needs_action_table=needs_action_table errors_table=errors_table %} + +{% endblock content %} From ef12d321ba6679e695ac2b9ba9a91f085ef45f6c Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Wed, 24 Jan 2024 14:22:23 -0800 Subject: [PATCH 25/37] Add link to audit on detail page for staff For staff view users, show a link to the audit page for a collaborative analysis workspace on its detail page. Add tests to check that only staff viewers see it. --- .../tests/test_views.py | 32 +++++++++++++++++++ ...collaborativeanalysisworkspace_detail.html | 10 ++++++ 2 files changed, 42 insertions(+) diff --git a/primed/collaborative_analysis/tests/test_views.py b/primed/collaborative_analysis/tests/test_views.py index 6af7e7b9..af069d46 100644 --- a/primed/collaborative_analysis/tests/test_views.py +++ b/primed/collaborative_analysis/tests/test_views.py @@ -105,6 +105,38 @@ def test_link_to_analyst_group_view(self): ) self.assertNotIn(obj.analyst_group.name, response.content.decode()) + def test_link_to_audit_staff_view(self): + """Links to the audit view page do appear on the detail page for staff viewers.""" + user = User.objects.create_user( + username="test-staff-view", password="test-staff-view" + ) + user.user_permissions.add( + Permission.objects.get( + codename=AnVILProjectManagerAccess.STAFF_VIEW_PERMISSION_CODENAME + ) + ) + obj = factories.CollaborativeAnalysisWorkspaceFactory.create() + self.client.force_login(user) + response = self.client.get(obj.workspace.get_absolute_url()) + url = reverse( + "collaborative_analysis:workspaces:audit", + args=[obj.workspace.billing_project.name, obj.workspace.name], + ) + self.assertIn(url, response.content.decode()) + + def test_link_to_audit_view(self): + """Links to the audit view page do not appear on the detail page for viewers.""" + obj = factories.CollaborativeAnalysisWorkspaceFactory.create() + self.client.force_login(self.user) + response = self.client.get(obj.workspace.get_absolute_url()) + self.assertNotIn( + reverse( + "collaborative_analysis:workspaces:audit", + args=[obj.workspace.billing_project.name, obj.workspace.name], + ), + response.content.decode(), + ) + class CollaborativeAnalysisWorkspaceCreateTest(AnVILAPIMockTestMixin, TestCase): """Tests of the WorkspaceCreate view from ACM with this app's CollaborativeAnalysisWorkspace model.""" diff --git a/primed/templates/collaborative_analysis/collaborativeanalysisworkspace_detail.html b/primed/templates/collaborative_analysis/collaborativeanalysisworkspace_detail.html index adee8f10..e8d77c71 100644 --- a/primed/templates/collaborative_analysis/collaborativeanalysisworkspace_detail.html +++ b/primed/templates/collaborative_analysis/collaborativeanalysisworkspace_detail.html @@ -23,3 +23,13 @@ {% endif %} {% endblock workspace_data %} + + +{% block action_buttons %} +{% if perms.anvil_consortium_manager.anvil_consortium_manager_staff_view %} +

+ Audit workspace access +

+{% endif %} +{{block.super}} +{% endblock action_buttons %} From 72eb2e881e9e99b1159f9679fb478c48b61f4a78 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Wed, 24 Jan 2024 15:57:54 -0800 Subject: [PATCH 26/37] Ignore PRIMED CC groups in the audit Ignore PRIMED CC group sin the auth domain for a collaborative workspace audit. These groups will be expected to potentially be there and it is not an error. --- primed/collaborative_analysis/audit.py | 16 ++++++- .../tests/test_audit.py | 46 +++++++++++++++++-- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/primed/collaborative_analysis/audit.py b/primed/collaborative_analysis/audit.py index 2aa44496..3043e0e7 100644 --- a/primed/collaborative_analysis/audit.py +++ b/primed/collaborative_analysis/audit.py @@ -128,6 +128,7 @@ class CollaborativeAnalysisWorkspaceAccessAudit: # Allowed reasons for access. IN_SOURCE_AUTH_DOMAINS = "Account is in all source auth domains for this workspace." + DCC_ACCESS = "DCC groups are allowed access." # Allowed reasons for no access. NOT_IN_SOURCE_AUTH_DOMAINS = ( @@ -187,12 +188,17 @@ def _audit_workspace(self, workspace): ) # Check that no groups have access. + groups_to_ignore = [ + "PRIMED_CC_ADMINS", + "PRIMED_CC_WRITERS", + "PRIMED_CC_MEMBERS", + ] group_memberships = GroupGroupMembership.objects.filter( parent_group=workspace.workspace.authorization_domains.first() ) for membership in group_memberships: # Ignore PRIMED admins group. - if membership.child_group.name != "PRIMED_CC_ADMINS": + if membership.child_group.name not in groups_to_ignore: self.errors.append( RemoveAccess( collaborative_analysis_workspace=workspace, @@ -200,6 +206,14 @@ def _audit_workspace(self, workspace): note=self.UNEXPECTED_GROUP_ACCESS, ) ) + else: + self.verified.append( + VerifiedAccess( + collaborative_analysis_workspace=workspace, + member=membership.child_group, + note=self.DCC_ACCESS, + ) + ) def _audit_workspace_and_account(self, collaborative_analysis_workspace, account): """Audit access for a specific CollaborativeAnalysisWorkspace and account.""" diff --git a/primed/collaborative_analysis/tests/test_audit.py b/primed/collaborative_analysis/tests/test_audit.py index 1a1383c1..9188da57 100644 --- a/primed/collaborative_analysis/tests/test_audit.py +++ b/primed/collaborative_analysis/tests/test_audit.py @@ -888,12 +888,52 @@ def test_no_errors_for_primed_admins_group(self): ) collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() collab_audit._audit_workspace(workspace) - self.assertEqual(len(collab_audit.verified), 0) + self.assertEqual(len(collab_audit.verified), 1) self.assertEqual(len(collab_audit.needs_action), 0) self.assertEqual(len(collab_audit.errors), 0) + record = collab_audit.verified[0] + self.assertIsInstance(record, audit.VerifiedAccess) + self.assertEqual(record.collaborative_analysis_workspace, workspace) + self.assertEqual(record.member, group) + self.assertEqual(record.note, collab_audit.DCC_ACCESS) - def test_dcc_access(self): - self.fail("How do we handle DCC member access?") + def test_no_errors_for_primed_cc_members_group(self): + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + # Add a group to the auth domain. + group = ManagedGroupFactory.create(name="PRIMED_CC_MEMBERS") + GroupGroupMembershipFactory.create( + parent_group=workspace.workspace.authorization_domains.first(), + child_group=group, + ) + collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() + collab_audit._audit_workspace(workspace) + self.assertEqual(len(collab_audit.verified), 1) + self.assertEqual(len(collab_audit.needs_action), 0) + self.assertEqual(len(collab_audit.errors), 0) + record = collab_audit.verified[0] + self.assertIsInstance(record, audit.VerifiedAccess) + self.assertEqual(record.collaborative_analysis_workspace, workspace) + self.assertEqual(record.member, group) + self.assertEqual(record.note, collab_audit.DCC_ACCESS) + + def test_no_errors_for_primed_cc_writers_group(self): + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + # Add a group to the auth domain. + group = ManagedGroupFactory.create(name="PRIMED_CC_WRITERS") + GroupGroupMembershipFactory.create( + parent_group=workspace.workspace.authorization_domains.first(), + child_group=group, + ) + collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() + collab_audit._audit_workspace(workspace) + self.assertEqual(len(collab_audit.verified), 1) + self.assertEqual(len(collab_audit.needs_action), 0) + self.assertEqual(len(collab_audit.errors), 0) + record = collab_audit.verified[0] + self.assertIsInstance(record, audit.VerifiedAccess) + self.assertEqual(record.collaborative_analysis_workspace, workspace) + self.assertEqual(record.member, group) + self.assertEqual(record.note, collab_audit.DCC_ACCESS) def test_two_workspaces(self): # Create a workspace with an analyst that needs access. From 404c099ea5d1014d8662d99e089819d55f17276a Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Wed, 24 Jan 2024 16:31:53 -0800 Subject: [PATCH 27/37] Handle CC_MEMBERS and CC_WRITERS groups differently in audit Instead of ignoring PRIMED_CC_MEMBERS and PRIMED_CC_WRITERS in the collaborative analysis workspace audit, include them in the checks. These groups both should have access to the workspace, so either return a Verified or GrantAccess result. --- primed/collaborative_analysis/audit.py | 44 +++++++++++----- .../tests/test_audit.py | 51 +++++++++++++++---- 2 files changed, 72 insertions(+), 23 deletions(-) diff --git a/primed/collaborative_analysis/audit.py b/primed/collaborative_analysis/audit.py index 3043e0e7..02fe7e50 100644 --- a/primed/collaborative_analysis/audit.py +++ b/primed/collaborative_analysis/audit.py @@ -188,32 +188,48 @@ def _audit_workspace(self, workspace): ) # Check that no groups have access. - groups_to_ignore = [ - "PRIMED_CC_ADMINS", - "PRIMED_CC_WRITERS", - "PRIMED_CC_MEMBERS", - ] group_memberships = GroupGroupMembership.objects.filter( - parent_group=workspace.workspace.authorization_domains.first() + parent_group=workspace.workspace.authorization_domains.first(), + ).exclude( + # Ignore cc admins group - it is handled differently because it should have admin privileges. + child_group__name="PRIMED_CC_ADMINS", ) - for membership in group_memberships: - # Ignore PRIMED admins group. - if membership.child_group.name not in groups_to_ignore: - self.errors.append( - RemoveAccess( + # CC groups that should have access. + cc_groups = ManagedGroup.objects.filter( + name__in=[ + "PRIMED_CC_WRITERS", + "PRIMED_CC_MEMBERS", + ] + ) + for cc_group in cc_groups: + try: + group_memberships.get(child_group=cc_group) + except GroupGroupMembership.DoesNotExist: + self.needs_action.append( + GrantAccess( collaborative_analysis_workspace=workspace, - member=membership.child_group, - note=self.UNEXPECTED_GROUP_ACCESS, + member=cc_group, + note=self.DCC_ACCESS, ) ) else: + group_memberships = group_memberships.exclude(child_group=cc_group) self.verified.append( VerifiedAccess( collaborative_analysis_workspace=workspace, - member=membership.child_group, + member=cc_group, note=self.DCC_ACCESS, ) ) + # Any other groups are an error. + for membership in group_memberships: + self.errors.append( + RemoveAccess( + collaborative_analysis_workspace=workspace, + member=membership.child_group, + note=self.UNEXPECTED_GROUP_ACCESS, + ) + ) def _audit_workspace_and_account(self, collaborative_analysis_workspace, account): """Audit access for a specific CollaborativeAnalysisWorkspace and account.""" diff --git a/primed/collaborative_analysis/tests/test_audit.py b/primed/collaborative_analysis/tests/test_audit.py index 9188da57..d0967054 100644 --- a/primed/collaborative_analysis/tests/test_audit.py +++ b/primed/collaborative_analysis/tests/test_audit.py @@ -878,7 +878,7 @@ def test_unexpected_group_in_auth_domain(self): self.assertEqual(record.member, group) self.assertEqual(record.note, collab_audit.UNEXPECTED_GROUP_ACCESS) - def test_no_errors_for_primed_admins_group(self): + def test_ignores_primed_admins_group(self): workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() # Add a group to the auth domain. group = ManagedGroupFactory.create(name="PRIMED_CC_ADMINS") @@ -888,16 +888,11 @@ def test_no_errors_for_primed_admins_group(self): ) collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() collab_audit._audit_workspace(workspace) - self.assertEqual(len(collab_audit.verified), 1) + self.assertEqual(len(collab_audit.verified), 0) self.assertEqual(len(collab_audit.needs_action), 0) self.assertEqual(len(collab_audit.errors), 0) - record = collab_audit.verified[0] - self.assertIsInstance(record, audit.VerifiedAccess) - self.assertEqual(record.collaborative_analysis_workspace, workspace) - self.assertEqual(record.member, group) - self.assertEqual(record.note, collab_audit.DCC_ACCESS) - def test_no_errors_for_primed_cc_members_group(self): + def test_verified_access_for_primed_cc_members_group(self): workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() # Add a group to the auth domain. group = ManagedGroupFactory.create(name="PRIMED_CC_MEMBERS") @@ -916,7 +911,26 @@ def test_no_errors_for_primed_cc_members_group(self): self.assertEqual(record.member, group) self.assertEqual(record.note, collab_audit.DCC_ACCESS) - def test_no_errors_for_primed_cc_writers_group(self): + def test_grant_access_for_primed_cc_members_group(self): + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + # Add a group to the auth domain. + group = ManagedGroupFactory.create(name="PRIMED_CC_MEMBERS") + # GroupGroupMembershipFactory.create( + # parent_group=workspace.workspace.authorization_domains.first(), + # child_group=group, + # ) + collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() + collab_audit._audit_workspace(workspace) + self.assertEqual(len(collab_audit.verified), 0) + self.assertEqual(len(collab_audit.needs_action), 1) + self.assertEqual(len(collab_audit.errors), 0) + record = collab_audit.needs_action[0] + self.assertIsInstance(record, audit.GrantAccess) + self.assertEqual(record.collaborative_analysis_workspace, workspace) + self.assertEqual(record.member, group) + self.assertEqual(record.note, collab_audit.DCC_ACCESS) + + def test_verified_access_for_primed_cc_writers_group(self): workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() # Add a group to the auth domain. group = ManagedGroupFactory.create(name="PRIMED_CC_WRITERS") @@ -935,6 +949,25 @@ def test_no_errors_for_primed_cc_writers_group(self): self.assertEqual(record.member, group) self.assertEqual(record.note, collab_audit.DCC_ACCESS) + def test_grant_access_for_primed_cc_writers_group(self): + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + # Add a group to the auth domain. + group = ManagedGroupFactory.create(name="PRIMED_CC_WRITERS") + # GroupGroupMembershipFactory.create( + # parent_group=workspace.workspace.authorization_domains.first(), + # child_group=group, + # ) + collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() + collab_audit._audit_workspace(workspace) + self.assertEqual(len(collab_audit.verified), 0) + self.assertEqual(len(collab_audit.needs_action), 1) + self.assertEqual(len(collab_audit.errors), 0) + record = collab_audit.needs_action[0] + self.assertIsInstance(record, audit.GrantAccess) + self.assertEqual(record.collaborative_analysis_workspace, workspace) + self.assertEqual(record.member, group) + self.assertEqual(record.note, collab_audit.DCC_ACCESS) + def test_two_workspaces(self): # Create a workspace with an analyst that needs access. workspace_1 = factories.CollaborativeAnalysisWorkspaceFactory.create() From ecbfe2550b88246e27c4756314408d7ff4d32a3f Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Thu, 25 Jan 2024 08:54:33 -0800 Subject: [PATCH 28/37] Add a view that audits all collaborative analysis workspaces --- .../tests/test_views.py | 299 +++++++++++++++++- primed/collaborative_analysis/urls.py | 5 + primed/collaborative_analysis/views.py | 23 +- ...laborativeanalysisworkspace_audit_all.html | 49 +++ 4 files changed, 374 insertions(+), 2 deletions(-) create mode 100644 primed/templates/collaborative_analysis/collaborativeanalysisworkspace_audit_all.html diff --git a/primed/collaborative_analysis/tests/test_views.py b/primed/collaborative_analysis/tests/test_views.py index af069d46..0861b34b 100644 --- a/primed/collaborative_analysis/tests/test_views.py +++ b/primed/collaborative_analysis/tests/test_views.py @@ -340,7 +340,7 @@ def test_creates_workspace(self): self.assertEqual(new_workspace_data.workspace, new_workspace) -class CollaborativeAnalysisWorkspaceAuditTest(TestCase): +class WorkspaceAuditTest(TestCase): """Tests for the CollaborativeAnalysisWorkspaceAuditTest view.""" def setUp(self): @@ -684,3 +684,300 @@ def test_context_error_table_group_in_auth_domain(self): audit.CollaborativeAnalysisWorkspaceAccessAudit.UNEXPECTED_GROUP_ACCESS, ) self.assertIsNotNone(table.rows[0].get_cell_value("action")) + + +class WorkspaceAuditAllTest(TestCase): + """Tests for the CollaborativeAnalysisWorkspaceAuditAllTest view.""" + + 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=AnVILProjectManagerAccess.STAFF_VIEW_PERMISSION_CODENAME + ) + ) + + def get_url(self, *args): + """Get the url for the view being tested.""" + return reverse( + "collaborative_analysis:workspaces:audit_all", + args=args, + ) + + def get_view(self): + """Return the view being tested.""" + return views.WorkspaceAuditAll.as_view() + + def test_view_redirect_not_logged_in(self): + "View redirects to login view when user is not logged in." + # Need a client for redirects. + response = self.client.get(self.get_url()) + self.assertRedirects( + response, + resolve_url(settings.LOGIN_URL) + "?next=" + self.get_url(), + ) + + def test_status_code_with_user_permission_view(self): + """Returns successful response code if the user has view permission.""" + request = self.factory.get(self.get_url()) + request.user = self.user + response = self.get_view()(request) + self.assertEqual(response.status_code, 200) + + 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()) + request.user = user_no_perms + with self.assertRaises(PermissionDenied): + self.get_view()(request) + + def test_context_data_access_audit_no_workspaces(self): + """The data_access_audit exists in the context.""" + self.client.force_login(self.user) + response = self.client.get(self.get_url()) + self.assertIn("data_access_audit", response.context_data) + self.assertIsInstance( + response.context_data["data_access_audit"], + audit.CollaborativeAnalysisWorkspaceAccessAudit, + ) + self.assertTrue(response.context_data["data_access_audit"].completed) + qs = response.context_data["data_access_audit"].queryset + self.assertEqual(len(qs), 0) + + def test_context_data_access_audit_one_workspace(self): + """The data_access_audit exists in the context.""" + instance = factories.CollaborativeAnalysisWorkspaceFactory.create() + self.client.force_login(self.user) + response = self.client.get(self.get_url()) + self.assertIn("data_access_audit", response.context_data) + self.assertIsInstance( + response.context_data["data_access_audit"], + audit.CollaborativeAnalysisWorkspaceAccessAudit, + ) + self.assertTrue(response.context_data["data_access_audit"].completed) + qs = response.context_data["data_access_audit"].queryset + self.assertEqual(len(qs), 1) + self.assertIn(instance, qs) + + def test_context_data_access_audit_two_workspaces(self): + """The data_access_audit exists in the context.""" + instance_1 = factories.CollaborativeAnalysisWorkspaceFactory.create() + instance_2 = factories.CollaborativeAnalysisWorkspaceFactory.create() + self.client.force_login(self.user) + response = self.client.get(self.get_url()) + self.assertIn("data_access_audit", response.context_data) + self.assertIsInstance( + response.context_data["data_access_audit"], + audit.CollaborativeAnalysisWorkspaceAccessAudit, + ) + self.assertTrue(response.context_data["data_access_audit"].completed) + qs = response.context_data["data_access_audit"].queryset + self.assertEqual(len(qs), 2) + self.assertIn(instance_1, qs) + self.assertIn(instance_2, qs) + + def test_context_verified_table_access(self): + """verified_table shows a record when audit has one account with verified access.""" + instance = factories.CollaborativeAnalysisWorkspaceFactory.create() + # Create accounts. + account = AccountFactory.create() + # Set up source workspaces. + source_workspace = dbGaPWorkspaceFactory.create() + instance.source_workspaces.add(source_workspace.workspace) + # Analyst group membership. + GroupAccountMembershipFactory.create( + group=instance.analyst_group, account=account + ) + # Source workspace auth domains membership. + GroupAccountMembershipFactory.create( + group=source_workspace.workspace.authorization_domains.first(), + account=account, + ) + # CollaborativeAnalysisWorkspace auth domain membership. + GroupAccountMembershipFactory.create( + group=instance.workspace.authorization_domains.first(), + account=account, + ) + # Check the table in the context. + self.client.force_login(self.user) + response = self.client.get(self.get_url()) + self.assertIn("verified_table", response.context_data) + table = response.context_data["verified_table"] + self.assertIsInstance( + table, + audit.AccessAuditResultsTable, + ) + self.assertEqual(len(table.rows), 1) + self.assertEqual( + table.rows[0].get_cell_value("workspace"), + instance, + ) + self.assertEqual(table.rows[0].get_cell_value("member"), account) + self.assertEqual( + table.rows[0].get_cell_value("note"), + audit.CollaborativeAnalysisWorkspaceAccessAudit.IN_SOURCE_AUTH_DOMAINS, + ) + self.assertIsNone(table.rows[0].get_cell_value("action")) + + def test_context_verified_table_no_access(self): + """verified_table shows a record when audit has one account with verified no access.""" + instance = factories.CollaborativeAnalysisWorkspaceFactory.create() + # Create accounts. + account = AccountFactory.create() + # Set up source workspaces. + source_workspace = dbGaPWorkspaceFactory.create() + instance.source_workspaces.add(source_workspace.workspace) + # Analyst group membership. + GroupAccountMembershipFactory.create( + group=instance.analyst_group, account=account + ) + # Source workspace auth domains membership. + # GroupAccountMembershipFactory.create( + # group=source_workspace.workspace.authorization_domains.first(), + # account=account, + # ) + # CollaborativeAnalysisWorkspace auth domain membership. + # GroupAccountMembershipFactory.create( + # group=instance.workspace.authorization_domains.first(), account=account + # ) + # Check the table in the context. + self.client.force_login(self.user) + response = self.client.get(self.get_url()) + self.assertIn("verified_table", response.context_data) + table = response.context_data["verified_table"] + self.assertIsInstance( + table, + audit.AccessAuditResultsTable, + ) + self.assertEqual(len(table.rows), 1) + self.assertEqual( + table.rows[0].get_cell_value("workspace"), + instance, + ) + self.assertEqual(table.rows[0].get_cell_value("member"), account) + self.assertEqual( + table.rows[0].get_cell_value("note"), + audit.CollaborativeAnalysisWorkspaceAccessAudit.NOT_IN_SOURCE_AUTH_DOMAINS, + ) + self.assertIsNone(table.rows[0].get_cell_value("action")) + + def test_context_needs_action_table_grant(self): + """needs_action_table shows a record when audit finds that access needs to be granted.""" + instance = factories.CollaborativeAnalysisWorkspaceFactory.create() + # Create accounts. + account = AccountFactory.create() + # Set up source workspaces. + source_workspace = dbGaPWorkspaceFactory.create() + instance.source_workspaces.add(source_workspace.workspace) + # Analyst group membership. + GroupAccountMembershipFactory.create( + group=instance.analyst_group, account=account + ) + # Source workspace auth domains membership. + GroupAccountMembershipFactory.create( + group=source_workspace.workspace.authorization_domains.first(), + account=account, + ) + # CollaborativeAnalysisWorkspace auth domain membership. + # GroupAccountMembershipFactory.create( + # group=self.collaborative_analysis_workspace.workspace.authorization_domains.first(), account=account + # ) + # Check the table in the context. + self.client.force_login(self.user) + response = self.client.get(self.get_url()) + self.assertIn("needs_action_table", response.context_data) + table = response.context_data["needs_action_table"] + self.assertIsInstance( + table, + audit.AccessAuditResultsTable, + ) + self.assertEqual(len(table.rows), 1) + self.assertEqual( + table.rows[0].get_cell_value("workspace"), + instance, + ) + self.assertEqual(table.rows[0].get_cell_value("member"), account) + self.assertEqual( + table.rows[0].get_cell_value("note"), + audit.CollaborativeAnalysisWorkspaceAccessAudit.IN_SOURCE_AUTH_DOMAINS, + ) + self.assertIsNotNone(table.rows[0].get_cell_value("action")) + + def test_context_needs_action_table_remove(self): + """needs_action_table shows a record when audit finds that access needs to be removed.""" + instance = factories.CollaborativeAnalysisWorkspaceFactory.create() + # Create accounts. + account = AccountFactory.create() + # Set up source workspaces. + source_workspace = dbGaPWorkspaceFactory.create() + instance.source_workspaces.add(source_workspace.workspace) + # Analyst group membership. + GroupAccountMembershipFactory.create( + group=instance.analyst_group, account=account + ) + # Source workspace auth domains membership. + # GroupAccountMembershipFactory.create( + # group=source_workspace.workspace.authorization_domains.first(), + # account=account, + # ) + # CollaborativeAnalysisWorkspace auth domain membership. + GroupAccountMembershipFactory.create( + group=instance.workspace.authorization_domains.first(), + account=account, + ) + # Check the table in the context. + self.client.force_login(self.user) + response = self.client.get(self.get_url()) + self.assertIn("needs_action_table", response.context_data) + table = response.context_data["needs_action_table"] + self.assertIsInstance( + table, + audit.AccessAuditResultsTable, + ) + self.assertEqual(len(table.rows), 1) + self.assertEqual( + table.rows[0].get_cell_value("workspace"), + instance, + ) + self.assertEqual(table.rows[0].get_cell_value("member"), account) + self.assertEqual( + table.rows[0].get_cell_value("note"), + audit.CollaborativeAnalysisWorkspaceAccessAudit.NOT_IN_SOURCE_AUTH_DOMAINS, + ) + self.assertIsNotNone(table.rows[0].get_cell_value("action")) + + def test_context_error_table_group_in_auth_domain(self): + """error shows a record when audit finds a group in the auth domain.""" + instance = factories.CollaborativeAnalysisWorkspaceFactory.create() + # Create accounts. + group = ManagedGroupFactory.create() + GroupGroupMembershipFactory.create( + parent_group=instance.workspace.authorization_domains.first(), + child_group=group, + ) + # Check the table in the context. + self.client.force_login(self.user) + response = self.client.get(self.get_url()) + self.assertIn("errors_table", response.context_data) + table = response.context_data["errors_table"] + self.assertIsInstance( + table, + audit.AccessAuditResultsTable, + ) + self.assertEqual(len(table.rows), 1) + self.assertEqual( + table.rows[0].get_cell_value("workspace"), + instance, + ) + self.assertEqual(table.rows[0].get_cell_value("member"), group) + self.assertEqual( + table.rows[0].get_cell_value("note"), + audit.CollaborativeAnalysisWorkspaceAccessAudit.UNEXPECTED_GROUP_ACCESS, + ) + self.assertIsNotNone(table.rows[0].get_cell_value("action")) diff --git a/primed/collaborative_analysis/urls.py b/primed/collaborative_analysis/urls.py index 31735ff0..e53dcef9 100644 --- a/primed/collaborative_analysis/urls.py +++ b/primed/collaborative_analysis/urls.py @@ -7,6 +7,11 @@ collaborative_analysis_workspace_patterns = ( [ + path( + "audit/", + views.WorkspaceAuditAll.as_view(), + name="audit_all", + ), path( "//audit/", views.WorkspaceAudit.as_view(), diff --git a/primed/collaborative_analysis/views.py b/primed/collaborative_analysis/views.py index abc1124e..a678e81b 100644 --- a/primed/collaborative_analysis/views.py +++ b/primed/collaborative_analysis/views.py @@ -1,7 +1,7 @@ from anvil_consortium_manager.auth import AnVILConsortiumManagerStaffViewRequired from django.http import Http404 from django.utils.translation import gettext_lazy as _ -from django.views.generic import DetailView +from django.views.generic import DetailView, TemplateView from . import audit, models @@ -46,3 +46,24 @@ def get_context_data(self, **kwargs): context["needs_action_table"] = data_access_audit.get_needs_action_table() context["data_access_audit"] = data_access_audit return context + + +class WorkspaceAuditAll(AnVILConsortiumManagerStaffViewRequired, TemplateView): + """View to show audit results for all `CollaborativeAnalysisWorkspace` objects.""" + + template_name = ( + "collaborative_analysis/collaborativeanalysisworkspace_audit_all.html" + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # Run the audit + data_access_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit( + queryset=models.CollaborativeAnalysisWorkspace.objects.all() + ) + data_access_audit.run_audit() + context["verified_table"] = data_access_audit.get_verified_table() + context["errors_table"] = data_access_audit.get_errors_table() + context["needs_action_table"] = data_access_audit.get_needs_action_table() + context["data_access_audit"] = data_access_audit + return context diff --git a/primed/templates/collaborative_analysis/collaborativeanalysisworkspace_audit_all.html b/primed/templates/collaborative_analysis/collaborativeanalysisworkspace_audit_all.html new file mode 100644 index 00000000..72f6f5d8 --- /dev/null +++ b/primed/templates/collaborative_analysis/collaborativeanalysisworkspace_audit_all.html @@ -0,0 +1,49 @@ +{% extends "anvil_consortium_manager/base.html" %} + +{% block title %}Collaborative analysis workspace audit{% endblock %} + + +{% block content %} + +

Collaborative analysis workspace audit

+ +

Audit results

+ +
+ This page shows the audit results for all Collaborative Analysis Workspaces in this app. + +

+ To have access to a Collaborative Analysis Workspace, an account must meet both of the following criteria: + +

    +
  • Be in the analyst group associated with the workspace
  • +
  • Be in the auth domain for all source workspaces
  • +
+

+

The audit result categories are explained below. +

    + +
  • Verified includes the following:
  • +
      +
    • An account in the analyst group is in the auth domain for this workspace and is in all auth domains for all source workspaces.
    • +
    • An account in the analyst group is not in the auth domain for this workspace and is not in all auth domains for all source workspaces.
    • +
    + +
  • Needs action includes the following:
  • +
      +
    • An account in the analyst group is not in the auth domain for this workspace and is in all auth domains for all source workspaces.
    • +
    • An account in the analyst group is in the auth domain for this workspace and is not in all auth domains for all source workspaces.
    • +
    + +
  • Errors
  • +
      +
    • A group is unexpectedly in the auth domain.
    • +
    +
+

+

Any errors should be reported!

+
+ +{% include "__audit_tables.html" with verified_table=verified_table needs_action_table=needs_action_table errors_table=errors_table %} + +{% endblock content %} From ee4f7309ca7cd1d897ff858491ba0ab061992faa Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Thu, 25 Jan 2024 08:59:06 -0800 Subject: [PATCH 29/37] Fix page titles for collaborative analysis workspaces --- .../collaborativeanalysisworkspace_audit.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/primed/templates/collaborative_analysis/collaborativeanalysisworkspace_audit.html b/primed/templates/collaborative_analysis/collaborativeanalysisworkspace_audit.html index 394609db..8c64ca86 100644 --- a/primed/templates/collaborative_analysis/collaborativeanalysisworkspace_audit.html +++ b/primed/templates/collaborative_analysis/collaborativeanalysisworkspace_audit.html @@ -1,6 +1,6 @@ {% extends "anvil_consortium_manager/base.html" %} -{% block title %}dbGaP workspace audit{% endblock %} +{% block title %}Collaborative Analysis Workspace audit{% endblock %} {% block content %} From 64ed2385c73bd09d9f0926dac49b6e548f807549 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Thu, 25 Jan 2024 09:23:33 -0800 Subject: [PATCH 30/37] Register CollaborativeAnalysisWorkspace in the admin --- primed/collaborative_analysis/admin.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/primed/collaborative_analysis/admin.py b/primed/collaborative_analysis/admin.py index 8c38f3f3..96b11254 100644 --- a/primed/collaborative_analysis/admin.py +++ b/primed/collaborative_analysis/admin.py @@ -1,3 +1,21 @@ from django.contrib import admin +from simple_history.admin import SimpleHistoryAdmin -# Register your models here. +from . import models + + +@admin.register(models.CollaborativeAnalysisWorkspace) +class CollaborativeAnalysisWorkspaceAdmin(SimpleHistoryAdmin): + """Admin class for the `CollaborativeAnalysisWorkspace` model.""" + + list_display = ( + "id", + "workspace", + "custodian", + ) + list_filter = ("proposal_id",) + sortable_by = ( + "id", + "workspace", + "custodian", + ) From 225d9804ac2b7c2df2e506b4b60f3ba43d37ec32 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Thu, 25 Jan 2024 09:40:12 -0800 Subject: [PATCH 31/37] Add debugging print statements --- primed/collaborative_analysis/tests/test_views.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/primed/collaborative_analysis/tests/test_views.py b/primed/collaborative_analysis/tests/test_views.py index 0861b34b..e94f4995 100644 --- a/primed/collaborative_analysis/tests/test_views.py +++ b/primed/collaborative_analysis/tests/test_views.py @@ -200,6 +200,8 @@ def test_creates_workspace(self): "workspacedata-0-analyst_group": self.analyst_group.pk, }, ) + print(response["form"].errors) + print(response["workspace_data_formset"].errors) self.assertEqual(response.status_code, 302) # The workspace is created. new_workspace = Workspace.objects.latest("pk") From 8920bc154f82edd47ab677bc024071fb593153f5 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Thu, 25 Jan 2024 09:57:12 -0800 Subject: [PATCH 32/37] Fix debugging print statements --- primed/collaborative_analysis/tests/test_views.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/primed/collaborative_analysis/tests/test_views.py b/primed/collaborative_analysis/tests/test_views.py index e94f4995..7529c564 100644 --- a/primed/collaborative_analysis/tests/test_views.py +++ b/primed/collaborative_analysis/tests/test_views.py @@ -200,8 +200,9 @@ def test_creates_workspace(self): "workspacedata-0-analyst_group": self.analyst_group.pk, }, ) - print(response["form"].errors) - print(response["workspace_data_formset"].errors) + print(response.content) + print(response.context_data["form"].errors) + print(response.context_data["workspace_data_formset"].errors) self.assertEqual(response.status_code, 302) # The workspace is created. new_workspace = Workspace.objects.latest("pk") From 5f021a23877a6d32f281882072124914c5bcd898 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Thu, 25 Jan 2024 10:11:21 -0800 Subject: [PATCH 33/37] Fix collab analysis create/import tests for mysql Need to pass the workspace pk instead of the workspace_data pk. --- primed/collaborative_analysis/tests/test_views.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/primed/collaborative_analysis/tests/test_views.py b/primed/collaborative_analysis/tests/test_views.py index 7529c564..1c2618fb 100644 --- a/primed/collaborative_analysis/tests/test_views.py +++ b/primed/collaborative_analysis/tests/test_views.py @@ -161,7 +161,7 @@ def setUp(self): ) self.workspace_type = "collab_analysis" self.custodian = UserFactory.create() - self.source_workspace = dbGaPWorkspaceFactory.create() + self.source_workspace = dbGaPWorkspaceFactory.create().workspace self.analyst_group = ManagedGroupFactory.create() def get_url(self, *args): @@ -200,9 +200,6 @@ def test_creates_workspace(self): "workspacedata-0-analyst_group": self.analyst_group.pk, }, ) - print(response.content) - print(response.context_data["form"].errors) - print(response.context_data["workspace_data_formset"].errors) self.assertEqual(response.status_code, 302) # The workspace is created. new_workspace = Workspace.objects.latest("pk") @@ -234,7 +231,7 @@ def setUp(self): ) ) self.custodian = UserFactory.create() - self.source_workspace = dbGaPWorkspaceFactory.create() + self.source_workspace = dbGaPWorkspaceFactory.create().workspace self.workspace_type = "collab_analysis" self.analyst_group = ManagedGroupFactory.create() From be3fa7fd1b5dc6171bb8a6652b9edee26aac9e5d Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Thu, 25 Jan 2024 11:02:33 -0800 Subject: [PATCH 34/37] Include checkboxes showing access in collab workspace audit In the audit results, show a checkbox to indicate if an account has current access to the workspace or not. --- primed/collaborative_analysis/audit.py | 14 ++++++++++++++ primed/collaborative_analysis/tests/test_audit.py | 2 ++ 2 files changed, 16 insertions(+) diff --git a/primed/collaborative_analysis/audit.py b/primed/collaborative_analysis/audit.py index 02fe7e50..c818cf48 100644 --- a/primed/collaborative_analysis/audit.py +++ b/primed/collaborative_analysis/audit.py @@ -11,6 +11,8 @@ from django.urls import reverse from django.utils.safestring import mark_safe +from primed.primed_anvil.tables import BooleanIconColumn + from . import models @@ -21,6 +23,7 @@ class AccessAuditResult: collaborative_analysis_workspace: models.CollaborativeAnalysisWorkspace member: Union[Account, ManagedGroup] note: str + has_access: bool def get_action_url(self): """The URL that handles the action needed.""" @@ -35,6 +38,7 @@ def get_table_dictionary(self): row = { "workspace": self.collaborative_analysis_workspace, "member": self.member, + "has_access": self.has_access, "note": self.note, "action": self.get_action(), "action_url": self.get_action_url(), @@ -46,16 +50,22 @@ def get_table_dictionary(self): class VerifiedAccess(AccessAuditResult): """Audit results class for when an account has verified access.""" + has_access: bool = True + @dataclass class VerifiedNoAccess(AccessAuditResult): """Audit results class for when an account has verified no access.""" + has_access: bool = False + @dataclass class GrantAccess(AccessAuditResult): """Audit results class for when an account should be granted access.""" + has_access: bool = False + def get_action(self): return "Grant access" @@ -82,6 +92,8 @@ def get_action_url(self): class RemoveAccess(AccessAuditResult): """Audit results class for when access for an account should be removed.""" + has_access: bool = True + def get_action(self): return "Remove access" @@ -109,6 +121,7 @@ class AccessAuditResultsTable(tables.Table): workspace = tables.Column(linkify=True) member = tables.Column(linkify=True) + has_access = BooleanIconColumn(show_false_icon=True) note = tables.Column() action = tables.Column() @@ -135,6 +148,7 @@ class CollaborativeAnalysisWorkspaceAccessAudit: "Account is not in all source auth domains for this workspace." ) NOT_IN_ANALYST_GROUP = "Account is not in the analyst group for this workspace." + INACTIVE_ACCOUNT = "Account is inactive." # Errors. UNEXPECTED_GROUP_ACCESS = "Unexpected group added to the auth domain." diff --git a/primed/collaborative_analysis/tests/test_audit.py b/primed/collaborative_analysis/tests/test_audit.py index d0967054..42903ec8 100644 --- a/primed/collaborative_analysis/tests/test_audit.py +++ b/primed/collaborative_analysis/tests/test_audit.py @@ -1095,6 +1095,7 @@ def test_two_rows(self): { "workspace": workspace, "member": member_account, + "has_access": True, "note": "a note", "action": "", "action_url": "", @@ -1102,6 +1103,7 @@ def test_two_rows(self): { "workspace": workspace, "member": member_group, + "has_access": False, "note": "a note", "action": "", "action_url": "", From 7cf6745008ac88e249b6e6504185f0e8c7acf650 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Thu, 25 Jan 2024 12:12:37 -0800 Subject: [PATCH 35/37] Add collab analysis links to navbar --- .../tests/test_views.py | 42 +++++++++++++++++++ .../anvil_consortium_manager/navbar.html | 1 + .../collaborative_analysis/nav_items.html | 13 ++++++ 3 files changed, 56 insertions(+) create mode 100644 primed/templates/collaborative_analysis/nav_items.html diff --git a/primed/collaborative_analysis/tests/test_views.py b/primed/collaborative_analysis/tests/test_views.py index 1c2618fb..d5f67a8d 100644 --- a/primed/collaborative_analysis/tests/test_views.py +++ b/primed/collaborative_analysis/tests/test_views.py @@ -30,6 +30,48 @@ User = get_user_model() +class NavbarTest(TestCase): + """Tests for the navbar involving Collaborative Analysis links.""" + + def setUp(self): + """Set up test class.""" + self.factory = RequestFactory() + # Create a user with both view and edit permission. + + def get_url(self, *args): + """Get the url for the view being tested.""" + # Use the workspace landing page, since view users can see it. + return reverse("anvil_consortium_manager:workspaces:landing_page", args=args) + + def test_links_for_staff_view(self): + """Returns successful response code.""" + user = User.objects.create_user(username="test", password="test") + user.user_permissions.add( + Permission.objects.get( + codename=AnVILProjectManagerAccess.STAFF_VIEW_PERMISSION_CODENAME + ) + ) + self.client.force_login(user) + response = self.client.get(self.get_url()) + self.assertContains( + response, reverse("collaborative_analysis:workspaces:audit_all") + ) + + def test_links_for_view(self): + """Returns successful response code.""" + user = User.objects.create_user(username="test", password="test") + user.user_permissions.add( + Permission.objects.get( + codename=AnVILProjectManagerAccess.VIEW_PERMISSION_CODENAME + ) + ) + self.client.force_login(user) + response = self.client.get(self.get_url()) + self.assertNotContains( + response, reverse("collaborative_analysis:workspaces:audit_all") + ) + + class CollaborativeAnalysisWorkspaceDetailTest(TestCase): """Tests of the WorkspaceDetail view from ACM with this app's CollaborativeAnalysisWorkspace model.""" diff --git a/primed/templates/anvil_consortium_manager/navbar.html b/primed/templates/anvil_consortium_manager/navbar.html index 6dc96662..5b880f57 100644 --- a/primed/templates/anvil_consortium_manager/navbar.html +++ b/primed/templates/anvil_consortium_manager/navbar.html @@ -45,6 +45,7 @@ {% include 'cdsa/nav_items.html' %} + {% include 'collaborative_analysis/nav_items.html' %} {{ block.super }} diff --git a/primed/templates/collaborative_analysis/nav_items.html b/primed/templates/collaborative_analysis/nav_items.html new file mode 100644 index 00000000..b12d240d --- /dev/null +++ b/primed/templates/collaborative_analysis/nav_items.html @@ -0,0 +1,13 @@ + From 29eb4888bd14c3f1da4e90526bba8605550092df Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Mon, 29 Jan 2024 10:31:35 -0800 Subject: [PATCH 36/37] Do not grant PRIMED_CC_MEMBERS access to collab analysis workspaces PRIMED_CC_WRITERS should get access, but not PRIMED_CC_MEMBERS. --- primed/collaborative_analysis/audit.py | 2 +- .../tests/test_audit.py | 23 ++++++++----------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/primed/collaborative_analysis/audit.py b/primed/collaborative_analysis/audit.py index c818cf48..34cc2a5b 100644 --- a/primed/collaborative_analysis/audit.py +++ b/primed/collaborative_analysis/audit.py @@ -212,7 +212,7 @@ def _audit_workspace(self, workspace): cc_groups = ManagedGroup.objects.filter( name__in=[ "PRIMED_CC_WRITERS", - "PRIMED_CC_MEMBERS", + # "PRIMED_CC_MEMBERS", # CC_MEMBERS should not get access. ] ) for cc_group in cc_groups: diff --git a/primed/collaborative_analysis/tests/test_audit.py b/primed/collaborative_analysis/tests/test_audit.py index 42903ec8..62baf77f 100644 --- a/primed/collaborative_analysis/tests/test_audit.py +++ b/primed/collaborative_analysis/tests/test_audit.py @@ -892,7 +892,7 @@ def test_ignores_primed_admins_group(self): self.assertEqual(len(collab_audit.needs_action), 0) self.assertEqual(len(collab_audit.errors), 0) - def test_verified_access_for_primed_cc_members_group(self): + def test_error_for_primed_cc_members_group(self): workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() # Add a group to the auth domain. group = ManagedGroupFactory.create(name="PRIMED_CC_MEMBERS") @@ -902,19 +902,19 @@ def test_verified_access_for_primed_cc_members_group(self): ) collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() collab_audit._audit_workspace(workspace) - self.assertEqual(len(collab_audit.verified), 1) + self.assertEqual(len(collab_audit.verified), 0) self.assertEqual(len(collab_audit.needs_action), 0) - self.assertEqual(len(collab_audit.errors), 0) - record = collab_audit.verified[0] - self.assertIsInstance(record, audit.VerifiedAccess) + self.assertEqual(len(collab_audit.errors), 1) + record = collab_audit.errors[0] + self.assertIsInstance(record, audit.RemoveAccess) self.assertEqual(record.collaborative_analysis_workspace, workspace) self.assertEqual(record.member, group) - self.assertEqual(record.note, collab_audit.DCC_ACCESS) + self.assertEqual(record.note, collab_audit.UNEXPECTED_GROUP_ACCESS) - def test_grant_access_for_primed_cc_members_group(self): + def test_no_access_for_primed_cc_members_group(self): workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() # Add a group to the auth domain. - group = ManagedGroupFactory.create(name="PRIMED_CC_MEMBERS") + ManagedGroupFactory.create(name="PRIMED_CC_MEMBERS") # GroupGroupMembershipFactory.create( # parent_group=workspace.workspace.authorization_domains.first(), # child_group=group, @@ -922,13 +922,8 @@ def test_grant_access_for_primed_cc_members_group(self): collab_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() collab_audit._audit_workspace(workspace) self.assertEqual(len(collab_audit.verified), 0) - self.assertEqual(len(collab_audit.needs_action), 1) + self.assertEqual(len(collab_audit.needs_action), 0) self.assertEqual(len(collab_audit.errors), 0) - record = collab_audit.needs_action[0] - self.assertIsInstance(record, audit.GrantAccess) - self.assertEqual(record.collaborative_analysis_workspace, workspace) - self.assertEqual(record.member, group) - self.assertEqual(record.note, collab_audit.DCC_ACCESS) def test_verified_access_for_primed_cc_writers_group(self): workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() From eb73ada01bc1edd02c88b13d3819d1a92d93f6b3 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Fri, 16 Feb 2024 14:10:20 -0800 Subject: [PATCH 37/37] Update language on collab workspace audit template --- .../collaborativeanalysisworkspace_audit_all.html | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/primed/templates/collaborative_analysis/collaborativeanalysisworkspace_audit_all.html b/primed/templates/collaborative_analysis/collaborativeanalysisworkspace_audit_all.html index 72f6f5d8..40730a51 100644 --- a/primed/templates/collaborative_analysis/collaborativeanalysisworkspace_audit_all.html +++ b/primed/templates/collaborative_analysis/collaborativeanalysisworkspace_audit_all.html @@ -27,13 +27,16 @@

Audit results

  • An account in the analyst group is in the auth domain for this workspace and is in all auth domains for all source workspaces.
  • An account in the analyst group is not in the auth domain for this workspace and is not in all auth domains for all source workspaces.
  • +
  • The PRIMED_CC_WRITERS group is in the auth domain for this workspace.
  • Needs action includes the following:
    • An account in the analyst group is not in the auth domain for this workspace and is in all auth domains for all source workspaces.
    • An account in the analyst group is in the auth domain for this workspace and is not in all auth domains for all source workspaces.
    • -
    +
  • An account is not in the analyst group but is in the auth domain of this workspace.
  • +
  • The PRIMED_CC_WRITERS group needs to be added to the auth domain for this workspace.
  • +
  • Errors