diff --git a/CHANGELOG.md b/CHANGELOG.md index cc0d79f7..9623ea5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/anvil_consortium_manager/__init__.py b/anvil_consortium_manager/__init__.py index 03b05414..036c5359 100644 --- a/anvil_consortium_manager/__init__.py +++ b/anvil_consortium_manager/__init__.py @@ -1 +1 @@ -__version__ = "0.23.0.dev0" +__version__ = "0.23.0.dev1" diff --git a/anvil_consortium_manager/migrations/0019_accountuserarchive.py b/anvil_consortium_manager/migrations/0019_accountuserarchive.py new file mode 100644 index 00000000..032587bd --- /dev/null +++ b/anvil_consortium_manager/migrations/0019_accountuserarchive.py @@ -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), + ), + ] diff --git a/anvil_consortium_manager/models.py b/anvil_consortium_manager/models.py index 24c12022..19be2c9b 100644 --- a/anvil_consortium_manager/models.py +++ b/anvil_consortium_manager/models.py @@ -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() @@ -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.""" diff --git a/anvil_consortium_manager/templates/anvil_consortium_manager/account_confirm_unlink_user.html b/anvil_consortium_manager/templates/anvil_consortium_manager/account_confirm_unlink_user.html new file mode 100644 index 00000000..ed31553d --- /dev/null +++ b/anvil_consortium_manager/templates/anvil_consortium_manager/account_confirm_unlink_user.html @@ -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 %} +
+ Are you sure you want to unlink {{ object.user }} from {{ object }}? +
+ + + +Ths account was previously linked to the following user(s):
+