Skip to content

Commit

Permalink
Merge pull request #484 from UW-GAC/feature/unlink-account-from-user
Browse files Browse the repository at this point in the history
Add ability to unlink users from an Account
  • Loading branch information
amstilp authored May 31, 2024
2 parents 511a782 + 49ad21c commit 4cc0e16
Show file tree
Hide file tree
Showing 10 changed files with 612 additions and 4 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

* Do not track previous groups for inactive accounts.
* Add an audit error if any group membership records exist for inactive accounts.
* Add the ability to unlink a user from an account.

## 0.22.1 (2024-05-22)

Expand Down
2 changes: 1 addition & 1 deletion anvil_consortium_manager/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.23.0.dev0"
__version__ = "0.23.0.dev1"
61 changes: 61 additions & 0 deletions anvil_consortium_manager/migrations/0019_accountuserarchive.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Generated by Django 5.0 on 2024-05-31 20:45

import django.db.models.deletion
import django_extensions.db.fields
import simple_history.models
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('anvil_consortium_manager', '0018_alter_historicalworkspace_is_requester_pays_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='AccountUserArchive',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')),
('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='anvil_consortium_manager.account')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('verified_email_entry', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='anvil_consortium_manager.useremailentry')),
],
options={
'get_latest_by': 'modified',
'abstract': False,
},
),
migrations.AddField(
model_name='account',
name='unlinked_users',
field=models.ManyToManyField(blank=True, help_text='Previous users that this account has been linked to.', related_name='unlinked_accounts', through='anvil_consortium_manager.AccountUserArchive', to=settings.AUTH_USER_MODEL),
),
migrations.CreateModel(
name='HistoricalAccountUserArchive',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')),
('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('account', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='anvil_consortium_manager.account')),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('user', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)),
('verified_email_entry', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='anvil_consortium_manager.useremailentry')),
],
options={
'verbose_name': 'historical account user archive',
'verbose_name_plural': 'historical account user archives',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
]
35 changes: 35 additions & 0 deletions anvil_consortium_manager/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,13 @@ class Account(TimeStampedModel, ActivatorModel):
help_text="""The UserEmailEntry object used to verify the email,
if the account was created by a user linking their email.""",
)
unlinked_users = models.ManyToManyField(
settings.AUTH_USER_MODEL,
related_name="unlinked_accounts",
help_text="Previous users that this account has been linked to.",
blank=True,
through="AccountUserArchive",
)
note = models.TextField(blank=True, help_text="Additional notes.")

history = HistoricalRecords()
Expand Down Expand Up @@ -345,6 +352,34 @@ def has_workspace_access(self, workspace):
accessible_workspaces = self.get_accessible_workspaces()
return workspace in accessible_workspaces

def unlink_user(self):
"""Unlink the user from this account.
This will remove the user from the account and add the user (and verified email entry, if applicable) to the
unlinked_users field.
Raises:
ValueError: If there is no user linked to the account.
"""
if not self.user:
raise ValueError("No user is linked to this account.")
self.unlinked_users.add(self.user, through_defaults={"verified_email_entry": self.verified_email_entry})
self.user = None
self.verified_email_entry = None
self.save()


class AccountUserArchive(TimeStampedModel):
"""A model to store information about the previous users of an Account."""

account = models.ForeignKey(Account, on_delete=models.CASCADE)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
verified_email_entry = models.ForeignKey(UserEmailEntry, on_delete=models.CASCADE, null=True, blank=True)
history = HistoricalRecords()

def __str__(self):
return "{user} for {account}".format(user=self.user, account=self.account)


class ManagedGroup(TimeStampedModel):
"""A model to store information about AnVIL Managed Groups."""
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{% extends "anvil_consortium_manager/base.html" %}
{% load static %}

{% load render_table from django_tables2 %}

{% block title %}Unlink user{% endblock %}

{% block content %}
<div class="container">

<div class="row">
<div class="col-sm-12">


<h2>Unlink user from account</h2>

