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 %}
+
+
+
+
+
+ {{ 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)
+
+
+ {% for record in data_access_audit.needs_action %}
+ - {{ record|stringformat:'r' }}
+ {% endfor %}
+
+
+
+
Errors - {{data_access_audit.errors|length }} record(s)
+
+
+ {% for record in data_access_audit.errors %}
+ - {{ record|stringformat:'r' }}
+ {% endfor %}
+
+
+
+
+{% 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