Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Associate study sites with members groups #648

Merged
merged 6 commits into from
Jul 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions add_cdsa_example_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
signed_agreement__version=v10,
study_site=cc,
)
cdsa_1001.signed_agreement.representative.study_sites.add(cc)
GroupGroupMembershipFactory.create(parent_group=cdsa_group, child_group=cdsa_1001.signed_agreement.anvil_access_group)

cdsa_1002 = factories.MemberAgreementFactory.create(
Expand All @@ -81,6 +82,7 @@
signed_agreement__version=v10,
study_site=cardinal,
)
cdsa_1002.signed_agreement.representative.study_sites.add(cardinal)
GroupGroupMembershipFactory.create(parent_group=cdsa_group, child_group=cdsa_1002.signed_agreement.anvil_access_group)

cdsa_1003 = factories.MemberAgreementFactory.create(
Expand Down
20 changes: 20 additions & 0 deletions primed/primed_anvil/migrations/0007_studysite_member_group.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 4.2.13 on 2024-07-10 20:21

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('anvil_consortium_manager', '0019_accountuserarchive'),
('primed_anvil', '0006_studysite_drupal_node_id'),
]

operations = [
migrations.AddField(
model_name='studysite',
name='member_group',
field=models.ForeignKey(blank=True, help_text='The AnVIL Managed Group associated with this site.', null=True, on_delete=django.db.models.deletion.PROTECT, to='anvil_consortium_manager.managedgroup'),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 4.2.13 on 2024-07-10 20:24

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('anvil_consortium_manager', '0019_accountuserarchive'),
('primed_anvil', '0007_studysite_member_group'),
]

operations = [
migrations.AlterField(
model_name='studysite',
name='member_group',
field=models.OneToOneField(blank=True, help_text='The AnVIL Managed Group associated with this site.', null=True, on_delete=django.db.models.deletion.PROTECT, to='anvil_consortium_manager.managedgroup'),
),
]
10 changes: 10 additions & 0 deletions primed/primed_anvil/models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from anvil_consortium_manager.models import ManagedGroup
from django.conf import settings
from django.db import models
from django.urls import reverse
Expand Down Expand Up @@ -37,6 +38,15 @@ class StudySite(TimeStampedModel, models.Model):
full_name = models.CharField(max_length=255)
"""The full name of the Study Sites."""

member_group = models.OneToOneField(
ManagedGroup,
on_delete=models.PROTECT,
help_text="The AnVIL Managed Group associated with this site.",
null=True,
blank=True,
unique=True,
)

drupal_node_id = models.IntegerField(blank=True, null=True)
"""Reference node ID for entity in drupal"""

Expand Down
16 changes: 12 additions & 4 deletions primed/primed_anvil/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,11 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, extra_columns=extra_columns, **kwargs)


class UserAccountSingleGroupMembershipTable(tables.Table):
"""A table with users and info about whether they are members of a group."""
class UserAccountTable(tables.Table):
"""A table for `User`s with `Account` information."""

name = tables.Column(linkify=True)
account = tables.Column(linkify=True, verbose_name="AnVIL account")

class Meta:
model = User
Expand All @@ -167,8 +170,13 @@ class Meta:
)
order_by = ("name",)

name = tables.Column(linkify=lambda record: record.get_absolute_url())
account = tables.Column(verbose_name="AnVIL account")

class UserAccountSingleGroupMembershipTable(UserAccountTable):
"""A table with users and info about whether they are members of a group."""

class Meta(UserAccountTable.Meta):
pass

is_group_member = tables.BooleanColumn(verbose_name="Has access?", default=False)