<p>
Are you sure you want to unlink <a href="{{ object.user.get_absolute_url }}">{{ object.user }} </a> from <a href="{{ object.get_absolute_url }}">{{ object }}</a>?
</p>

<form method="POST">{% csrf_token %}
{{ form }}
<input type="submit" class="btn btn-danger" value="Yes, unlink"/>
<a href="{{ object.get_absolute_url }}" class="btn btn-secondary" role="button">
No, cancel</a>
</form>

</div>
</div>


</div>
{% endblock content %}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
Service account
</span>
{% endif %}
{% if object.verified_email_entry %}
{% if object.user and object.verified_email_entry %}
<span class="badge bg-success">
<span class="fa-solid fa-user-check me-1"></span>
Verified by user
Expand All @@ -33,7 +33,7 @@
<dt class="col-sm-2">Email</dt> <dd class="col-sm-10">{{ object.email }}</dd>
<dt class="col-sm-2">Status</dt> <dd class="col-sm-10">{{ object.get_status_display }}</dd>
<dt class="col-sm-2">User</dt> <dd class="col-sm-10">
{% if object.verified_email_entry %}
{% if object.user and object.verified_email_entry %}
{% if object.verified_email_entry.user.get_absolute_url %}
<a href="{{ object.verified_email_entry.user.get_absolute_url }}">{{ object.verified_email_entry.user }}</a>
{% else %}
Expand All @@ -43,14 +43,31 @@
&mdash;
{% endif %}
</dd>
<dt class="col-sm-2">Date verified</dt> <dd class="col-sm-10">{% if object.verified_email_entry %}{{ object.verified_email_entry.date_verified }}{% else %} &mdash; {% endif %}</dd>
<dt class="col-sm-2">Date verified</dt> <dd class="col-sm-10">{% if object.user and object.verified_email_entry %}{{ object.verified_email_entry.date_verified }}{% else %} &mdash; {% endif %}</dd>
<dt class="col-sm-2">Date created</dt> <dd class="col-sm-10">{{ object.created }}</dd>
<dt class="col-sm-2">Date modified</dt> <dd class="col-sm-10">{{ object.modified }}</dd>
</dl>

{% endblock panel %}


{% block after_panel %}
{% if object.unlinked_users.count %}
<div class="card">
<div class="card-body bg-light">
<h5 class="card-title"><span class="fa-solid fa-link-slash mx-2"></span> Unlinked users</h5>
<p class="card-text">Ths account was previously linked to the following user(s):</p>
</div>
<ul class="list-group list-group-flush">
{% for unlinked_user in object.unlinked_users.all %}
<li class="list-group-item">
<a href="{{ unlinked_user.get_absolute_url }}">{{ unlinked_user }}</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}

