diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 055a0096..de3f7d09 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -17,7 +17,7 @@ updates: interval: "weekly" day: "sunday" allow: - # Allow both direct and indirect updates for all packages - - dependency-type: "all" + # Allow only direct dependencies - should be ok with pip-sync? + - dependency-type: "direct" # Allow up to 20 dependencies for pip dependencies open-pull-requests-limit: 20 diff --git a/.github/workflows/combine-prs.yml b/.github/workflows/combine-prs.yml index 04e15b8c..c2f75a73 100644 --- a/.github/workflows/combine-prs.yml +++ b/.github/workflows/combine-prs.yml @@ -22,3 +22,5 @@ jobs: with: labels: combined-pr # Optional: add a label to the combined PR ci_required: true # require all checks to pass before combining + select_label: dependencies # Optional: only combine PRs with this label + autoclose: false # do not close the source PRs - dependabot should handle it. diff --git a/add_cdsa_example_data.py b/add_cdsa_example_data.py index 44ef9d69..b091fe94 100644 --- a/add_cdsa_example_data.py +++ b/add_cdsa_example_data.py @@ -8,14 +8,18 @@ ManagedGroupFactory, ) from django.conf import settings +from django.core.management import call_command from primed.cdsa.tests import factories -from primed.duo.tests.factories import DataUseModifierFactory, DataUsePermissionFactory +from primed.duo.models import DataUseModifier, DataUsePermission from primed.primed_anvil.models import Study, StudySite from primed.primed_anvil.tests.factories import StudyFactory, StudySiteFactory from primed.users.models import User from primed.users.tests.factories import UserFactory +# Load duos +call_command("load_duo") + # Create major versions major_version = factories.AgreementMajorVersionFactory.create(version=1) @@ -28,8 +32,8 @@ ) # Create a couple signed CDSAs. -dup = DataUsePermissionFactory.create(abbreviation="GRU") -dum = DataUseModifierFactory.create(abbreviation="NPU") +dup = DataUsePermission.objects.get(abbreviation="GRU") +dum = DataUseModifier.objects.get(abbreviation="NPU") # create the CDSA auth group cdsa_group = ManagedGroupFactory.create(name=settings.ANVIL_CDSA_GROUP_NAME) @@ -113,6 +117,7 @@ signed_agreement__signing_institution="UW", study=Study.objects.get(short_name="MESA"), signed_agreement__version=v10, + additional_limitations="This data can only be used for testing the app.", ) GroupGroupMembershipFactory.create( parent_group=cdsa_group, child_group=cdsa_1006.signed_agreement.anvil_access_group @@ -219,5 +224,6 @@ workspace__name="DEMO_PRIMED_CDSA_MESA_2", study=Study.objects.get(short_name="MESA"), data_use_permission=dup, + additional_limitations="Additional limitations for workspace.", ) cdsa_workspace_2.data_use_modifiers.add(dum) diff --git a/primed/cdsa/adapters.py b/primed/cdsa/adapters.py index c72c975e..b32edc11 100644 --- a/primed/cdsa/adapters.py +++ b/primed/cdsa/adapters.py @@ -33,4 +33,10 @@ def get_extra_detail_context_data(self, workspace, request): extra_context["data_prep_active"] = associated_data_prep.filter( dataprepworkspace__is_active=True ).exists() + # Get the primary CDSA for this study, assuming it exists. + try: + extra_context["primary_cdsa"] = workspace.cdsaworkspace.get_primary_cdsa() + except models.DataAffiliateAgreement.DoesNotExist: + extra_context["primary_cdsa"] = None + return extra_context diff --git a/primed/cdsa/audit/signed_agreement_audit.py b/primed/cdsa/audit/signed_agreement_audit.py index 2f8c5e9e..929783c4 100644 --- a/primed/cdsa/audit/signed_agreement_audit.py +++ b/primed/cdsa/audit/signed_agreement_audit.py @@ -7,22 +7,20 @@ from django.db.models import QuerySet from django.urls import reverse +from primed.primed_anvil.audit import PRIMEDAudit, PRIMEDAuditResult + from .. import models # Dataclasses for storing audit results? @dataclass -class AccessAuditResult: +class AccessAuditResult(PRIMEDAuditResult): """Base class to hold results for auditing CDSA access for a specific SignedAgreement.""" note: str signed_agreement: models.SignedAgreement action: str = None - # def __init__(self, *args, **kwargs): - # super().__init(*args, **kwargs) - # self.anvil_cdsa_group = ManagedGroup.objects.get(name="PRIMED_CDSA") - def __post_init__(self): self.anvil_cdsa_group = ManagedGroup.objects.get( name=settings.ANVIL_CDSA_GROUP_NAME @@ -107,7 +105,7 @@ class Meta: attrs = {"class": "table align-middle"} -class SignedAgreementAccessAudit: +class SignedAgreementAccessAudit(PRIMEDAudit): """Audit for Signed Agreements.""" # Access verified. @@ -129,12 +127,7 @@ class SignedAgreementAccessAudit: results_table_class = SignedAgreementAccessAuditTable def __init__(self, signed_agreement_queryset=None): - # Store the CDSA group for auditing membership. - self.completed = False - # Set up lists to hold audit results. - self.verified = [] - self.needs_action = [] - self.errors = [] + super().__init__() # Store the queryset to run the audit on. if signed_agreement_queryset is None: signed_agreement_queryset = models.SignedAgreement.objects.all() @@ -332,27 +325,7 @@ def _audit_signed_agreement(self, signed_agreement): else: self._audit_component_agreement(signed_agreement) - def run_audit(self): + def _run_audit(self): """Run an audit on all SignedAgreements.""" for signed_agreement in self.signed_agreement_queryset: self._audit_signed_agreement(signed_agreement) - self.completed = True - - def get_all_results(self): - return self.verified + self.needs_action + self.errors - - 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/cdsa/audit/workspace_audit.py b/primed/cdsa/audit/workspace_audit.py index 6d545c5f..2e329859 100644 --- a/primed/cdsa/audit/workspace_audit.py +++ b/primed/cdsa/audit/workspace_audit.py @@ -6,12 +6,14 @@ from django.db.models import QuerySet from django.urls import reverse +from primed.primed_anvil.audit import PRIMEDAudit, PRIMEDAuditResult + # from . import models from .. import models @dataclass -class AccessAuditResult: +class AccessAuditResult(PRIMEDAuditResult): """Base class to hold results for auditing CDSA access for a specific SignedAgreement.""" workspace: models.CDSAWorkspace @@ -106,7 +108,7 @@ class Meta: attrs = {"class": "table align-middle"} -class WorkspaceAccessAudit: +class WorkspaceAccessAudit(PRIMEDAudit): """Audit for CDSA Workspaces.""" # Access verified. @@ -224,27 +226,7 @@ def _audit_workspace(self, workspace): ) return - def run_audit(self): + def _run_audit(self): """Run an audit on all SignedAgreements.""" for workspace in self.cdsa_workspace_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_all_results(self): - return self.verified + self.needs_action + self.errors - - 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/cdsa/forms.py b/primed/cdsa/forms.py index cfa854d2..5fbf7dab 100644 --- a/primed/cdsa/forms.py +++ b/primed/cdsa/forms.py @@ -77,6 +77,7 @@ class Meta: fields = ( "signed_agreement", "study", + "additional_limitations", ) widgets = { "study": autocomplete.ModelSelect2( @@ -114,7 +115,7 @@ class Meta: "data_use_permission", "data_use_modifiers", "disease_term", - "data_use_limitations", + "additional_limitations", "gsr_restricted", "acknowledgments", "available_data", diff --git a/primed/cdsa/management/commands/run_cdsa_audit.py b/primed/cdsa/management/commands/run_cdsa_audit.py new file mode 100644 index 00000000..8337f91a --- /dev/null +++ b/primed/cdsa/management/commands/run_cdsa_audit.py @@ -0,0 +1,92 @@ +from django.contrib.sites.models import Site +from django.core.mail import send_mail +from django.core.management.base import BaseCommand +from django.template.loader import render_to_string +from django.urls import reverse + +from ...audit import signed_agreement_audit, workspace_audit + + +class Command(BaseCommand): + help = "Run dbGaP access audit." + + def add_arguments(self, parser): + email_group = parser.add_argument_group(title="Email reports") + email_group.add_argument( + "--email", + help="""Email to which to send access reports that need action or have errors.""", + ) + + def _audit_signed_agreements(self): + self.stdout.write("Running SignedAgreement access audit... ", ending="") + data_access_audit = signed_agreement_audit.SignedAgreementAccessAudit() + data_access_audit.run_audit() + + # Construct the url for handling errors. + url = ( + "https://" + + Site.objects.get_current().domain + + reverse("cdsa:audit:signed_agreements:all") + ) + self._report_results(data_access_audit, url) + self._send_email(data_access_audit, url) + + def _audit_workspaces(self): + self.stdout.write("Running CDSAWorkspace access audit... ", ending="") + data_access_audit = workspace_audit.WorkspaceAccessAudit() + data_access_audit.run_audit() + + # Construct the url for handling errors. + url = ( + "https://" + + Site.objects.get_current().domain + + reverse("cdsa:audit:workspaces:all") + ) + self._report_results(data_access_audit, url) + self._send_email(data_access_audit, url) + + def _report_results(self, data_access_audit, resolve_url): + # Report errors and needs access. + audit_ok = data_access_audit.ok() + if audit_ok: + self.stdout.write(self.style.SUCCESS("ok!")) + else: + self.stdout.write(self.style.ERROR("problems found.")) + + # Print results + self.stdout.write("* Verified: {}".format(len(data_access_audit.verified))) + self.stdout.write( + "* Needs action: {}".format(len(data_access_audit.needs_action)) + ) + self.stdout.write("* Errors: {}".format(len(data_access_audit.errors))) + + if not audit_ok: + self.stdout.write( + self.style.ERROR(f"Please visit {resolve_url} to resolve these issues.") + ) + + def _send_email(self, data_access_audit, url): + # Send email if requested and there are problems. + if not data_access_audit.ok(): + subject = "CDSA {} errors".format(data_access_audit.__class__.__name__) + html_body = render_to_string( + "primed_anvil/email_audit_report.html", + context={ + "title": subject, + "data_access_audit": data_access_audit, + "url": url, + }, + ) + send_mail( + subject, + "Audit problems found. Please see attached report.", + None, + [self.email], + fail_silently=False, + html_message=html_body, + ) + + def handle(self, *args, **options): + self.email = options["email"] + self._audit_signed_agreements() + self._audit_workspaces() diff --git a/primed/cdsa/migrations/0015_dataaffiliateagreement_additional_limitations_and_more.py b/primed/cdsa/migrations/0015_dataaffiliateagreement_additional_limitations_and_more.py new file mode 100644 index 00000000..2db9518a --- /dev/null +++ b/primed/cdsa/migrations/0015_dataaffiliateagreement_additional_limitations_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.10 on 2024-03-12 21:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("cdsa", "0014_gsr_restricted_help_and_verbose"), + ] + + operations = [ + migrations.AddField( + model_name="dataaffiliateagreement", + name="additional_limitations", + field=models.TextField( + blank=True, + help_text="Additional limitations on data use as specified in the signed CDSA.", + ), + ), + migrations.AddField( + model_name="historicaldataaffiliateagreement", + name="additional_limitations", + field=models.TextField( + blank=True, + help_text="Additional limitations on data use as specified in the signed CDSA.", + ), + ), + ] diff --git a/primed/cdsa/migrations/0016_rename_data_use_limitations_cdsaworkspace_additional_limitations_and_more.py b/primed/cdsa/migrations/0016_rename_data_use_limitations_cdsaworkspace_additional_limitations_and_more.py new file mode 100644 index 00000000..6679fa3e --- /dev/null +++ b/primed/cdsa/migrations/0016_rename_data_use_limitations_cdsaworkspace_additional_limitations_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.10 on 2024-03-14 18:35 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("cdsa", "0015_dataaffiliateagreement_additional_limitations_and_more"), + ] + + operations = [ + migrations.RenameField( + model_name="cdsaworkspace", + old_name="data_use_limitations", + new_name="additional_limitations", + ), + migrations.RenameField( + model_name="historicalcdsaworkspace", + old_name="data_use_limitations", + new_name="additional_limitations", + ), + ] diff --git a/primed/cdsa/migrations/0017_alter_cdsaworkspace_additional_limitations_and_more.py b/primed/cdsa/migrations/0017_alter_cdsaworkspace_additional_limitations_and_more.py new file mode 100644 index 00000000..79b56330 --- /dev/null +++ b/primed/cdsa/migrations/0017_alter_cdsaworkspace_additional_limitations_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.10 on 2024-03-14 18:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "cdsa", + "0016_rename_data_use_limitations_cdsaworkspace_additional_limitations_and_more", + ), + ] + + operations = [ + migrations.AlterField( + model_name="cdsaworkspace", + name="additional_limitations", + field=models.TextField( + blank=True, + help_text="Additional data use limitations that cannot be captured by DUO.", + ), + ), + migrations.AlterField( + model_name="historicalcdsaworkspace", + name="additional_limitations", + field=models.TextField( + blank=True, + help_text="Additional data use limitations that cannot be captured by DUO.", + ), + ), + ] diff --git a/primed/cdsa/models.py b/primed/cdsa/models.py index 4896a3ed..0e223f55 100644 --- a/primed/cdsa/models.py +++ b/primed/cdsa/models.py @@ -277,6 +277,10 @@ class DataAffiliateAgreement(TimeStampedModel, AgreementTypeModel, models.Model) help_text="Study that this agreement is associated with.", ) anvil_upload_group = models.ForeignKey(ManagedGroup, on_delete=models.PROTECT) + additional_limitations = models.TextField( + blank=True, + help_text="Additional limitations on data use as specified in the signed CDSA.", + ) def get_absolute_url(self): return reverse( @@ -284,6 +288,17 @@ def get_absolute_url(self): kwargs={"cc_id": self.signed_agreement.cc_id}, ) + def clean(self): + super().clean() + if ( + self.additional_limitations + and hasattr(self, "signed_agreement") + and not self.signed_agreement.is_primary + ): + raise ValidationError( + "Additional limitations are only allowed for primary agreements." + ) + def get_agreement_group(self): return self.study @@ -318,8 +333,9 @@ class CDSAWorkspace( on_delete=models.PROTECT, help_text="The study associated with data in this workspace.", ) - data_use_limitations = models.TextField( - help_text="""The full data use limitations for this workspace.""" + additional_limitations = models.TextField( + help_text="""Additional data use limitations that cannot be captured by DUO.""", + blank=True, ) acknowledgments = models.TextField( help_text="Acknowledgments associated with data in this workspace." @@ -337,3 +353,12 @@ class CDSAWorkspace( class Meta: verbose_name = " CDSA workspace" verbose_name_plural = " CDSA workspaces" + + def get_primary_cdsa(self): + """Return the primary, valid CDSA associated with this workspace.""" + cdsa = DataAffiliateAgreement.objects.get( + study=self.study, + signed_agreement__is_primary=True, + signed_agreement__status=SignedAgreement.StatusChoices.ACTIVE, + ) + return cdsa diff --git a/primed/cdsa/tests/factories.py b/primed/cdsa/tests/factories.py index 610b63b4..855a3ed4 100644 --- a/primed/cdsa/tests/factories.py +++ b/primed/cdsa/tests/factories.py @@ -107,7 +107,6 @@ class Meta: class CDSAWorkspaceFactory(DjangoModelFactory): study = SubFactory(StudyFactory) - data_use_limitations = Faker("paragraph") acknowledgments = Faker("paragraph") requested_by = SubFactory(UserFactory) data_use_permission = SubFactory(DataUsePermissionFactory) diff --git a/primed/cdsa/tests/test_commands.py b/primed/cdsa/tests/test_commands.py index a320f0a6..916bd62f 100644 --- a/primed/cdsa/tests/test_commands.py +++ b/primed/cdsa/tests/test_commands.py @@ -5,8 +5,16 @@ from io import StringIO from os.path import isdir, isfile +from anvil_consortium_manager.tests.factories import ( + GroupGroupMembershipFactory, + ManagedGroupFactory, +) +from django.conf import settings +from django.contrib.sites.models import Site +from django.core import mail from django.core.management import CommandError, call_command from django.test import TestCase +from django.urls import reverse from ..tests import factories @@ -93,3 +101,366 @@ def test_directory_exists(self): "cdsa_records", "--outdir", self.outdir, "--no-color", stdout=out ) self.assertIn("already exists", str(e.exception)) + + +class RunCDSAAuditTest(TestCase): + """Tests for the run_cdsa_audit command""" + + def setUp(self): + super().setUp() + self.cdsa_group = ManagedGroupFactory.create( + name=settings.ANVIL_CDSA_GROUP_NAME + ) + + def test_command_output_no_records(self): + """Test command output.""" + out = StringIO() + call_command("run_cdsa_audit", "--no-color", stdout=out) + expected_output = ( + "Running SignedAgreement access audit... ok!\n" + "* Verified: 0\n" + "* Needs action: 0\n" + "* Errors: 0\n" + ) + self.assertIn(expected_output, out.getvalue()) + expected_output = ( + "Running CDSAWorkspace access audit... ok!\n" + "* Verified: 0\n" + "* Needs action: 0\n" + "* Errors: 0\n" + ) + self.assertIn(expected_output, out.getvalue()) + # Zero messages have been sent by default. + self.assertEqual(len(mail.outbox), 0) + + def test_command_run_audit_one_agreement_verified(self): + """Test command output with one verified instance.""" + factories.MemberAgreementFactory.create(signed_agreement__is_primary=False) + out = StringIO() + call_command("run_cdsa_audit", "--no-color", stdout=out) + expected_output = ( + "Running SignedAgreement access audit... ok!\n" + "* Verified: 1\n" + "* Needs action: 0\n" + "* Errors: 0\n" + ) + self.assertIn(expected_output, out.getvalue()) + expected_output = ( + "Running CDSAWorkspace access audit... ok!\n" + "* Verified: 0\n" + "* Needs action: 0\n" + "* Errors: 0\n" + ) + self.assertIn(expected_output, out.getvalue()) + # Zero messages have been sent by default. + self.assertEqual(len(mail.outbox), 0) + + def test_command_run_audit_one_agreement_needs_action(self): + """Test command output with one needs_action instance.""" + factories.MemberAgreementFactory.create() + out = StringIO() + call_command("run_cdsa_audit", "--no-color", stdout=out) + expected_output = ( + "Running SignedAgreement access audit... problems found.\n" + "* Verified: 0\n" + "* Needs action: 1\n" + "* Errors: 0\n" + ) + self.assertIn(expected_output, out.getvalue()) + self.assertIn(reverse("cdsa:audit:signed_agreements:all"), out.getvalue()) + self.assertIn("Running CDSAWorkspace access audit... ok!", out.getvalue()) + # Zero messages have been sent by default. + self.assertEqual(len(mail.outbox), 0) + + def test_command_run_audit_one_agreement_error(self): + """Test command output with one error instance.""" + agreement = factories.MemberAgreementFactory.create( + signed_agreement__is_primary=False + ) + GroupGroupMembershipFactory.create( + parent_group=self.cdsa_group, + child_group=agreement.signed_agreement.anvil_access_group, + ) + out = StringIO() + call_command("run_cdsa_audit", "--no-color", stdout=out) + expected_output = ( + "Running SignedAgreement access audit... problems found.\n" + "* Verified: 0\n" + "* Needs action: 0\n" + "* Errors: 1\n" + ) + self.assertIn(expected_output, out.getvalue()) + self.assertIn(reverse("cdsa:audit:signed_agreements:all"), out.getvalue()) + self.assertIn("Running CDSAWorkspace access audit... ok!", out.getvalue()) + # Zero messages have been sent by default. + self.assertEqual(len(mail.outbox), 0) + + def test_command_run_audit_one_agreement_verified_email(self): + """No email is sent when there are no errors.""" + factories.MemberAgreementFactory.create(signed_agreement__is_primary=False) + out = StringIO() + call_command( + "run_cdsa_audit", "--no-color", email="test@example.com", stdout=out + ) + self.assertIn("Running CDSAWorkspace access audit... ok!", out.getvalue()) + self.assertIn("Running SignedAgreement access audit... ok!", out.getvalue()) + # Zero messages have been sent by default. + self.assertEqual(len(mail.outbox), 0) + + def test_command_run_audit_one_agreement_needs_action_email(self): + """Email is sent for one needs_action instance.""" + factories.MemberAgreementFactory.create() + out = StringIO() + call_command( + "run_cdsa_audit", "--no-color", email="test@example.com", stdout=out + ) + expected_output = ( + "Running SignedAgreement access audit... problems found.\n" + "* Verified: 0\n" + "* Needs action: 1\n" + "* Errors: 0\n" + ) + self.assertIn(expected_output, out.getvalue()) + self.assertIn("Running CDSAWorkspace access audit... ok!", out.getvalue()) + # One message has been sent by default. + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertEqual(email.to, ["test@example.com"]) + self.assertEqual(email.subject, "CDSA SignedAgreementAccessAudit errors") + self.assertIn( + reverse("cdsa:audit:signed_agreements:all"), email.alternatives[0][0] + ) + + def test_command_run_audit_one_agreement_error_email(self): + """Test command output with one error instance.""" + agreement = factories.MemberAgreementFactory.create( + signed_agreement__is_primary=False + ) + GroupGroupMembershipFactory.create( + parent_group=self.cdsa_group, + child_group=agreement.signed_agreement.anvil_access_group, + ) + out = StringIO() + call_command( + "run_cdsa_audit", "--no-color", email="test@example.com", stdout=out + ) + expected_output = ( + "Running SignedAgreement access audit... problems found.\n" + "* Verified: 0\n" + "* Needs action: 0\n" + "* Errors: 1\n" + ) + self.assertIn(expected_output, out.getvalue()) + self.assertIn(reverse("cdsa:audit:signed_agreements:all"), out.getvalue()) + self.assertIn("Running CDSAWorkspace access audit... ok!", out.getvalue()) + # One message has been sent by default. + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertEqual(email.to, ["test@example.com"]) + self.assertEqual(email.subject, "CDSA SignedAgreementAccessAudit errors") + self.assertIn( + reverse("cdsa:audit:signed_agreements:all"), email.alternatives[0][0] + ) + + def test_command_run_audit_one_workspace_verified(self): + """Test command output with one verified instance.""" + factories.CDSAWorkspaceFactory.create() + out = StringIO() + call_command("run_cdsa_audit", "--no-color", stdout=out) + expected_output = ( + "Running SignedAgreement access audit... ok!\n" + "* Verified: 0\n" + "* Needs action: 0\n" + "* Errors: 0\n" + ) + self.assertIn(expected_output, out.getvalue()) + expected_output = ( + "Running CDSAWorkspace access audit... ok!\n" + "* Verified: 1\n" + "* Needs action: 0\n" + "* Errors: 0\n" + ) + self.assertIn(expected_output, out.getvalue()) + # Zero messages have been sent by default. + self.assertEqual(len(mail.outbox), 0) + + def test_command_run_audit_one_workspace_needs_action(self): + """Test command output with one needs_action instance.""" + agreement = factories.DataAffiliateAgreementFactory.create() + GroupGroupMembershipFactory.create( + parent_group=self.cdsa_group, + child_group=agreement.signed_agreement.anvil_access_group, + ) + factories.CDSAWorkspaceFactory.create(study=agreement.study) + out = StringIO() + call_command("run_cdsa_audit", "--no-color", stdout=out) + expected_output = ( + "Running CDSAWorkspace access audit... problems found.\n" + "* Verified: 0\n" + "* Needs action: 1\n" + "* Errors: 0\n" + ) + self.assertIn(expected_output, out.getvalue()) + self.assertIn(reverse("cdsa:audit:workspaces:all"), out.getvalue()) + self.assertIn("Running SignedAgreement access audit... ok!", out.getvalue()) + # Zero messages have been sent by default. + self.assertEqual(len(mail.outbox), 0) + + def test_command_run_audit_one_workspace_error(self): + """Test command output with one error instance.""" + workspace = factories.CDSAWorkspaceFactory.create() + GroupGroupMembershipFactory.create( + parent_group=workspace.workspace.authorization_domains.first(), + child_group=self.cdsa_group, + ) + out = StringIO() + call_command("run_cdsa_audit", "--no-color", stdout=out) + expected_output = ( + "Running CDSAWorkspace access audit... problems found.\n" + "* Verified: 0\n" + "* Needs action: 0\n" + "* Errors: 1\n" + ) + self.assertIn(expected_output, out.getvalue()) + self.assertIn(reverse("cdsa:audit:workspaces:all"), out.getvalue()) + self.assertIn("Running SignedAgreement access audit... ok!", out.getvalue()) + # Zero messages have been sent by default. + self.assertEqual(len(mail.outbox), 0) + + def test_command_run_audit_one_workspace_verified_email(self): + """No email is sent when there are no errors.""" + factories.CDSAWorkspaceFactory.create() + out = StringIO() + call_command( + "run_cdsa_audit", "--no-color", email="test@example.com", stdout=out + ) + self.assertIn("Running CDSAWorkspace access audit... ok!", out.getvalue()) + self.assertIn("Running SignedAgreement access audit... ok!", out.getvalue()) + # Zero messages have been sent by default. + self.assertEqual(len(mail.outbox), 0) + + def test_command_run_audit_one_workspace_needs_action_email(self): + """Email is sent for one needs_action instance.""" + agreement = factories.DataAffiliateAgreementFactory.create() + GroupGroupMembershipFactory.create( + parent_group=self.cdsa_group, + child_group=agreement.signed_agreement.anvil_access_group, + ) + factories.CDSAWorkspaceFactory.create(study=agreement.study) + out = StringIO() + call_command( + "run_cdsa_audit", "--no-color", email="test@example.com", stdout=out + ) + expected_output = ( + "Running CDSAWorkspace access audit... problems found.\n" + "* Verified: 0\n" + "* Needs action: 1\n" + "* Errors: 0\n" + ) + self.assertIn(expected_output, out.getvalue()) + self.assertIn("Running SignedAgreement access audit... ok!", out.getvalue()) + # One message has been sent by default. + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertEqual(email.to, ["test@example.com"]) + self.assertEqual(email.subject, "CDSA WorkspaceAccessAudit errors") + self.assertIn(reverse("cdsa:audit:workspaces:all"), email.alternatives[0][0]) + + def test_command_run_audit_one_workspace_error_email(self): + """Test command output with one error instance.""" + workspace = factories.CDSAWorkspaceFactory.create() + GroupGroupMembershipFactory.create( + parent_group=workspace.workspace.authorization_domains.first(), + child_group=self.cdsa_group, + ) + out = StringIO() + call_command( + "run_cdsa_audit", "--no-color", email="test@example.com", stdout=out + ) + expected_output = ( + "Running CDSAWorkspace access audit... problems found.\n" + "* Verified: 0\n" + "* Needs action: 0\n" + "* Errors: 1\n" + ) + self.assertIn(expected_output, out.getvalue()) + self.assertIn(reverse("cdsa:audit:workspaces:all"), out.getvalue()) + self.assertIn("Running SignedAgreement access audit... ok!", out.getvalue()) + # One message has been sent by default. + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertEqual(email.to, ["test@example.com"]) + self.assertEqual(email.subject, "CDSA WorkspaceAccessAudit errors") + self.assertIn(reverse("cdsa:audit:workspaces:all"), email.alternatives[0][0]) + + def test_signed_agreement_and_workspace_needs_action(self): + agreement = factories.DataAffiliateAgreementFactory.create() + factories.CDSAWorkspaceFactory.create(study=agreement.study) + out = StringIO() + call_command("run_cdsa_audit", "--no-color", stdout=out) + expected_output = ( + "Running CDSAWorkspace access audit... problems found.\n" + "* Verified: 0\n" + "* Needs action: 1\n" + "* Errors: 0\n" + ) + self.assertIn(expected_output, out.getvalue()) + expected_output = ( + "Running SignedAgreement access audit... problems found.\n" + "* Verified: 0\n" + "* Needs action: 1\n" + "* Errors: 0\n" + ) + self.assertIn(expected_output, out.getvalue()) + # No messages have been sent by default. + self.assertEqual(len(mail.outbox), 0) + + def test_signed_agreement_and_workspace_needs_action_email(self): + agreement = factories.DataAffiliateAgreementFactory.create() + factories.CDSAWorkspaceFactory.create(study=agreement.study) + out = StringIO() + call_command( + "run_cdsa_audit", "--no-color", email="test@example.com", stdout=out + ) + expected_output = ( + "Running CDSAWorkspace access audit... problems found.\n" + "* Verified: 0\n" + "* Needs action: 1\n" + "* Errors: 0\n" + ) + self.assertIn(expected_output, out.getvalue()) + expected_output = ( + "Running SignedAgreement access audit... problems found.\n" + "* Verified: 0\n" + "* Needs action: 1\n" + "* Errors: 0\n" + ) + self.assertIn(expected_output, out.getvalue()) + # Two messages has been sent. + self.assertEqual(len(mail.outbox), 2) + email = mail.outbox[0] + self.assertEqual(email.to, ["test@example.com"]) + self.assertEqual(email.subject, "CDSA SignedAgreementAccessAudit errors") + self.assertIn( + reverse("cdsa:audit:signed_agreements:all"), email.alternatives[0][0] + ) + email = mail.outbox[1] + self.assertEqual(email.to, ["test@example.com"]) + self.assertEqual(email.subject, "CDSA WorkspaceAccessAudit errors") + self.assertIn(reverse("cdsa:audit:workspaces:all"), email.alternatives[0][0]) + + def test_different_domain(self): + """Test command output when a different domain is specified.""" + site = Site.objects.create(domain="foobar.com", name="test") + site.save() + factories.MemberAgreementFactory.create() + with self.settings(SITE_ID=site.id): + out = StringIO() + call_command( + "run_cdsa_audit", "--no-color", email="test@example.com", stdout=out + ) + self.assertIn( + "Running SignedAgreement access audit... problems found.", + out.getvalue(), + ) + self.assertIn("https://foobar.com", out.getvalue()) diff --git a/primed/cdsa/tests/test_forms.py b/primed/cdsa/tests/test_forms.py index 7662e422..3ab4573f 100644 --- a/primed/cdsa/tests/test_forms.py +++ b/primed/cdsa/tests/test_forms.py @@ -1,6 +1,7 @@ """Tests for the `cdsa` app.""" from anvil_consortium_manager.tests.factories import WorkspaceFactory +from django.core.exceptions import NON_FIELD_ERRORS from django.test import TestCase from primed.duo.models import DataUseModifier @@ -416,6 +417,35 @@ def test_invalid_signed_agreement_wrong_type(self): self.assertEqual(len(form.errors["signed_agreement"]), 1) self.assertIn("expected type", form.errors["signed_agreement"][0]) + def test_valid_primary_with_additional_limitations(self): + """Form is valid with necessary input.""" + signed_agreement = factories.SignedAgreementFactory.create( + type=models.SignedAgreement.DATA_AFFILIATE, is_primary=True + ) + form_data = { + "signed_agreement": signed_agreement, + "study": self.study, + "additional_limitations": "test limitations", + } + form = self.form_class(data=form_data) + self.assertTrue(form.is_valid()) + + def test_invalid_component_with_additional_limitations(self): + """Form is valid with necessary input.""" + signed_agreement = factories.SignedAgreementFactory.create( + type=models.SignedAgreement.DATA_AFFILIATE, is_primary=False + ) + form_data = { + "signed_agreement": signed_agreement, + "study": self.study, + "additional_limitations": "test limitations", + } + form = self.form_class(data=form_data) + self.assertFalse(form.is_valid()) + self.assertIn(NON_FIELD_ERRORS, form.errors) + self.assertEqual(len(form.errors[NON_FIELD_ERRORS]), 1) + self.assertIn("only allowed for primary", form.errors[NON_FIELD_ERRORS][0]) + class NonDataAffiliateAgreementFormTest(TestCase): """Tests for the NonDataAffiliateAgreementForm class.""" @@ -511,7 +541,6 @@ def test_valid(self): "study": self.study, "requested_by": self.requester, "data_use_permission": self.duo_permission, - "data_use_limitations": "test limitations", "acknowledgments": "test acknowledgmnts", "gsr_restricted": False, } @@ -526,7 +555,6 @@ def test_valid_with_one_data_use_modifier(self): "requested_by": self.requester, "data_use_permission": self.duo_permission, "data_use_modifier": DataUseModifier.objects.all(), - "data_use_limitations": "test limitations", "acknowledgments": "test acknowledgmnts", "gsr_restricted": False, } @@ -541,7 +569,6 @@ def test_valid_with_two_data_use_modifiers(self): "requested_by": self.requester, "data_use_permission": self.duo_permission, "data_use_modifier": DataUseModifier.objects.all(), - "data_use_limitations": "test limitations", "acknowledgments": "test acknowledgmnts", "gsr_restricted": False, } @@ -555,7 +582,6 @@ def test_invalid_missing_workspace(self): "study": self.study, "requested_by": self.requester, "data_use_permission": self.duo_permission, - "data_use_limitations": "test limitations", "acknowledgments": "test acknowledgmnts", "gsr_restricted": False, } @@ -573,7 +599,6 @@ def test_invalid_missing_study(self): # "study": self.study, "requested_by": self.requester, "data_use_permission": self.duo_permission, - "data_use_limitations": "test limitations", "acknowledgments": "test acknowledgmnts", "gsr_restricted": False, } @@ -591,7 +616,6 @@ def test_invalid_missing_requested_by(self): "study": self.study, # "requested_by": self.requester, "data_use_permission": self.duo_permission, - "data_use_limitations": "test limitations", "acknowledgments": "test acknowledgmnts", "gsr_restricted": False, } @@ -609,7 +633,6 @@ def test_invalid_missing_data_use_permission(self): "study": self.study, "requested_by": self.requester, # "data_use_permission": self.duo_permission, - "data_use_limitations": "test limitations", "acknowledgments": "test acknowledgmnts", "gsr_restricted": False, } @@ -620,23 +643,20 @@ def test_invalid_missing_data_use_permission(self): self.assertEqual(len(form.errors["data_use_permission"]), 1) self.assertIn("required", form.errors["data_use_permission"][0]) - def test_invalid_missing_data_use_limitations(self): + def test_valid_additional_limitations(self): """Form is invalid when missing data_use_limitations.""" form_data = { "workspace": self.workspace, "study": self.study, "requested_by": self.requester, "data_use_permission": self.duo_permission, - # "data_use_limitations": "test limitations", + "additional_limitations": "test limitations", "acknowledgments": "test acknowledgmnts", "gsr_restricted": False, } form = self.form_class(data=form_data) - self.assertFalse(form.is_valid()) - self.assertEqual(len(form.errors), 1) - self.assertIn("data_use_limitations", form.errors) - self.assertEqual(len(form.errors["data_use_limitations"]), 1) - self.assertIn("required", form.errors["data_use_limitations"][0]) + self.assertTrue(form.is_valid()) + self.assertEqual(form.instance.additional_limitations, "test limitations") def test_invalid_missing_acknowledgments(self): """Form is invalid when missing acknowledgments.""" @@ -645,7 +665,6 @@ def test_invalid_missing_acknowledgments(self): "study": self.study, "requested_by": self.requester, "data_use_permission": self.duo_permission, - "data_use_limitations": "test limitations", # "acknowledgments": "test acknowledgmnts", "gsr_restricted": False, } @@ -663,7 +682,6 @@ def test_invalid_missing_gsr_restricted(self): "study": self.study, "requested_by": self.requester, "data_use_permission": self.duo_permission, - "data_use_limitations": "test limitations", "acknowledgments": "test acknowledgmnts", # "gsr_restricted": False, } @@ -682,7 +700,6 @@ def test_invalid_duplicate_workspace(self): "study": self.study, "requested_by": self.requester, "data_use_permission": self.duo_permission, - "data_use_limitations": "test limitations", "acknowledgments": "test acknowledgmnts", "gsr_restricted": False, } @@ -701,7 +718,6 @@ def test_valid_one_available_data(self): "study": self.study, "requested_by": self.requester, "data_use_permission": self.duo_permission, - "data_use_limitations": "test limitations", "acknowledgments": "test acknowledgmnts", "available_data": [available_data], "gsr_restricted": False, @@ -717,7 +733,6 @@ def test_valid_two_available_data(self): "study": self.study, "requested_by": self.requester, "data_use_permission": self.duo_permission, - "data_use_limitations": "test limitations", "acknowledgments": "test acknowledgmnts", "available_data": available_data, "gsr_restricted": False, diff --git a/primed/cdsa/tests/test_models.py b/primed/cdsa/tests/test_models.py index 0e41a4f5..98b9858f 100644 --- a/primed/cdsa/tests/test_models.py +++ b/primed/cdsa/tests/test_models.py @@ -506,6 +506,22 @@ def test_clean_incorrect_type(self): e.exception.error_dict["signed_agreement"][0].messages[0], ) + def test_clean_additional_limitations_primary(self): + instance = factories.DataAffiliateAgreementFactory.create( + signed_agreement__is_primary=True, + additional_limitations="foo bar", + ) + instance.full_clean() + + def test_clean_additional_limitations_not_primary(self): + instance = factories.DataAffiliateAgreementFactory.create( + signed_agreement__is_primary=False, + additional_limitations="foo bar", + ) + with self.assertRaises(ValidationError) as e: + instance.clean() + self.assertIn("only allowed for primary agreements", e.exception.message) + def test_str_method(self): """The custom __str__ method returns the correct string.""" instance = factories.DataAffiliateAgreementFactory.create() @@ -616,7 +632,6 @@ def test_model_saving(self): requester = UserFactory.create() instance = models.CDSAWorkspace( study=study, - data_use_limitations="test limitations", acknowledgments="test acknowledgments", requested_by=requester, workspace=workspace, @@ -657,3 +672,64 @@ def test_available_data(self): instance.available_data.add(*available_data) self.assertIn(available_data[0], instance.available_data.all()) self.assertIn(available_data[1], instance.available_data.all()) + + def test_additional_limitations(self): + """Can have additional_limitations set.""" + instance = factories.CDSAWorkspaceFactory.create(additional_limitations="foo") + self.assertEqual(instance.additional_limitations, "foo") + + def test_get_primary_cdsa(self): + """get_primary_cdsa returns the primary valid CDSA for the study.""" + instance = factories.CDSAWorkspaceFactory.create() + agreement = factories.DataAffiliateAgreementFactory.create( + signed_agreement__is_primary=True, + signed_agreement__status=models.SignedAgreement.StatusChoices.ACTIVE, + study=instance.study, + ) + self.assertEqual(instance.get_primary_cdsa(), agreement) + + def test_get_primary_cdsa_not_primary(self): + instance = factories.CDSAWorkspaceFactory.create() + factories.DataAffiliateAgreementFactory.create( + signed_agreement__is_primary=False, + signed_agreement__status=models.SignedAgreement.StatusChoices.ACTIVE, + study=instance.study, + ) + with self.assertRaises(models.DataAffiliateAgreement.DoesNotExist): + instance.get_primary_cdsa() + + def test_get_primary_cdsa_not_active(self): + instance = factories.CDSAWorkspaceFactory.create() + factories.DataAffiliateAgreementFactory.create( + signed_agreement__is_primary=True, + signed_agreement__status=models.SignedAgreement.StatusChoices.LAPSED, + study=instance.study, + ) + with self.assertRaises(models.DataAffiliateAgreement.DoesNotExist): + instance.get_primary_cdsa() + + def test_get_primary_cdsa_different_study(self): + instance = factories.CDSAWorkspaceFactory.create() + factories.DataAffiliateAgreementFactory.create( + signed_agreement__is_primary=True, + signed_agreement__status=models.SignedAgreement.StatusChoices.ACTIVE, + # study=instance.study, + ) + with self.assertRaises(models.DataAffiliateAgreement.DoesNotExist): + instance.get_primary_cdsa() + + def test_get_primary_cdsa_multiple_agreements(self): + """get_primary_cdsa returns the primary valid CDSA for the study.""" + instance = factories.CDSAWorkspaceFactory.create() + factories.DataAffiliateAgreementFactory.create( + signed_agreement__is_primary=True, + signed_agreement__status=models.SignedAgreement.StatusChoices.ACTIVE, + study=instance.study, + ) + factories.DataAffiliateAgreementFactory.create( + signed_agreement__is_primary=True, + signed_agreement__status=models.SignedAgreement.StatusChoices.ACTIVE, + study=instance.study, + ) + with self.assertRaises(models.DataAffiliateAgreement.MultipleObjectsReturned): + instance.get_primary_cdsa() diff --git a/primed/cdsa/tests/test_views.py b/primed/cdsa/tests/test_views.py index 130c10af..77116569 100644 --- a/primed/cdsa/tests/test_views.py +++ b/primed/cdsa/tests/test_views.py @@ -4007,6 +4007,29 @@ def test_change_status_button_user_has_view_perm(self): ), ) + def test_response_includes_additional_limitations(self): + """Response includes a link to the study detail page.""" + instance = factories.DataAffiliateAgreementFactory.create( + signed_agreement__is_primary=True, + additional_limitations="Test limitations for this data affiliate agreement", + ) + self.client.force_login(self.user) + response = self.client.get(self.get_url(instance.signed_agreement.cc_id)) + self.assertContains(response, "Additional limitations") + self.assertContains( + response, "Test limitations for this data affiliate agreement" + ) + + def test_response_with_no_additional_limitations(self): + """Response includes a link to the study detail page.""" + instance = factories.DataAffiliateAgreementFactory.create( + signed_agreement__is_primary=True, + additional_limitations="", + ) + self.client.force_login(self.user) + response = self.client.get(self.get_url(instance.signed_agreement.cc_id)) + self.assertNotContains(response, "Additional limitations") + class DataAffiliateAgreementListTest(TestCase): """Tests for the DataAffiliateAgreement view.""" @@ -7267,9 +7290,6 @@ def test_associated_data_prep_workspaces_context_exists(self): self.client.force_login(self.user) response = self.client.get(obj.get_absolute_url()) self.assertIn("associated_data_prep_workspaces", response.context_data) - import ipdb - - ipdb.set_trace() self.assertIsInstance( response.context_data["associated_data_prep_workspaces"], DataPrepWorkspaceTable, @@ -7354,6 +7374,75 @@ def test_context_data_prep_active_with_one_active_one_inactive_prep_workspace(se self.assertIn("data_prep_active", response.context_data) self.assertTrue(response.context["data_prep_active"]) + def test_response_context_primary_cdsa(self): + agreement = factories.DataAffiliateAgreementFactory.create( + signed_agreement__is_primary=True, + ) + instance = factories.CDSAWorkspaceFactory.create( + study=agreement.study, + ) + self.client.force_login(self.user) + response = self.client.get(instance.get_absolute_url()) + self.assertIn("primary_cdsa", response.context) + self.assertEqual(response.context["primary_cdsa"], agreement) + + def test_response_includes_additional_limitations(self): + """Response includes DataAffiliate additional limitations if they exist.""" + agreement = factories.DataAffiliateAgreementFactory.create( + signed_agreement__is_primary=True, + additional_limitations="Test limitations for this data affiliate agreement", + ) + instance = factories.CDSAWorkspaceFactory.create( + study=agreement.study, + ) + self.client.force_login(self.user) + response = self.client.get(instance.get_absolute_url()) + self.assertContains( + response, "Test limitations for this data affiliate agreement" + ) + + def test_response_data_use_limitations(self): + """All data use limitations appear in the response content.""" + instance = factories.CDSAWorkspaceFactory.create( + data_use_permission__definition="Test permission.", + data_use_permission__abbreviation="P", + additional_limitations="Test additional limitations for workspace", + ) + modifier_1 = DataUseModifierFactory.create( + abbreviation="M1", definition="Test modifier 1." + ) + modifier_2 = DataUseModifierFactory.create( + abbreviation="M2", definition="Test modifier 2." + ) + instance.data_use_modifiers.add(modifier_1, modifier_2) + # Create an agreement with data use limitations. + factories.DataAffiliateAgreementFactory.create( + signed_agreement__is_primary=True, + study=instance.study, + additional_limitations="Test limitations for this data affiliate agreement", + ) + self.client.force_login(self.user) + response = self.client.get(instance.get_absolute_url()) + self.assertContains(response, "
  • P: Test permission.
  • ") + self.assertContains(response, "
  • M1: Test modifier 1.
  • ") + self.assertContains(response, "
  • M2: Test modifier 2.
  • ") + self.assertContains( + response, + "
    Additional limitations from CDSA
    ", + ) + self.assertContains( + response, + "
  • Test limitations for this data affiliate agreement
  • ", + ) + self.assertContains( + response, + "
    Additional limitations for this consent group
    ", + ) + self.assertContains( + response, + "
  • Test additional limitations for workspace
  • ", + ) + class CDSAWorkspaceCreateTest(AnVILAPIMockTestMixin, TestCase): """Tests of the WorkspaceCreate view from ACM with this app's CDSAWorkspace model.""" @@ -7415,7 +7504,6 @@ def test_creates_upload_workspace_without_duos(self): "workspacedata-MAX_NUM_FORMS": 1, "workspacedata-0-study": study.pk, "workspacedata-0-data_use_permission": duo_permission.pk, - "workspacedata-0-data_use_limitations": "test limitations", "workspacedata-0-acknowledgments": "test acknowledgments", "workspacedata-0-requested_by": self.requester.pk, "workspacedata-0-gsr_restricted": False, @@ -7430,7 +7518,6 @@ def test_creates_upload_workspace_without_duos(self): self.assertEqual(new_workspace_data.workspace, new_workspace) self.assertEqual(new_workspace_data.study, study) self.assertEqual(new_workspace_data.data_use_permission, duo_permission) - self.assertEqual(new_workspace_data.data_use_limitations, "test limitations") self.assertEqual(new_workspace_data.acknowledgments, "test acknowledgments") self.assertEqual(new_workspace_data.requested_by, self.requester) @@ -7467,7 +7554,6 @@ def test_creates_upload_workspace_with_duo_modifiers(self): "workspacedata-MIN_NUM_FORMS": 1, "workspacedata-MAX_NUM_FORMS": 1, "workspacedata-0-study": study.pk, - "workspacedata-0-data_use_limitations": "test limitations", "workspacedata-0-acknowledgments": "test acknowledgments", "workspacedata-0-data_use_permission": data_use_permission.pk, "workspacedata-0-data_use_modifiers": [ @@ -7516,7 +7602,6 @@ def test_creates_upload_workspace_with_disease_term(self): "workspacedata-MIN_NUM_FORMS": 1, "workspacedata-MAX_NUM_FORMS": 1, "workspacedata-0-study": study.pk, - "workspacedata-0-data_use_limitations": "test limitations", "workspacedata-0-acknowledgments": "test acknowledgments", "workspacedata-0-data_use_permission": data_use_permission.pk, "workspacedata-0-disease_term": "foo", diff --git a/primed/collaborative_analysis/audit.py b/primed/collaborative_analysis/audit.py index 6d3c0438..e4764e6f 100644 --- a/primed/collaborative_analysis/audit.py +++ b/primed/collaborative_analysis/audit.py @@ -10,13 +10,14 @@ ) from django.urls import reverse +from primed.primed_anvil.audit import PRIMEDAudit, PRIMEDAuditResult from primed.primed_anvil.tables import BooleanIconColumn from . import models @dataclass -class AccessAuditResult: +class AccessAuditResult(PRIMEDAuditResult): """Base class to hold the result of an access audit for a CollaborativeAnalysisWorkspace.""" collaborative_analysis_workspace: models.CollaborativeAnalysisWorkspace @@ -105,7 +106,7 @@ class Meta: attrs = {"class": "table align-middle"} -class CollaborativeAnalysisWorkspaceAccessAudit: +class CollaborativeAnalysisWorkspaceAccessAudit(PRIMEDAudit): """Class to audit access to a CollaborativeAnalysisWorkspace.""" # Allowed reasons for access. @@ -124,6 +125,7 @@ class CollaborativeAnalysisWorkspaceAccessAudit: UNEXPECTED_GROUP_ACCESS = "Unexpected group added to the auth domain." ALLOWED_GROUP_NAMES = ("PRIMED_CC_WRITERS",) + results_table_class = AccessAuditResultsTable def __init__(self, queryset=None): @@ -132,13 +134,10 @@ def __init__(self, queryset=None): Args: queryset: A queryset of CollaborativeAnalysisWorkspaces to audit. """ + super().__init__() if queryset is None: queryset = models.CollaborativeAnalysisWorkspace.objects.all() self.queryset = queryset - self.verified = [] - self.needs_action = [] - self.errors = [] - self.completed = False def _audit_workspace(self, workspace): """Audit access to a single CollaborativeAnalysisWorkspace.""" @@ -164,6 +163,8 @@ def _audit_workspace(self, workspace): # Loop over remaining accounts in the auth domain. for account in auth_domain_membership: + # Should this be an error, or a needs_action? + # eg if an analyst is removed on purpose, it should be needs_action. self.errors.append( RemoveAccess( collaborative_analysis_workspace=workspace, @@ -328,27 +329,7 @@ def _audit_workspace_and_account(self, collaborative_analysis_workspace, account ) ) - def run_audit(self): + def _run_audit(self): """Run the audit on the set of workspaces.""" for workspace in self.queryset: self._audit_workspace(workspace) - self.completed = True - - def get_all_results(self): - return self.verified + self.needs_action + self.errors - - 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/management/__init__.py b/primed/collaborative_analysis/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/primed/collaborative_analysis/management/commands/__init__.py b/primed/collaborative_analysis/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/primed/collaborative_analysis/management/commands/run_collaborative_analysis_audit.py b/primed/collaborative_analysis/management/commands/run_collaborative_analysis_audit.py new file mode 100644 index 00000000..e92790c0 --- /dev/null +++ b/primed/collaborative_analysis/management/commands/run_collaborative_analysis_audit.py @@ -0,0 +1,66 @@ +from django.contrib.sites.models import Site +from django.core.mail import send_mail +from django.core.management.base import BaseCommand +from django.template.loader import render_to_string +from django.urls import reverse + +from ... import audit + + +class Command(BaseCommand): + help = "Run Collaborative analysis access audit." + + def add_arguments(self, parser): + email_group = parser.add_argument_group(title="Email reports") + email_group.add_argument( + "--email", + help="""Email to which to send access reports that need action or have errors.""", + ) + + def handle(self, *args, **options): + self.stdout.write("Running Collaborative analysis access audit... ", ending="") + data_access_audit = audit.CollaborativeAnalysisWorkspaceAccessAudit() + data_access_audit.run_audit() + + # Report errors and needs access. + audit_ok = data_access_audit.ok() + # Construct the url for handling errors. + url = ( + "https://" + Site.objects.get_current().domain + reverse("dbgap:audit:all") + ) + if audit_ok: + self.stdout.write(self.style.SUCCESS("ok!")) + else: + self.stdout.write(self.style.ERROR("problems found.")) + + # Print results + self.stdout.write("* Verified: {}".format(len(data_access_audit.verified))) + self.stdout.write( + "* Needs action: {}".format(len(data_access_audit.needs_action)) + ) + self.stdout.write("* Errors: {}".format(len(data_access_audit.errors))) + + if not audit_ok: + self.stdout.write( + self.style.ERROR(f"Please visit {url} to resolve these issues.") + ) + + # Send email if requested and there are problems. + email = options["email"] + subject = "Collaborative analysis access audit - problems found" + html_body = render_to_string( + "primed_anvil/email_audit_report.html", + context={ + "title": "Collaborative analysis access audit", + "data_access_audit": data_access_audit, + "url": url, + }, + ) + send_mail( + subject, + "Audit problems found. Please see attached report.", + None, + [email], + fail_silently=False, + html_message=html_body, + ) diff --git a/primed/collaborative_analysis/tests/test_commands.py b/primed/collaborative_analysis/tests/test_commands.py new file mode 100644 index 00000000..2fa3be81 --- /dev/null +++ b/primed/collaborative_analysis/tests/test_commands.py @@ -0,0 +1,192 @@ +"""Tests for management commands in the `dbgap` app.""" + +from io import StringIO + +from anvil_consortium_manager.tests.factories import ( + GroupAccountMembershipFactory, + GroupGroupMembershipFactory, +) +from django.contrib.sites.models import Site +from django.core import mail +from django.core.management import call_command +from django.test import TestCase + +from primed.dbgap.tests.factories import dbGaPWorkspaceFactory +from primed.miscellaneous_workspaces.tests.factories import OpenAccessWorkspaceFactory + +from . import factories + + +class RunCollaborativeAnalysisAuditTest(TestCase): + """Tests for the run_collaborative_analysis_audit command""" + + def test_command_output_no_records(self): + """Test command output.""" + out = StringIO() + call_command("run_collaborative_analysis_audit", "--no-color", stdout=out) + self.assertIn( + "Running Collaborative analysis access audit... ok!", out.getvalue() + ) + self.assertIn("* Verified: 0", out.getvalue()) + self.assertIn("* Needs action: 0", out.getvalue()) + self.assertIn("* Errors: 0", out.getvalue()) + # Zero messages have been sent by default. + self.assertEqual(len(mail.outbox), 0) + + def test_command_run_audit_one_instance_verified(self): + """Test command output with one verified instance.""" + source_workspace = dbGaPWorkspaceFactory.create() + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + workspace.source_workspaces.add(source_workspace.workspace) + # One analyst without access. + GroupAccountMembershipFactory.create(group=workspace.analyst_group) + out = StringIO() + call_command("run_collaborative_analysis_audit", "--no-color", stdout=out) + self.assertIn( + "Running Collaborative analysis access audit... ok!", out.getvalue() + ) + self.assertIn("* Verified: 1", out.getvalue()) + self.assertIn("* Needs action: 0", out.getvalue()) + self.assertIn("* Errors: 0", out.getvalue()) + # Zero messages have been sent by default. + self.assertEqual(len(mail.outbox), 0) + + def test_command_run_audit_one_instance_needs_action(self): + """Test command output with one needs_action instance.""" + source_workspace = OpenAccessWorkspaceFactory.create() + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + workspace.source_workspaces.add(source_workspace.workspace) + # One analyst with access. + GroupAccountMembershipFactory.create(group=workspace.analyst_group) + out = StringIO() + call_command("run_collaborative_analysis_audit", "--no-color", stdout=out) + self.assertIn( + "Running Collaborative analysis access audit... problems found.", + out.getvalue(), + ) + self.assertIn("* Verified: 0", out.getvalue()) + self.assertIn("* Needs action: 1", out.getvalue()) + self.assertIn("* Errors: 0", out.getvalue()) + # Zero messages have been sent by default. + self.assertEqual(len(mail.outbox), 0) + + def test_command_run_audit_one_instance_error(self): + """Test command output with one error instance.""" + source_workspace = OpenAccessWorkspaceFactory.create() + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + workspace.source_workspaces.add(source_workspace.workspace) + # One group with unexpected access. + GroupGroupMembershipFactory.create( + parent_group=workspace.workspace.authorization_domains.first() + ) + out = StringIO() + call_command("run_collaborative_analysis_audit", "--no-color", stdout=out) + self.assertIn( + "Running Collaborative analysis access audit... problems found.", + out.getvalue(), + ) + self.assertIn("* Verified: 0", out.getvalue()) + self.assertIn("* Needs action: 0", out.getvalue()) + self.assertIn("* Errors: 1", out.getvalue()) + # Zero messages have been sent by default. + self.assertEqual(len(mail.outbox), 0) + + def test_command_run_audit_one_instance_verified_email(self): + """No email is sent when there are no errors.""" + source_workspace = dbGaPWorkspaceFactory.create() + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + workspace.source_workspaces.add(source_workspace.workspace) + # One analyst without access. + GroupAccountMembershipFactory.create(group=workspace.analyst_group) + out = StringIO() + call_command( + "run_collaborative_analysis_audit", + "--no-color", + email="test@example.com", + stdout=out, + ) + self.assertIn( + "Running Collaborative analysis access audit... ok!", out.getvalue() + ) + # Zero messages have been sent by default. + self.assertEqual(len(mail.outbox), 0) + + def test_command_run_audit_one_instance_needs_action_email(self): + """Email is sent for one needs_action instance.""" + # Create a workspace and matching DAR. + source_workspace = OpenAccessWorkspaceFactory.create() + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + workspace.source_workspaces.add(source_workspace.workspace) + # One analyst with access. + GroupAccountMembershipFactory.create(group=workspace.analyst_group) + out = StringIO() + call_command( + "run_collaborative_analysis_audit", + "--no-color", + email="test@example.com", + stdout=out, + ) + self.assertIn( + "Running Collaborative analysis access audit... problems found.", + out.getvalue(), + ) + self.assertIn("* Verified: 0", out.getvalue()) + self.assertIn("* Needs action: 1", out.getvalue()) + self.assertIn("* Errors: 0", out.getvalue()) + # One message has been sent by default. + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertEqual(email.to, ["test@example.com"]) + self.assertEqual( + email.subject, "Collaborative analysis access audit - problems found" + ) + + def test_command_run_audit_one_instance_error_email(self): + """Test command output with one error instance.""" + # Create a workspace and matching DAR. + source_workspace = OpenAccessWorkspaceFactory.create() + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + workspace.source_workspaces.add(source_workspace.workspace) + # One group with unexpected access. + GroupGroupMembershipFactory.create( + parent_group=workspace.workspace.authorization_domains.first() + ) + out = StringIO() + call_command( + "run_collaborative_analysis_audit", + "--no-color", + email="test@example.com", + stdout=out, + ) + self.assertIn( + "Running Collaborative analysis access audit... problems found.", + out.getvalue(), + ) + self.assertIn("* Verified: 0", out.getvalue()) + self.assertIn("* Needs action: 0", out.getvalue()) + self.assertIn("* Errors: 1", out.getvalue()) + # One message has been sent by default. + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertEqual(email.to, ["test@example.com"]) + self.assertEqual( + email.subject, "Collaborative analysis access audit - problems found" + ) + + def test_different_domain(self): + """Test command output when a different domain is specified.""" + site = Site.objects.create(domain="foobar.com", name="test") + site.save() + with self.settings(SITE_ID=site.id): + source_workspace = OpenAccessWorkspaceFactory.create() + workspace = factories.CollaborativeAnalysisWorkspaceFactory.create() + workspace.source_workspaces.add(source_workspace.workspace) + # One analyst with access. + GroupAccountMembershipFactory.create(group=workspace.analyst_group) + out = StringIO() + call_command("run_collaborative_analysis_audit", "--no-color", stdout=out) + self.assertIn( + "Running Collaborative analysis access audit... problems found.", + out.getvalue(), + ) + self.assertIn("https://foobar.com", out.getvalue()) diff --git a/primed/collaborative_analysis/views.py b/primed/collaborative_analysis/views.py index 8b175625..fe3e08f2 100644 --- a/primed/collaborative_analysis/views.py +++ b/primed/collaborative_analysis/views.py @@ -144,6 +144,8 @@ def get_audit_result(self): instance._audit_workspace_and_group( self.collaborative_analysis_workspace, self.member ) + # Set to completed, because we are just running this one specific check. + instance.completed = True return instance.get_all_results()[0] def get(self, request, *args, **kwargs): diff --git a/primed/dbgap/audit.py b/primed/dbgap/audit.py index f13d44da..d76eb26f 100644 --- a/primed/dbgap/audit.py +++ b/primed/dbgap/audit.py @@ -4,7 +4,7 @@ from django.db.models import QuerySet from django.urls import reverse -# from . import models +from primed.primed_anvil.audit import PRIMEDAudit, PRIMEDAuditResult from primed.primed_anvil.tables import BooleanIconColumn from .models import ( @@ -17,7 +17,7 @@ # Dataclasses for storing audit results? @dataclass -class AuditResult: +class AuditResult(PRIMEDAuditResult): """Base class to hold results for auditing dbGaP workspace access for a dbGaPDataAccessSnapshot.""" workspace: dbGaPWorkspace @@ -136,7 +136,7 @@ class Meta: attrs = {"class": "table align-middle"} -class dbGaPAccessAudit: +class dbGaPAccessAudit(PRIMEDAudit): # Access verified. APPROVED_DAR = "Approved DAR." @@ -157,11 +157,7 @@ class dbGaPAccessAudit: results_table_class = dbGaPAccessAuditTable def __init__(self, dbgap_application_queryset=None, dbgap_workspace_queryset=None): - self.completed = False - # Set up lists to hold audit results. - self.verified = None - self.needs_action = None - self.errors = None + super().__init__() if dbgap_application_queryset is None: dbgap_application_queryset = dbGaPApplication.objects.all() if not ( @@ -183,15 +179,10 @@ def __init__(self, dbgap_application_queryset=None, dbgap_workspace_queryset=Non ) self.dbgap_workspace_queryset = dbgap_workspace_queryset - def run_audit(self): - self.verified = [] - self.needs_action = [] - self.errors = [] - + def _run_audit(self): for dbgap_application in self.dbgap_application_queryset: for dbgap_workspace in self.dbgap_workspace_queryset: self.audit_application_and_workspace(dbgap_application, dbgap_workspace) - self.completed = True def audit_application_and_workspace(self, dbgap_application, dbgap_workspace): """Audit access for a specific dbGaP application and a specific workspace.""" @@ -330,22 +321,3 @@ def audit_application_and_workspace(self, dbgap_application, dbgap_workspace): note=self.DAR_NOT_APPROVED, ) ) - - def get_all_results(self): - return self.verified + self.needs_action + self.errors - - 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/dbgap/management/__init__.py b/primed/dbgap/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/primed/dbgap/management/commands/__init__.py b/primed/dbgap/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/primed/dbgap/management/commands/run_dbgap_audit.py b/primed/dbgap/management/commands/run_dbgap_audit.py new file mode 100644 index 00000000..57089930 --- /dev/null +++ b/primed/dbgap/management/commands/run_dbgap_audit.py @@ -0,0 +1,66 @@ +from django.contrib.sites.models import Site +from django.core.mail import send_mail +from django.core.management.base import BaseCommand +from django.template.loader import render_to_string +from django.urls import reverse + +from ... import audit + + +class Command(BaseCommand): + help = "Run dbGaP access audit." + + def add_arguments(self, parser): + email_group = parser.add_argument_group(title="Email reports") + email_group.add_argument( + "--email", + help="""Email to which to send access reports that need action or have errors.""", + ) + + def handle(self, *args, **options): + self.stdout.write("Running dbGaP access audit... ", ending="") + data_access_audit = audit.dbGaPAccessAudit() + data_access_audit.run_audit() + + # Report errors and needs access. + audit_ok = data_access_audit.ok() + # Construct the url for handling errors. + url = ( + "https://" + Site.objects.get_current().domain + reverse("dbgap:audit:all") + ) + if audit_ok: + self.stdout.write(self.style.SUCCESS("ok!")) + else: + self.stdout.write(self.style.ERROR("problems found.")) + + # Print results + self.stdout.write("* Verified: {}".format(len(data_access_audit.verified))) + self.stdout.write( + "* Needs action: {}".format(len(data_access_audit.needs_action)) + ) + self.stdout.write("* Errors: {}".format(len(data_access_audit.errors))) + + if not audit_ok: + self.stdout.write( + self.style.ERROR(f"Please visit {url} to resolve these issues.") + ) + + # Send email if requested and there are problems. + email = options["email"] + subject = "dbGaP access audit - problems found" + html_body = render_to_string( + "primed_anvil/email_audit_report.html", + context={ + "title": "dbGaP access audit", + "data_access_audit": data_access_audit, + "url": url, + }, + ) + send_mail( + subject, + "Audit problems found. Please see attached report.", + None, + [email], + fail_silently=False, + html_message=html_body, + ) diff --git a/primed/dbgap/tests/test_audit.py b/primed/dbgap/tests/test_audit.py index 0a8f3e72..f2699c6a 100644 --- a/primed/dbgap/tests/test_audit.py +++ b/primed/dbgap/tests/test_audit.py @@ -241,6 +241,7 @@ def test_verified_access(self): record.dbgap_application, dar.dbgap_data_access_snapshot.dbgap_application ) self.assertEqual(record.data_access_request, dar) + self.assertTrue(dbgap_audit.ok()) def test_two_workspaces(self): """run_audit with one application and two workspaces with different access.""" @@ -301,6 +302,7 @@ def test_verified_no_access_dar_not_approved(self): ) self.assertEqual(record.data_access_request, dar) self.assertEqual(record.note, audit.dbGaPAccessAudit.DAR_NOT_APPROVED) + self.assertTrue(dbgap_audit.ok()) def test_verified_no_access_no_dar(self): """run_audit with one application and one workspace that has verified no access.""" @@ -322,6 +324,7 @@ def test_verified_no_access_no_dar(self): self.assertEqual(record.dbgap_application, dbgap_application) self.assertIsNone(record.data_access_request) self.assertEqual(record.note, audit.dbGaPAccessAudit.NO_DAR) + self.assertTrue(dbgap_audit.ok()) def test_grant_access_new_approved_dar(self): # Create a workspace and matching DAR. @@ -347,6 +350,7 @@ def test_grant_access_new_approved_dar(self): ) self.assertEqual(record.data_access_request, dar) self.assertEqual(record.note, audit.dbGaPAccessAudit.NEW_APPROVED_DAR) + self.assertFalse(dbgap_audit.ok()) def test_grant_access_new_workspace(self): # Create a workspace and matching DAR. @@ -372,6 +376,7 @@ def test_grant_access_new_workspace(self): ) self.assertEqual(record.data_access_request, dar) self.assertEqual(record.note, audit.dbGaPAccessAudit.NEW_WORKSPACE) + self.assertFalse(dbgap_audit.ok()) def test_grant_access_updated_dar(self): # Create a workspace and matching DAR. @@ -408,6 +413,7 @@ def test_grant_access_updated_dar(self): ) self.assertEqual(record.data_access_request, dar) self.assertEqual(record.note, audit.dbGaPAccessAudit.NEW_APPROVED_DAR) + self.assertFalse(dbgap_audit.ok()) def test_remove_access_udpated_dar(self): # Create a workspace and matching DAR. @@ -446,6 +452,7 @@ def test_remove_access_udpated_dar(self): ) self.assertEqual(record.data_access_request, dar) self.assertEqual(record.note, audit.dbGaPAccessAudit.PREVIOUS_APPROVAL) + self.assertFalse(dbgap_audit.ok()) def test_error_remove_access_unknown_reason(self): """Access needs to be removed for an unknown reason.""" @@ -484,6 +491,7 @@ def test_error_remove_access_unknown_reason(self): self.assertEqual(record.dbgap_application, dbgap_application) self.assertEqual(record.data_access_request, dar) self.assertEqual(record.note, audit.dbGaPAccessAudit.ERROR_HAS_ACCESS) + self.assertFalse(dbgap_audit.ok()) def test_error_remove_access_no_snapshot(self): """Access needs to be removed for an unknown reason when there is no snapshot.""" @@ -506,6 +514,7 @@ def test_error_remove_access_no_snapshot(self): self.assertEqual(record.dbgap_application, dbgap_application) self.assertIsNone(record.data_access_request) self.assertEqual(record.note, audit.dbGaPAccessAudit.ERROR_HAS_ACCESS) + self.assertFalse(dbgap_audit.ok()) def test_error_remove_access_snapshot_no_dar(self): """Group has access but there is no matching DAR.""" @@ -528,6 +537,7 @@ def test_error_remove_access_snapshot_no_dar(self): self.assertEqual(record.dbgap_application, snapshot.dbgap_application) self.assertIsNone(record.data_access_request) self.assertEqual(record.note, audit.dbGaPAccessAudit.ERROR_HAS_ACCESS) + self.assertFalse(dbgap_audit.ok()) def test_two_applications(self): """run_audit with two applications and one workspace.""" @@ -645,6 +655,66 @@ def test_dbgap_application_queryset_not_queryset(self): def test_two_applications_two_workspaces(self): pass + def test_ok_with_verified_and_needs_action(self): + # Create a workspace and matching DAR. + dbgap_workspace = factories.dbGaPWorkspaceFactory.create() + other_dar = factories.dbGaPDataAccessRequestForWorkspaceFactory.create( + dbgap_workspace=dbgap_workspace + ) + # Add the anvil group to the auth group for the workspace. + GroupGroupMembershipFactory( + parent_group=dbgap_workspace.workspace.authorization_domains.first(), + child_group=other_dar.dbgap_data_access_snapshot.dbgap_application.anvil_access_group, + ) + factories.dbGaPDataAccessRequestForWorkspaceFactory.create( + dbgap_workspace=dbgap_workspace + ) + # # Add the anvil group to the auth group for the workspace. + # GroupGroupMembershipFactory( + # parent_group=dbgap_workspace.workspace.authorization_domains.first(), + # child_group=dar.dbgap_data_access_snapshot.dbgap_application.anvil_access_group, + # ) + dbgap_audit = audit.dbGaPAccessAudit() + dbgap_audit.run_audit() + self.assertEqual(len(dbgap_audit.verified), 1) + self.assertEqual(len(dbgap_audit.needs_action), 1) + self.assertFalse(dbgap_audit.ok()) + + def test_ok_with_verified_and_error(self): + # Create a workspace and matching DAR. + dbgap_workspace = factories.dbGaPWorkspaceFactory.create() + other_dar = factories.dbGaPDataAccessRequestForWorkspaceFactory.create( + dbgap_workspace=dbgap_workspace + ) + # Add the anvil group to the auth group for the workspace. + GroupGroupMembershipFactory( + parent_group=dbgap_workspace.workspace.authorization_domains.first(), + child_group=other_dar.dbgap_data_access_snapshot.dbgap_application.anvil_access_group, + ) + dbgap_application = factories.dbGaPApplicationFactory.create() + # dar = factories.dbGaPDataAccessRequestForWorkspaceFactory.create( + # dbgap_workspace=dbgap_workspace + # ) + # Add the anvil group to the auth group for the workspace. + GroupGroupMembershipFactory( + parent_group=dbgap_workspace.workspace.authorization_domains.first(), + child_group=dbgap_application.anvil_access_group, + ) + dbgap_audit = audit.dbGaPAccessAudit() + dbgap_audit.run_audit() + self.assertEqual(len(dbgap_audit.verified), 1) + self.assertEqual(len(dbgap_audit.errors), 1) + self.assertFalse(dbgap_audit.ok()) + + def test_ok_not_completed(self): + dbgap_audit = audit.dbGaPAccessAudit() + with self.assertRaises(ValueError) as e: + dbgap_audit.ok() + self.assertEqual( + str(e.exception), + "Audit has not been completed. Use run_audit() to run the audit.", + ) + class dbGaPAccessAuditTableTest(TestCase): """Tests for the `dbGaPAccessAuditTableTest` table.""" diff --git a/primed/dbgap/tests/test_commands.py b/primed/dbgap/tests/test_commands.py new file mode 100644 index 00000000..e3b4dc93 --- /dev/null +++ b/primed/dbgap/tests/test_commands.py @@ -0,0 +1,147 @@ +"""Tests for management commands in the `dbgap` app.""" + +from io import StringIO + +from anvil_consortium_manager.tests.factories import GroupGroupMembershipFactory +from django.contrib.sites.models import Site +from django.core import mail +from django.core.management import call_command +from django.test import TestCase + +from . import factories + + +class RunDbGaPAuditTest(TestCase): + """Tests for the run_dbgap_audit command""" + + def test_command_output_no_records(self): + """Test command output.""" + out = StringIO() + call_command("run_dbgap_audit", "--no-color", stdout=out) + self.assertIn("Running dbGaP access audit... ok!", out.getvalue()) + self.assertIn("* Verified: 0", out.getvalue()) + self.assertIn("* Needs action: 0", out.getvalue()) + self.assertIn("* Errors: 0", out.getvalue()) + # Zero messages have been sent by default. + self.assertEqual(len(mail.outbox), 0) + + def test_command_run_audit_one_instance_verified(self): + """Test command output with one verified instance.""" + # Create a workspace and matching DAR. + factories.dbGaPWorkspaceFactory.create() + factories.dbGaPApplicationFactory.create() + out = StringIO() + call_command("run_dbgap_audit", "--no-color", stdout=out) + self.assertIn("Running dbGaP access audit... ok!", out.getvalue()) + self.assertIn("* Verified: 1", out.getvalue()) + self.assertIn("* Needs action: 0", out.getvalue()) + self.assertIn("* Errors: 0", out.getvalue()) + # Zero messages have been sent by default. + self.assertEqual(len(mail.outbox), 0) + + def test_command_run_audit_one_instance_needs_action(self): + """Test command output with one needs_action instance.""" + # Create a workspace and matching DAR. + dbgap_workspace = factories.dbGaPWorkspaceFactory.create() + factories.dbGaPDataAccessRequestForWorkspaceFactory.create( + dbgap_workspace=dbgap_workspace + ) + out = StringIO() + call_command("run_dbgap_audit", "--no-color", stdout=out) + self.assertIn("Running dbGaP access audit... problems found.", out.getvalue()) + self.assertIn("* Verified: 0", out.getvalue()) + self.assertIn("* Needs action: 1", out.getvalue()) + self.assertIn("* Errors: 0", out.getvalue()) + # Zero messages have been sent by default. + self.assertEqual(len(mail.outbox), 0) + + def test_command_run_audit_one_instance_error(self): + """Test command output with one error instance.""" + # Create a workspace and matching DAR. + dbgap_workspace = factories.dbGaPWorkspaceFactory.create() + dbgap_application = factories.dbGaPApplicationFactory.create() + GroupGroupMembershipFactory( + parent_group=dbgap_workspace.workspace.authorization_domains.first(), + child_group=dbgap_application.anvil_access_group, + ) + out = StringIO() + call_command("run_dbgap_audit", "--no-color", stdout=out) + self.assertIn("Running dbGaP access audit... problems found.", out.getvalue()) + self.assertIn("* Verified: 0", out.getvalue()) + self.assertIn("* Needs action: 0", out.getvalue()) + self.assertIn("* Errors: 1", out.getvalue()) + # Zero messages have been sent by default. + self.assertEqual(len(mail.outbox), 0) + + def test_command_run_audit_one_instance_verified_email(self): + """No email is sent when there are no errors.""" + # Create a workspace and matching DAR. + factories.dbGaPWorkspaceFactory.create() + factories.dbGaPApplicationFactory.create() + out = StringIO() + call_command( + "run_dbgap_audit", "--no-color", email="test@example.com", stdout=out + ) + self.assertIn("Running dbGaP access audit... ok!", out.getvalue()) + # Zero messages have been sent by default. + self.assertEqual(len(mail.outbox), 0) + + def test_command_run_audit_one_instance_needs_action_email(self): + """Email is sent for one needs_action instance.""" + # Create a workspace and matching DAR. + dbgap_workspace = factories.dbGaPWorkspaceFactory.create() + factories.dbGaPDataAccessRequestForWorkspaceFactory.create( + dbgap_workspace=dbgap_workspace + ) + out = StringIO() + call_command( + "run_dbgap_audit", "--no-color", email="test@example.com", stdout=out + ) + self.assertIn("Running dbGaP access audit... problems found.", out.getvalue()) + self.assertIn("* Verified: 0", out.getvalue()) + self.assertIn("* Needs action: 1", out.getvalue()) + self.assertIn("* Errors: 0", out.getvalue()) + # One message has been sent by default. + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertEqual(email.to, ["test@example.com"]) + self.assertEqual(email.subject, "dbGaP access audit - problems found") + + def test_command_run_audit_one_instance_error_email(self): + """Test command output with one error instance.""" + # Create a workspace and matching DAR. + dbgap_workspace = factories.dbGaPWorkspaceFactory.create() + dbgap_application = factories.dbGaPApplicationFactory.create() + GroupGroupMembershipFactory( + parent_group=dbgap_workspace.workspace.authorization_domains.first(), + child_group=dbgap_application.anvil_access_group, + ) + out = StringIO() + call_command( + "run_dbgap_audit", "--no-color", email="test@example.com", stdout=out + ) + self.assertIn("Running dbGaP access audit... problems found.", out.getvalue()) + self.assertIn("* Verified: 0", out.getvalue()) + self.assertIn("* Needs action: 0", out.getvalue()) + self.assertIn("* Errors: 1", out.getvalue()) + # One message has been sent by default. + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertEqual(email.to, ["test@example.com"]) + self.assertEqual(email.subject, "dbGaP access audit - problems found") + + def test_different_domain(self): + """Test command output when a different domain is specified.""" + site = Site.objects.create(domain="foobar.com", name="test") + site.save() + with self.settings(SITE_ID=site.id): + dbgap_workspace = factories.dbGaPWorkspaceFactory.create() + factories.dbGaPDataAccessRequestForWorkspaceFactory.create( + dbgap_workspace=dbgap_workspace + ) + out = StringIO() + call_command("run_dbgap_audit", "--no-color", stdout=out) + self.assertIn( + "Running dbGaP access audit... problems found.", out.getvalue() + ) + self.assertIn("https://foobar.com", out.getvalue()) diff --git a/primed/duo/models.py b/primed/duo/models.py index 8a79e9b8..7c6efb25 100644 --- a/primed/duo/models.py +++ b/primed/duo/models.py @@ -1,3 +1,5 @@ +import re + from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse @@ -41,6 +43,12 @@ def get_ols_url(self): self.identifier.replace("DUO:", "DUO_") ) + def get_short_definition(self): + text = re.sub(r"This .+? indicates that ", "", self.definition) + # Only capitalize the first letter - keep the remaining text as is. + text = text[0].capitalize() + text[1:] + return text + class DataUsePermission(DUOFields, TreeNode): """A model to track the allowed main consent codes using GA4GH DUO codes.""" diff --git a/primed/duo/tests/test_models.py b/primed/duo/tests/test_models.py index f216087d..baeb1416 100644 --- a/primed/duo/tests/test_models.py +++ b/primed/duo/tests/test_models.py @@ -87,6 +87,24 @@ def test_unique_identifier(self): with self.assertRaises(IntegrityError): instance2.save() + def test_get_short_definition(self): + instance = factories.DataUsePermissionFactory.create( + definition="Test definition" + ) + self.assertEqual(instance.get_short_definition(), "Test definition") + + def test_get_short_definition_re_sub(self): + instance = factories.DataUsePermissionFactory.create( + definition="This XXX indicates that everything is fine." + ) + self.assertEqual(instance.get_short_definition(), "Everything is fine.") + + def test_get_short_definition_capitalization(self): + instance = factories.DataUsePermissionFactory.create( + definition="Test definition XyXy" + ) + self.assertEqual(instance.get_short_definition(), "Test definition XyXy") + class DataUseModifierTest(TestCase): """Tests for the DataUseModifier model.""" @@ -157,6 +175,16 @@ def test_unique_identifier(self): with self.assertRaises(IntegrityError): instance2.save() + def test_get_short_definition(self): + instance = factories.DataUseModifierFactory.create(definition="Test definition") + self.assertEqual(instance.get_short_definition(), "Test definition") + + def test_get_short_definition_re_sub(self): + instance = factories.DataUseModifierFactory.create( + definition="This XXX indicates that use is allowed." + ) + self.assertEqual(instance.get_short_definition(), "Use is allowed.") + class DataUseOntologyTestCase(TestCase): """Tests for the DataUseOntology abstract model.""" diff --git a/primed/primed_anvil/audit.py b/primed/primed_anvil/audit.py new file mode 100644 index 00000000..b01ac5c0 --- /dev/null +++ b/primed/primed_anvil/audit.py @@ -0,0 +1,141 @@ +from abc import ABC, abstractmethod, abstractproperty + + +class PRIMEDAuditResult(ABC): + """Abstract base class to hold an audit result for a single check. + + Subclasses of this class are typically also dataclasses. They can define any number of + fields that track information about an audit and its result. The companion RPIMEDAudit + class `verified`, `needs_action`, and `errors` attributes should store lists of + PRIMEDAuditResult instances. + + Typical usage: + @dataclass + class MyAuditResult(PRIMEDAuditResult): + + some_value: str + + def get_table_dictionary(self): + return {"some_value": self.some_value} + + audit_result = MyAuditResult(some_value="the value for this result") + """ + + @abstractmethod + def get_table_dictionary(self): + """Return a dictionary representation of the result.""" + ... # pragma: no cover + + +class PRIMEDAudit(ABC): + """Abstract base class for PRIMED audit classes. + + This class is intended to be subclassed in order to store all results for a PRIMED audit. + Subclasses should implement the _run_audit class method to perform the audit. To run the + audit itself, one can use the run_audit method, which calls the _run_audit method in + addition to performs completion checks. Typically, _run_audit should loop over a set of + instances or checks, and store the results in the `verified`, `needs_action`, and `errors` + attributes. + + Attributes: + verified: A list of PRIMEDAuditResult subclasses instances that have been verified. + needs_action: A list of PRIMEDAuditResult subclasses instances that some sort of need action. + errors: A list of PRIMEDAuditResult subclasses instances where an error has been detected. + completed: A boolean indicator of whether the audit has been run. + """ + + # TODO: Add add_verified_result, add_needs_action_result, add_error_result methods. They should + # verify that the result is an instance of PRIMEDAuditResult (subclass). + + @abstractproperty + def results_table_class(self): + return ... # pragma: no cover + + def __init__(self): + self.completed = False + # Set up lists to hold audit results. + self.verified = [] + self.needs_action = [] + self.errors = [] + self.completed = False + + @abstractmethod + def _run_audit(self): + """Run the audit and store results in `verified`, `needs_action`, and `errors` lists. + + This method should typically loop over a set of instances or checks, and store the + results in the `verified`, `needs_action`, and `errors` attributes. The results should + be instances of PRIMEDAuditResult subclasses. This method should not be called directly. + + When deciding which list to store a result in, consider the following: + - verified: The result is as expected and no action is needed. + - needs_action: The result is expected for some reason, but action is needed. + - errors: The result is not expected and action is likely needed. + """ + ... # pragma: no cover + + def run_audit(self): + """Run the audit and mark it as completed.""" + self._run_audit() + self.completed = True + + def get_all_results(self): + """Return all results in a list, regardless of type. + + Returns: + list: A combined list of `verified`, `needs_action`, and `errors` results. + """ + self._check_completed() + return self.verified + self.needs_action + self.errors + + def _check_completed(self): + if not self.completed: + raise ValueError( + "Audit has not been completed. Use run_audit() to run the audit." + ) + + def get_verified_table(self): + """Return a table of verified audit results. + + The subclass of the table will be the specified `results_table_class`. + + Returns: + results_table_class: A table of verified results. + """ + self._check_completed() + return self.results_table_class( + [x.get_table_dictionary() for x in self.verified] + ) + + def get_needs_action_table(self): + """Return a table of needs_action audit results. + + The subclass of the table will be the specified `results_table_class`. + + Returns: + results_table_class: A table of need action results. + """ + self._check_completed() + return self.results_table_class( + [x.get_table_dictionary() for x in self.needs_action] + ) + + def get_errors_table(self): + """Return a table of error audit results. + + The subclass of the table will be the specified `results_table_class`. + + Returns: + results_table_class: A table of error results. + """ + self._check_completed() + return self.results_table_class([x.get_table_dictionary() for x in self.errors]) + + def ok(self): + """Check audit results to see if action is needed. + + Returns: + bool: True if no action is needed, False otherwise. + """ + self._check_completed() + return len(self.errors) + len(self.needs_action) == 0 diff --git a/primed/primed_anvil/tests/test_audit.py b/primed/primed_anvil/tests/test_audit.py new file mode 100644 index 00000000..fb34965d --- /dev/null +++ b/primed/primed_anvil/tests/test_audit.py @@ -0,0 +1,150 @@ +"""Tests for the `audit.py` module.""" +from dataclasses import dataclass +from unittest import TestCase + +import django_tables2 as tables + +from .. import audit + + +@dataclass +class TempAuditResult(audit.PRIMEDAuditResult): + + value: str + + def get_table_dictionary(self): + return {"value": self.value} + + +class TempResultsTable(tables.Table): + """A dummy class to use as the results_table_class attribute of PRIMEDAudit.""" + + # Columns. + value = tables.Column() + + +class TempAudit(audit.PRIMEDAudit): + """A dummy class to use for testing the PRIMEDAudit class.""" + + # Required abstract properties. + results_table_class = TempResultsTable + + def _run_audit(self): + """Run the audit.""" + # For this test, do nothing. + pass + + +class PRIMEDAuditResultTest(TestCase): + """Tests for the `PRIMEDAuditResult` class.""" + + def test_abstract_base_class(self): + """The abstract base class cannot be instantiated.""" + with self.assertRaises(TypeError): + audit.PRIMEDAuditResult() + + def test_instantiation(self): + """Subclass of abstract base class can be instantiated.""" + TempAuditResult(value="foo") + + def test_get_table_dictionary(self): + audit_result = TempAuditResult(value="foo") + self.assertEqual(audit_result.get_table_dictionary(), {"value": "foo"}) + + +class PRIMEDAuditTest(TestCase): + """Tests for the `PRIMEDAudit` class.""" + + def test_abstract_base_class(self): + """The abstract base class cannot be instantiated.""" + with self.assertRaises(TypeError): + audit.PRIMEDAudit() + + def test_instantiation(self): + """Subclass of abstract base class can be instantiated.""" + TempAudit() + + def test_results_lists(self): + """The completed attribute is set appropriately.""" + # Instantiate the class. + audit_results = TempAudit() + self.assertEqual(audit_results.verified, []) + self.assertEqual(audit_results.needs_action, []) + self.assertEqual(audit_results.errors, []) + + def test_completed(self): + """The completed attribute is set appropriately.""" + # Instantiate the class. + audit_results = TempAudit() + self.assertFalse(audit_results.completed) + audit_results.run_audit() + self.assertTrue(audit_results.completed) + + def test_get_all_results(self): + audit_results = TempAudit() + audit_results.run_audit() + # Manually set some audit results to get the output we want. + audit_results.verified = ["a"] + audit_results.needs_action = ["b"] + audit_results.errors = ["c"] + self.assertEqual(audit_results.get_all_results(), ["a", "b", "c"]) + + def test_get_all_results_incomplete(self): + audit_results = TempAudit() + with self.assertRaises(ValueError) as e: + audit_results.get_all_results() + self.assertEqual( + str(e.exception), + "Audit has not been completed. Use run_audit() to run the audit.", + ) + + def test_get_verified_table(self): + audit_results = TempAudit() + audit_results.run_audit() + audit_results.verified = [ + TempAuditResult(value="a"), + ] + audit_results.needs_action = [ + TempAuditResult(value="b"), + ] + audit_results.errors = [ + TempAuditResult(value="c"), + ] + table = audit_results.get_verified_table() + self.assertIsInstance(table, TempResultsTable) + self.assertEqual(len(table.rows), 1) + self.assertEqual(table.rows[0].get_cell("value"), "a") + + def test_get_needs_action_table(self): + audit_results = TempAudit() + audit_results.run_audit() + audit_results.verified = [ + TempAuditResult(value="a"), + ] + audit_results.needs_action = [ + TempAuditResult(value="b"), + ] + audit_results.errors = [ + TempAuditResult(value="c"), + ] + table = audit_results.get_needs_action_table() + self.assertIsInstance(table, TempResultsTable) + self.assertEqual(len(table.rows), 1) + self.assertEqual(table.rows[0].get_cell("value"), "b") + + def test_get_errors_table(self): + audit_results = TempAudit() + audit_results.run_audit() + audit_results.verified = [ + TempAuditResult(value="a"), + ] + audit_results.needs_action = [ + TempAuditResult(value="b"), + ] + audit_results.errors = [ + TempAuditResult(value="c"), + ] + table = audit_results.get_errors_table() + self.assertIsInstance(table, TempResultsTable) + self.assertEqual(len(table.rows), 1) + self.assertEqual(table.rows[0].get_cell("value"), "c") diff --git a/primed/templates/cdsa/cdsaworkspace_detail.html b/primed/templates/cdsa/cdsaworkspace_detail.html index b0d2da12..9c2d8ee7 100644 --- a/primed/templates/cdsa/cdsaworkspace_detail.html +++ b/primed/templates/cdsa/cdsaworkspace_detail.html @@ -56,7 +56,27 @@

    - {{ object.cdsaworkspace.data_use_limitations }} +
    +
    DUO consent description
    +
    +
  • {{ workspace_data_object.data_use_permission.abbreviation }}: {{ workspace_data_object.data_use_permission.get_short_definition }}
  • + {% for x in workspace_data_object.data_use_modifiers.all %} +
  • {{ x.abbreviation }}: {{ x.get_short_definition }}
  • + {% endfor %} +
    + {% if workspace_data_object.additional_limitations %} +
    Additional limitations for this consent group
    +
    +
  • {{ workspace_data_object.additional_limitations }}
  • +
    + {% endif %} + {% if primary_cdsa.additional_limitations %} +
    Additional limitations from CDSA
    +
    +
  • {{ primary_cdsa.additional_limitations }}
  • +
    + {% endif %} +
    diff --git a/primed/templates/cdsa/dataaffiliateagreement_detail.html b/primed/templates/cdsa/dataaffiliateagreement_detail.html index 211b3e92..0d48c8a8 100644 --- a/primed/templates/cdsa/dataaffiliateagreement_detail.html +++ b/primed/templates/cdsa/dataaffiliateagreement_detail.html @@ -45,6 +45,28 @@ {% endblock panel %} +{% block after_panel %} + +{% if object.additional_limitations %} +
    +
    +
    + + Additional limitations +
    +
    +

    + {{ object.additional_limitations }} +

    +
    +
    +
    +{% endif %} + +{{ block.super }} + +{% endblock after_panel %} + {% block action_buttons %} {% if show_update_button %} diff --git a/primed/templates/primed_anvil/email_audit_report.html b/primed/templates/primed_anvil/email_audit_report.html new file mode 100644 index 00000000..0facbbe9 --- /dev/null +++ b/primed/templates/primed_anvil/email_audit_report.html @@ -0,0 +1,48 @@ + +{% load static i18n %} +{% load render_table from django_tables2 %} +{% get_current_language as LANGUAGE_CODE %} + + + Audit report + + + + +
    + +{% block content %} + +

    {{ title }}

    + +

    Please visit {{url}} to resolve.

    + +

    Verified

    +
    + {{ data_access_audit.verified|length }} record(s) verified. +
    + +

    Needs action - {{data_access_audit.needs_action|length }} record(s)

    +
    + +
    + +

    Errors - {{data_access_audit.errors|length }} record(s)

    +
    + +
    + + +{% endblock content %} + +
    + + diff --git a/primed_apps.cron b/primed_apps.cron index cf4a4f93..ada71ccf 100644 --- a/primed_apps.cron +++ b/primed_apps.cron @@ -10,3 +10,8 @@ MAILTO="primedweb@uw.edu" # Weekly cdsa_records run Sundays at 03:00 - disabled until permissions issues are resolved 0 3 * * SUN . /var/www/django/primed_apps/primed-apps-activate.sh; python manage.py cdsa_records --outdir /projects/primed/records/cdsa/$(date +'\%Y-\%m-\%d') >> cron.log + +# Weekly audits run Sundays around 04:00 +10 4 * * SUN . /var/www/django/primed_apps/primed-apps-activate.sh; python manage.py run_dbgap_audit --email primedconsortium@uw.edu >> cron.log +15 4 * * SUN . /var/www/django/primed_apps/primed-apps-activate.sh; python manage.py run_cdsa_audit --email primedconsortium@uw.edu >> cron.log +20 4 * * SUN . /var/www/django/primed_apps/primed-apps-activate.sh; python manage.py run_collaborative_analysis_audit --email primedconsortium@uw.edu >> cron.log diff --git a/requirements/dev-requirements.txt b/requirements/dev-requirements.txt index 74066afb..814e7b09 100644 --- a/requirements/dev-requirements.txt +++ b/requirements/dev-requirements.txt @@ -50,13 +50,13 @@ dill==0.3.8 # via pylint distlib==0.3.8 # via virtualenv -django==4.2.10 +django==4.2.11 # via # -c requirements/requirements.txt # django-debug-toolbar # django-stubs # django-stubs-ext -django-debug-toolbar==4.2.0 +django-debug-toolbar==4.3.0 # via -r requirements/dev-requirements.in django-stubs==4.2.7 # via -r requirements/dev-requirements.in @@ -111,7 +111,7 @@ mccabe==0.7.0 # via # flake8 # pylint -mypy==1.8.0 +mypy==1.9.0 # via -r requirements/dev-requirements.in mypy-extensions==1.0.0 # via @@ -233,7 +233,7 @@ types-pytz==2024.1.0.20240203 # via django-stubs types-pyyaml==6.0.12.12 # via django-stubs -types-requests==2.31.0.20240125 +types-requests==2.31.0.20240310 # via -r requirements/dev-requirements.in typing-extensions==4.8.0 # via @@ -253,7 +253,7 @@ urllib3==2.1.0 # -c requirements/test-requirements.txt # requests # types-requests -virtualenv==20.25.0 +virtualenv==20.25.1 # via pre-commit wcwidth==0.2.13 # via prompt-toolkit diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 927e8570..6b04b099 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -12,6 +12,7 @@ asgiref==3.7.2 # via # django # django-htmx + # django-simple-history attrs==23.2.0 # via # jsonschema @@ -36,15 +37,15 @@ charset-normalizer==3.3.2 # via requests click==8.1.3 # via pip-tools -crispy-bootstrap5==2023.10 +crispy-bootstrap5==2024.2 # via # -r requirements/requirements.in # django-anvil-consortium-manager -cryptography==42.0.4 +cryptography==42.0.5 # via pyjwt defusedxml==0.7.1 # via python3-openid -django==4.2.10 +django==4.2.11 # via # -r requirements/requirements.in # crispy-bootstrap5 @@ -62,7 +63,7 @@ django-allauth==0.54.0 # via -r requirements/requirements.in django-anvil-consortium-manager @ git+https://github.com/UW-GAC/django-anvil-consortium-manager.git@v0.22 # via -r requirements/requirements.in -django-autocomplete-light==3.9.7 +django-autocomplete-light==3.11.0 # via django-anvil-consortium-manager django-crispy-forms==2.1 # via @@ -79,15 +80,15 @@ django-extensions==3.2.3 # django-anvil-consortium-manager django-filter==23.5 # via django-anvil-consortium-manager -django-htmx==1.17.2 +django-htmx==1.17.3 # via -r requirements/requirements.in django-login-required-middleware==0.9.0 # via -r requirements/requirements.in django-maintenance-mode==0.21.1 # via -r requirements/requirements.in -django-model-utils==4.3.1 +django-model-utils==4.4.0 # via -r requirements/requirements.in -django-simple-history==3.4.0 +django-simple-history==3.5.0 # via # -r requirements/requirements.in # django-anvil-consortium-manager @@ -99,7 +100,7 @@ fastobo==0.12.3 # via pronto fontawesomefree==6.5.1 # via django-anvil-consortium-manager -google-auth==2.27.0 +google-auth==2.28.1 # via django-anvil-consortium-manager idna==3.3 # via requests @@ -133,13 +134,13 @@ packaging==21.3 # plotly pandas==2.0.3 # via -r requirements/requirements.in -pip-tools==7.4.0 +pip-tools==7.4.1 # via -r requirements/requirements.in pkgutil-resolve-name==1.3.10 # via jsonschema plotly==5.19.0 # via django-anvil-consortium-manager -pronto==2.5.5 +pronto==2.5.6 # via -r requirements/requirements.in pyasn1==0.5.1 # via @@ -191,9 +192,7 @@ rpds-py==0.17.1 rsa==4.9 # via google-auth six==1.16.0 - # via - # django-autocomplete-light - # python-dateutil + # via python-dateutil sqlparse==0.4.4 # via # -r requirements/requirements.in diff --git a/requirements/test-requirements.txt b/requirements/test-requirements.txt index 313a3407..e69f69b4 100644 --- a/requirements/test-requirements.txt +++ b/requirements/test-requirements.txt @@ -12,7 +12,7 @@ charset-normalizer==3.3.2 # via # -c requirements/requirements.txt # requests -coverage==7.4.1 +coverage==7.4.3 # via # -r requirements/test-requirements.in # django-coverage-plugin @@ -24,7 +24,7 @@ exceptiongroup==1.2.0 # via pytest factory-boy==3.3.0 # via -r requirements/test-requirements.in -faker==23.1.0 +faker==23.2.1 # via factory-boy freezegun==1.4.0 # via -r requirements/test-requirements.in @@ -45,14 +45,14 @@ pyparsing==3.1.1 # via # -c requirements/requirements.txt # packaging -pytest==8.0.1 +pytest==8.1.1 # via # -r requirements/test-requirements.in # pytest-django # pytest-sugar pytest-django==4.8.0 # via -r requirements/test-requirements.in -pytest-sugar==0.9.7 +pytest-sugar==1.0.0 # via -r requirements/test-requirements.in python-dateutil==2.8.2 # via @@ -65,7 +65,7 @@ requests==2.31.0 # via # -c requirements/requirements.txt # responses -responses==0.24.1 +responses==0.25.0 # via -r requirements/test-requirements.in six==1.16.0 # via