def __init__(self, *args, managed_group=None, **kwargs):
Expand Down
20 changes: 20 additions & 0 deletions primed/primed_anvil/tests/test_models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Tests of models in the `primed_anvil` app."""

from anvil_consortium_manager.tests.factories import ManagedGroupFactory
from django.core.exceptions import ValidationError
from django.db.utils import IntegrityError
from django.test import TestCase
Expand Down Expand Up @@ -48,6 +49,7 @@ class StudySiteTest(TestCase):
def test_model_saving(self):
"""Creation using the model constructor and .save() works."""
instance = models.StudySite(full_name="Test name", short_name="TEST")
instance.full_clean()
instance.save()
self.assertIsInstance(instance, models.StudySite)

Expand All @@ -72,6 +74,24 @@ def test_unique_short_name(self):
with self.assertRaises(IntegrityError):
instance2.save()

def test_can_set_members_group(self):
member_group = ManagedGroupFactory.create()
instance = models.StudySite(full_name="Test name", short_name="TEST", member_group=member_group)
instance.full_clean()
instance.save()
self.assertIsInstance(instance, models.StudySite)

def test_same_member_group_different_sites(self):
member_group = ManagedGroupFactory.create()
factories.StudySiteFactory.create(member_group=member_group)
instance = factories.StudySiteFactory.build(member_group=member_group)
with self.assertRaises(ValidationError) as e:
instance.full_clean()
self.assertEqual(len(e.exception.message_dict), 1)
self.assertIn("member_group", e.exception.message_dict)
self.assertEqual(len(e.exception.message_dict["member_group"]), 1)
self.assertIn("Study site with this Member group already exists.", e.exception.message_dict["member_group"][0])


class AvailableDataTest(TestCase):
"""Tests for the AvailableData model."""
Expand Down
29 changes: 29 additions & 0 deletions primed/primed_anvil/tests/test_tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,35 @@ def test_render_not_workspace(self):
column.render(None, "foo", None)


class UserAccountTableTest(TestCase):
"""Tests for the UserAccountTable class."""

def setUp(self):
self.managed_group = ManagedGroupFactory.create()

def test_row_count_with_no_objects(self):
table = tables.UserAccountTable(User.objects.all())
self.assertEqual(len(table.rows), 0)

def test_row_count_with_one_object(self):
UserFactory.create()
table = tables.UserAccountTable(User.objects.all())
self.assertEqual(len(table.rows), 1)

def test_row_count_with_two_objects(self):
UserFactory.create_batch(2)
table = tables.UserAccountTable(User.objects.all())
self.assertEqual(len(table.rows), 2)

def test_ordering(self):
"""Users are ordered alphabetically by name"""
user_foo = UserFactory.create(name="Foo")
user_bar = UserFactory.create(name="Bar")
table = tables.UserAccountTable(User.objects.all())
self.assertEqual(table.data[0], user_bar)
self.assertEqual(table.data[1], user_foo)


class UserAccountSingleGroupMembershipTableTest(TestCase):
"""Tests for the UserAccountSingleGroupMembershipTable class."""

Expand Down
60 changes: 58 additions & 2 deletions primed/primed_anvil/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from anvil_consortium_manager import models as acm_models
from anvil_consortium_manager.tests.factories import (
AccountFactory,
GroupAccountMembershipFactory,
ManagedGroupFactory,
WorkspaceGroupSharingFactory,
)
Expand All @@ -17,13 +18,13 @@
from django.test import RequestFactory, TestCase
from django.urls import reverse

from primed.cdsa.tables import CDSAWorkspaceStaffTable, CDSAWorkspaceUserTable
from primed.cdsa.tables import CDSAWorkspaceStaffTable, CDSAWorkspaceUserTable, MemberAgreementTable
from primed.cdsa.tests.factories import (
CDSAWorkspaceFactory,
DataAffiliateAgreementFactory,
MemberAgreementFactory,
)
from primed.dbgap.tables import dbGaPWorkspaceStaffTable, dbGaPWorkspaceUserTable
from primed.dbgap.tables import dbGaPApplicationTable, dbGaPWorkspaceStaffTable, dbGaPWorkspaceUserTable
from primed.dbgap.tests.factories import (
dbGaPApplicationFactory,
dbGaPStudyAccessionFactory,
Expand Down Expand Up @@ -843,6 +844,22 @@ def test_view_status_code_with_invalid_pk(self):
with self.assertRaises(Http404):
self.get_view()(request, pk=obj.pk + 1)

def test_table_classes(self):
"""Table classes are correct."""
obj = self.model_factory.create()
self.client.force_login(self.user)
response = self.client.get(self.get_url(obj.pk))
self.assertIn("tables", response.context_data)
self.assertEqual(len(response.context_data["tables"]), 4)
self.assertIsInstance(response.context_data["tables"][0], tables.UserAccountTable)
self.assertEqual(len(response.context_data["tables"][0].data), 0)
self.assertIsInstance(response.context_data["tables"][1], dbGaPApplicationTable)
self.assertEqual(len(response.context_data["tables"][1].data), 0)
self.assertIsInstance(response.context_data["tables"][2], MemberAgreementTable)
self.assertEqual(len(response.context_data["tables"][2].data), 0)
self.assertIsInstance(response.context_data["tables"][3], tables.AccountTable)
self.assertEqual(len(response.context_data["tables"][3].data), 0)

def test_site_user_table(self):
"""Contains a table of site users with the correct users."""
obj = self.model_factory.create()
Expand Down Expand Up @@ -883,6 +900,45 @@ def test_cdsa_table(self):
self.assertIn(site_cdsa, table.data)
self.assertNotIn(other_cdsa, table.data)

def test_site_user_table_when_member_group_is_set(self):
"""The site user table is the correct class when the member group is set."""
member_group = ManagedGroupFactory.create()
obj = self.model_factory.create(member_group=member_group)
self.client.force_login(self.user)
response = self.client.get(self.get_url(obj.pk))
table = response.context_data["tables"][0]
self.assertIsInstance(table, tables.UserAccountSingleGroupMembershipTable)
self.assertEqual(table.managed_group, member_group)

def test_member_group_table(self):
member_group = ManagedGroupFactory.create()
obj = self.model_factory.create(member_group=member_group)
account = AccountFactory.create(verified=True)
GroupAccountMembershipFactory.create(account=account, group=member_group)
other_account = AccountFactory.create(verified=True)
self.client.force_login(self.user)
response = self.client.get(self.get_url(obj.pk))
table = response.context_data["tables"][3]
self.assertEqual(len(table.rows), 1)
self.assertIn(account, table.data)
self.assertNotIn(other_account, table.data)

def test_member_table_group_not_set(self):
obj = self.model_factory.create()
AccountFactory.create(verified=True)
self.client.force_login(self.user)
response = self.client.get(self.get_url(obj.pk))
table = response.context_data["tables"][3]
self.assertEqual(len(table.rows), 0)

def test_link_to_member_group(self):
"""Response includes a link to the member group if it exists."""
member_group = ManagedGroupFactory.create()
obj = self.model_factory.create(member_group=member_group)
self.client.force_login(self.user)
response = self.client.get(self.get_url(obj.pk))
self.assertContains(response, member_group.get_absolute_url())


class StudySiteListTest(TestCase):
"""Tests for the StudySiteList view."""
Expand Down
27 changes: 16 additions & 11 deletions primed/primed_anvil/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
AnVILConsortiumManagerStaffViewRequired,
AnVILConsortiumManagerViewRequired,
)
from anvil_consortium_manager.models import AnVILProjectManagerAccess, Workspace
from anvil_consortium_manager.models import Account, AnVILProjectManagerAccess, Workspace
from dal import autocomplete
from django.contrib.auth import get_user_model
from django.contrib.auth.mixins import LoginRequiredMixin
Expand Down Expand Up @@ -33,7 +33,6 @@
OpenAccessWorkspaceStaffTable,
OpenAccessWorkspaceUserTable,
)
from primed.users.tables import UserTable

from . import filters, helpers, models, tables

Expand Down Expand Up @@ -124,19 +123,25 @@ class StudySiteDetail(AnVILConsortiumManagerStaffViewRequired, MultiTableMixin,
"""View to show details about a `StudySite`."""

model = models.StudySite
tables = [
UserTable,
dbGaPApplicationTable,
MemberAgreementTable,
]

# def get_table(self):
# return UserTable(User.objects.filter(study_sites=self.object))
def get_tables_data(self):
def get_tables(self):
user_qs = User.objects.filter(study_sites=self.object)
if self.object.member_group:
user_table = tables.UserAccountSingleGroupMembershipTable(user_qs, managed_group=self.object.member_group)
else:
user_table = tables.UserAccountTable(user_qs, exclude="study_sites")
dbgap_qs = dbGaPApplication.objects.filter(principal_investigator__study_sites=self.object)
cdsa_qs = MemberAgreement.objects.filter(study_site=self.object)
return [user_qs, dbgap_qs, cdsa_qs]
if self.object.member_group:
account_qs = Account.objects.filter(groupaccountmembership__group=self.object.member_group)
else:
account_qs = Account.objects.none()
return [
user_table,
dbGaPApplicationTable(dbgap_qs),
MemberAgreementTable(cdsa_qs),
tables.AccountTable(account_qs, exclude=("number_groups")),
]


class StudySiteList(AnVILConsortiumManagerStaffViewRequired, SingleTableView):
Expand Down
54 changes: 51 additions & 3 deletions primed/templates/primed_anvil/studysite_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@
<dl class="row">
<dt class="col-sm-2">Full name</dt> <dd class="col-sm-10">{{ object.full_name }}</dd>
<dt class="col-sm-2">Short name</dt> <dd class="col-sm-10">{{ object.short_name }}</dd>
<dt class="col-sm-2">Member group</dt> <dd class="col-sm-10">
{% if object.member_group %}
<a href="{{ object.member_group.get_absolute_url }}">{{ object.member_group }}</a>
{% else %}
&mdash;
{% endif %}
</dd>
</dl>
{% endblock panel %}

Expand Down Expand Up @@ -44,9 +51,50 @@ <h2 class="accordion-header" id="workspaces-heading-CDSA">
</div>
</div>

<h3>Investigators</h3>

<h3>Study Site Users</h3>
<p class='alert alert-warning'>Study site user list only contains those site users who have created an account on this website.</p>
{% render_table tables.0 %}
<div class="my-3">
<div class="accordion" id="accordionMembers">
<div class="accordion-item">
<h2 class="accordion-header" id="headingMembersOne">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseMembersOne" aria-expanded="false" aria-controls="collapseMembersOne">
<span class="fa-solid fa-cloud-arrow-down mx-2"></span>
View consortium members with data access
<span class="badge mx-2 bg-secondary pill"> {{ tables.1.rows|length }}</span>
</button>
</h2>
<div id="collapseMembersOne" class="accordion-collapse collapse" aria-labelledby="headingMembersOne" data-bs-parent="#accordionMembers">
<div class="accordion-body">
<p>
This table shows Accounts in the member group for this Study Site.
</p>
{% render_table tables.3 %}
</div>
</div>
</div>
</div>
</div>

<div class="my-3">
<div class="accordion" id="accordionStudySiteUsers">
<div class="accordion-item">
<h2 class="accordion-header" id="headingStudySiteUsersOne">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseStudySiteUsersOne" aria-expanded="false" aria-controls="collapseStudySiteUsersOne">
<span class="fa-solid fa-user mx-2"></span>
Study Site members
<span class="badge mx-2 bg-secondary pill"> {{ tables.0.rows|length }}</span>
</button>
</h2>
<div id="collapseStudySiteUsersOne" class="accordion-collapse collapse" aria-labelledby="headingStudySiteUsersOne" data-bs-parent="#accordionStudySiteUsers">
<div class="accordion-body">
<p>
This table shows users who are associated with this Study Site.
</p>
{% render_table tables.0 %}
</div>
</div>
</div>
</div>
</div>

{% endblock after_panel %}