<div class="my-3">
<div class="accordion" id="accordionGroups">
<div class="accordion-item">
Expand Down Expand Up @@ -114,6 +131,9 @@ <h2 class="accordion-header" id="headingAccessibleWorkspacesOne">
{% if show_reactivate_button %}
<a href="{% url 'anvil_consortium_manager:accounts:reactivate' uuid=object.uuid %}" class="btn btn-secondary" role="button">Reactivate account</a>
{% endif %}
{% if show_unlink_button %}
<a href="{% url 'anvil_consortium_manager:accounts:unlink' uuid=object.uuid %}" class="btn btn-secondary" role="button">Unlink user</a>
{% endif %}
<a href="{% url 'anvil_consortium_manager:accounts:delete' uuid=object.uuid %}" class="btn btn-danger" role="button">Delete from app</a>
</p>
{% endif %}
Expand Down
98 changes: 98 additions & 0 deletions anvil_consortium_manager/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from ..adapters.default import DefaultWorkspaceAdapter
from ..models import (
Account,
AccountUserArchive,
BillingProject,
DefaultWorkspaceData,
GroupAccountMembership,
Expand Down Expand Up @@ -949,6 +950,103 @@ def test_has_workspace_access_is_not_shared_two_auth_domain_in_both(self):
# Sharing membership.
self.assertFalse(account.has_workspace_access(workspace))

def test_unlink_user(self):
"""The unlink_user method removes the user and verified_email_entry."""
account = factories.AccountFactory.create(verified=True)
account.unlink_user()
self.assertIsNone(account.user)
self.assertIsNone(account.verified_email_entry)

def test_unlink_user_adds_to_archive(self):
"""The unlink_user method adds the user to the AccountUserArchive."""
account = factories.AccountFactory.create(verified=True)
user = account.user
verified_email_entry = account.verified_email_entry
account.unlink_user()
self.assertEqual(AccountUserArchive.objects.count(), 1)
archive = AccountUserArchive.objects.first()
self.assertEqual(archive.user, user)
self.assertEqual(archive.account, account)
self.assertEqual(archive.verified_email_entry, verified_email_entry)

def test_unlink_user_no_verified_email(self):
"""The unlink_user method removes the user and verified_email_entry."""
user = factories.UserFactory.create()
account = factories.AccountFactory.create(user=user)
account.unlink_user()
self.assertIsNone(account.user)
self.assertIsNone(account.verified_email_entry)
self.assertEqual(AccountUserArchive.objects.count(), 1)
archive = AccountUserArchive.objects.first()
self.assertEqual(archive.user, user)
self.assertEqual(archive.account, account)
self.assertIsNone(archive.verified_email_entry)

def test_can_archive_more_than_one_account_for_one_user(self):
"""The unlink_user method adds the user to the AccountUserArchive."""
user = factories.UserFactory.create()
account_1 = factories.AccountFactory.create(user=user)
account_1.unlink_user()
account_2 = factories.AccountFactory.create(user=user)
account_2.unlink_user()
self.assertEqual(AccountUserArchive.objects.count(), 2)
AccountUserArchive.objects.get(account=account_1, user=user)
AccountUserArchive.objects.get(account=account_2, user=user)

def test_raises_value_error_no_user(self):
"""Raises a ValueError if the account has no user."""
account = factories.AccountFactory.create()
with self.assertRaises(ValueError):
account.unlink_user()


class AccountUserArchiveTestCase(TestCase):
"""Tests for the AccountUserArchive model."""

def test_model_saving(self):
"""Creation using the model constructor and .save() works."""
user = factories.UserFactory.create()
account = factories.AccountFactory.create()
instance = AccountUserArchive(user=user, account=account)
instance.save()
self.assertIsInstance(instance, AccountUserArchive)

def test_created_timestamp(self):
"""created timestamp is set."""
user = factories.UserFactory.create()
account = factories.AccountFactory.create()
instance = AccountUserArchive(user=user, account=account)
instance.save()
self.assertIsNotNone(instance.created)

def test_verified_email_entry(self):
"""Creation using the model constructor and .save() works."""
account = factories.AccountFactory.create(verified=True)
instance = AccountUserArchive(
user=account.user, account=account, verified_email_entry=account.verified_email_entry
)
instance.save()
self.assertIsInstance(instance, AccountUserArchive)

def test_one_account_two_users(self):
"""Multiple AccountUserArchive records for one user."""
user = factories.UserFactory.create()
account_1 = factories.AccountFactory.create()
account_2 = factories.AccountFactory.create()
instance = AccountUserArchive(user=user, account=account_1)
instance.save()
instance_2 = AccountUserArchive(user=user, account=account_2)
instance_2.save()
self.assertEqual(AccountUserArchive.objects.count(), 2)

def test_str_method(self):
"""The custom __str__ method returns a string."""
user = factories.UserFactory.create()
account = factories.AccountFactory.create()
instance = AccountUserArchive(user=user, account=account)
instance.save()
self.assertIsInstance(instance.__str__(), str)


class ManagedGroupTest(TestCase):
def test_model_saving(self):
Expand Down
Loading

0 comments on commit 4cc0e16

Please sign in to comment.