diff --git a/arkindex/users/migrations/0017_right_unique_constraints.py b/arkindex/users/migrations/0017_right_unique_constraints.py new file mode 100644 index 0000000000000000000000000000000000000000..57b3276b592e77722e798c4cbe462c5740425d1e --- /dev/null +++ b/arkindex/users/migrations/0017_right_unique_constraints.py @@ -0,0 +1,68 @@ +# Generated by Django 4.0.1 on 2022-04-08 13:50 + +from django.db import migrations, models + +FORWARD_SQL = [ + """ + WITH duplicate_rights AS ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY group_id, content_type_id, content_id + ORDER BY level DESC + ) AS number + FROM users_right + WHERE user_id IS NULL + ) + DELETE FROM users_right + USING duplicate_rights + WHERE duplicate_rights.id = users_right.id + AND duplicate_rights.number > 1 + """, + """ + WITH duplicate_rights AS ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY user_id, content_type_id, content_id + ORDER BY level DESC + ) AS number + FROM users_right + WHERE group_id IS NULL + ) + DELETE FROM users_right + USING duplicate_rights + WHERE duplicate_rights.id = users_right.id + AND duplicate_rights.number > 1 + """ +] + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0016_alter_user_email_user_email_unique'), + ] + + operations = [ + migrations.RemoveConstraint( + model_name='right', + name='right_unique_target', + ), + migrations.RunSQL( + sql=FORWARD_SQL, + reverse_sql=migrations.RunSQL.noop, + ), + migrations.AddConstraint( + model_name='right', + constraint=models.UniqueConstraint( + condition=models.Q(user__isnull=False), + fields=('user', 'content_id', 'content_type'), + name='right_user_unique_target', + ), + ), + migrations.AddConstraint( + model_name='right', + constraint=models.UniqueConstraint( + condition=models.Q(group__isnull=False), + fields=('group', 'content_id', 'content_type'), + name='right_group_unique_target', + ), + ), + ] diff --git a/arkindex/users/models.py b/arkindex/users/models.py index 704a972de6ff3b68147aeef01127e3a35bb1859a..5fcd2f1beda12eaf35e03149b385d01546059119 100644 --- a/arkindex/users/models.py +++ b/arkindex/users/models.py @@ -44,15 +44,27 @@ class Right(models.Model): class Meta: constraints = [ - models.UniqueConstraint(fields=['user', 'group', 'content_id', 'content_type'], name='right_unique_target'), # User XOR Group is the owner of this access right models.CheckConstraint( name='user_xor_group', check=( models.Q(group_id__isnull=False, user_id__isnull=True) | models.Q(group_id__isnull=True, user_id__isnull=False) - ) - ) + ), + ), + # Ensure that the rights owner only has one Right instance for the same target. + # Since either user or group can be NULL, and a NULL in the fields of a unique constraint causes the + # constraint to still allow duplicates, this constraint is split in two partial constraints. + models.UniqueConstraint( + fields=['user', 'content_id', 'content_type'], + name='right_user_unique_target', + condition=models.Q(user__isnull=False), + ), + models.UniqueConstraint( + fields=['group', 'content_id', 'content_type'], + name='right_group_unique_target', + condition=models.Q(group__isnull=False), + ), ] diff --git a/arkindex/users/tests/test_generic_memberships.py b/arkindex/users/tests/test_generic_memberships.py index 0b02b581185612629fd37517184463d981215c04..510e8ddaf9b8f99dcea9a8c6e5fee727f4693dc0 100644 --- a/arkindex/users/tests/test_generic_memberships.py +++ b/arkindex/users/tests/test_generic_memberships.py @@ -1,5 +1,6 @@ import uuid +from django.db import IntegrityError from django.urls import reverse from rest_framework import status @@ -1063,3 +1064,15 @@ class TestMembership(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) with self.assertRaises(Right.DoesNotExist): new_member.refresh_from_db() + + def test_right_unique_user(self): + repo = Repository.objects.get(hook_token='worker-hook-token') + repo.memberships.create(user=self.user, level=Role.Admin.value) + with self.assertRaises(IntegrityError): + repo.memberships.create(user=self.user, level=Role.Admin.value) + + def test_right_unique_group(self): + repo = Repository.objects.get(hook_token='worker-hook-token') + repo.memberships.create(group=self.group, level=Role.Admin.value) + with self.assertRaises(IntegrityError): + repo.memberships.create(group=self.group, level=Role.Admin.value)