From 447ce1039a9631b97ab668f777667cdc404d1140 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Tue, 12 Dec 2023 10:09:43 +0100 Subject: [PATCH] Add alternate accounts to the user model Introduce a way to store alternate accounts on the user, and add the `PATCH /bot/users//alts` endpoint, which allows updating the user's alt accounts to the alt accounts in the request.. --- .../apps/api/migrations/0093_user_alts.py | 41 ++ ...e_0093_user_alts_0095_user_display_name.py | 14 + pydis_site/apps/api/models/__init__.py | 3 +- pydis_site/apps/api/models/bot/__init__.py | 2 +- pydis_site/apps/api/models/bot/user.py | 51 +- pydis_site/apps/api/serializers.py | 56 ++- pydis_site/apps/api/tests/test_infractions.py | 2 + pydis_site/apps/api/tests/test_users.py | 446 +++++++++++++++++- pydis_site/apps/api/viewsets/bot/user.py | 183 ++++++- pyproject.toml | 2 +- 10 files changed, 785 insertions(+), 15 deletions(-) create mode 100644 pydis_site/apps/api/migrations/0093_user_alts.py create mode 100644 pydis_site/apps/api/migrations/0096_merge_0093_user_alts_0095_user_display_name.py diff --git a/pydis_site/apps/api/migrations/0093_user_alts.py b/pydis_site/apps/api/migrations/0093_user_alts.py new file mode 100644 index 000000000..fa5f2102b --- /dev/null +++ b/pydis_site/apps/api/migrations/0093_user_alts.py @@ -0,0 +1,41 @@ +# Generated by Django 5.0 on 2023-12-14 13:14 + +import django.db.models.deletion +import pydis_site.apps.api.models.mixins +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0092_remove_redirect_filter_list'), + ] + + operations = [ + migrations.CreateModel( + name='UserAltRelationship', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('context', models.TextField(help_text='The reason for why this account was associated as an alt.', max_length=1900)), + ('actor', models.ForeignKey(help_text='The moderator that associated these accounts together.', on_delete=django.db.models.deletion.CASCADE, related_name='+', to='api.user')), + ('source', models.ForeignKey(help_text='The source of this user to alternate account relationship', on_delete=django.db.models.deletion.CASCADE, to='api.user', verbose_name='Source')), + ('target', models.ForeignKey(help_text='The target of this user to alternate account relationship', on_delete=django.db.models.deletion.CASCADE, related_name='+', to='api.user', verbose_name='Target')), + ], + bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model), + ), + migrations.AddField( + model_name='user', + name='alts', + field=models.ManyToManyField(help_text='Known alternate accounts of this user. Manually linked.', through='api.UserAltRelationship', to='api.user', verbose_name='Alternative accounts'), + ), + migrations.AddConstraint( + model_name='useraltrelationship', + constraint=models.UniqueConstraint(fields=('source', 'target'), name='api_useraltrelationship_unique_relationships'), + ), + migrations.AddConstraint( + model_name='useraltrelationship', + constraint=models.CheckConstraint(check=models.Q(('source', models.F('target')), _negated=True), name='api_useraltrelationship_prevent_alt_to_self'), + ), + ] diff --git a/pydis_site/apps/api/migrations/0096_merge_0093_user_alts_0095_user_display_name.py b/pydis_site/apps/api/migrations/0096_merge_0093_user_alts_0095_user_display_name.py new file mode 100644 index 000000000..eaf2b66ee --- /dev/null +++ b/pydis_site/apps/api/migrations/0096_merge_0093_user_alts_0095_user_display_name.py @@ -0,0 +1,14 @@ +# Generated by Django 5.0 on 2024-05-20 05:14 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0093_user_alts'), + ('api', '0095_user_display_name'), + ] + + operations = [ + ] diff --git a/pydis_site/apps/api/models/__init__.py b/pydis_site/apps/api/models/__init__.py index 5901c9787..7ac463b13 100644 --- a/pydis_site/apps/api/models/__init__.py +++ b/pydis_site/apps/api/models/__init__.py @@ -19,5 +19,6 @@ OffTopicChannelName, Reminder, Role, - User + User, + UserAltRelationship ) diff --git a/pydis_site/apps/api/models/bot/__init__.py b/pydis_site/apps/api/models/bot/__init__.py index c07a32385..a17c70ff6 100644 --- a/pydis_site/apps/api/models/bot/__init__.py +++ b/pydis_site/apps/api/models/bot/__init__.py @@ -16,4 +16,4 @@ from .offensive_message import OffensiveMessage from .reminder import Reminder from .role import Role -from .user import User +from .user import User, UserAltRelationship diff --git a/pydis_site/apps/api/models/bot/user.py b/pydis_site/apps/api/models/bot/user.py index 1cb109883..4d317b8e1 100644 --- a/pydis_site/apps/api/models/bot/user.py +++ b/pydis_site/apps/api/models/bot/user.py @@ -4,7 +4,7 @@ from django.db import models from pydis_site.apps.api.models.bot.role import Role -from pydis_site.apps.api.models.mixins import ModelReprMixin +from pydis_site.apps.api.models.mixins import ModelReprMixin, ModelTimestampMixin def _validate_existing_role(value: int) -> None: @@ -66,6 +66,13 @@ class User(ModelReprMixin, models.Model): help_text="Whether this user is in our server.", verbose_name="In Guild" ) + alts = models.ManyToManyField( + 'self', + through='UserAltRelationship', + through_fields=('source', 'target'), + help_text="Known alternate accounts of this user. Manually linked.", + verbose_name="Alternative accounts" + ) def __str__(self): """Returns the name and discriminator for the current user, for display purposes.""" @@ -91,3 +98,45 @@ def username(self) -> str: For usability in read-only fields such as Django Admin. """ return str(self) + + +class UserAltRelationship(ModelReprMixin, ModelTimestampMixin, models.Model): + """A relationship between a Discord user and its alts.""" + + source = models.ForeignKey( + User, + on_delete=models.CASCADE, + verbose_name="Source", + help_text="The source of this user to alternate account relationship", + ) + target = models.ForeignKey( + User, + on_delete=models.CASCADE, + verbose_name="Target", + related_name='+', + help_text="The target of this user to alternate account relationship", + ) + context = models.TextField( + help_text="The reason for why this account was associated as an alt.", + max_length=1900 + ) + actor = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='+', + help_text="The moderator that associated these accounts together." + ) + + class Meta: + """Add constraints to prevent users from being an alt of themselves.""" + + constraints = [ + models.UniqueConstraint( + name="%(app_label)s_%(class)s_unique_relationships", + fields=["source", "target"] + ), + models.CheckConstraint( + name="%(app_label)s_%(class)s_prevent_alt_to_self", + check=~models.Q(source=models.F("target")), + ), + ] diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index 60d3637cf..2b4bf3956 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -11,6 +11,7 @@ ListSerializer, ModelSerializer, PrimaryKeyRelatedField, + SerializerMethodField, ValidationError ) from rest_framework.settings import api_settings @@ -35,7 +36,8 @@ OffensiveMessage, Reminder, Role, - User + User, + UserAltRelationship ) class FrozenFieldsMixin: @@ -507,7 +509,7 @@ def to_representation(self, instance: Infraction) -> dict: """Return the dictionary representation of this infraction.""" ret = super().to_representation(instance) - ret['user'] = UserSerializer(instance.user).data + ret['user'] = UserWithAltsSerializer(instance.user).data ret['actor'] = UserSerializer(instance.actor).data return ret @@ -663,6 +665,36 @@ def update(self, queryset: QuerySet, validated_data: list) -> list: return updated +class UserAltRelationshipSerializer(FrozenFieldsMixin, ModelSerializer): + """A class providing (de-)serialization of `UserAltRelationship` instances.""" + + actor = PrimaryKeyRelatedField(queryset=User.objects.all()) + source = PrimaryKeyRelatedField(queryset=User.objects.all()) + target = PrimaryKeyRelatedField(queryset=User.objects.all()) + + class Meta: + """Metadata defined for the Django REST Framework.""" + + model = UserAltRelationship + fields = ('source', 'target', 'actor', 'context', 'created_at', 'updated_at') + frozen_fields = ('source', 'target', 'actor') + depth = 1 + + def to_representation(self, instance: UserAltRelationship) -> dict: + """Add the alts of the target to the representation.""" + representation = super().to_representation(instance) + representation['alts'] = tuple( + user_id + for (user_id,) in ( + UserAltRelationship.objects + .filter(source=instance.target) + .values_list('target_id') + ) + ) + return representation + + + class UserSerializer(ModelSerializer): """A class providing (de-)serialization of `User` instances.""" @@ -685,6 +717,26 @@ def create(self, validated_data: dict) -> User: raise ValidationError({"id": ["User with ID already present."]}) +class UserWithAltsSerializer(FrozenFieldsMixin, UserSerializer): + """A class providing (de-)serialization of `User` instances, expanding their alternate accounts.""" + + alts = SerializerMethodField() + + class Meta: + """Metadata defined for the Django REST Framework.""" + + model = User + fields = ('id', 'name', 'display_name', 'discriminator', 'roles', 'in_guild', 'alts') + frozen_fields = ('alts',) + + def get_alts(self, user: User) -> list[dict]: + """Retrieve the alts with all additional data on them.""" + return [ + UserAltRelationshipSerializer(alt).data + for alt in user.alts.through.objects.filter(source=user) + ] + + class NominationEntrySerializer(ModelSerializer): """A class providing (de-)serialization of `NominationEntry` instances.""" diff --git a/pydis_site/apps/api/tests/test_infractions.py b/pydis_site/apps/api/tests/test_infractions.py index f1e54b1e0..82722285a 100644 --- a/pydis_site/apps/api/tests/test_infractions.py +++ b/pydis_site/apps/api/tests/test_infractions.py @@ -749,6 +749,8 @@ def check_expanded_fields(self, infraction): obj = infraction[key] for field in ('id', 'name', 'discriminator', 'roles', 'in_guild'): self.assertTrue(field in obj, msg=f'field "{field}" missing from {key}') + if key == 'user': + self.assertIn('alts', obj) def test_list_expanded(self): url = reverse('api:bot:infraction-list-expanded') diff --git a/pydis_site/apps/api/tests/test_users.py b/pydis_site/apps/api/tests/test_users.py index 5dda63441..3799b6319 100644 --- a/pydis_site/apps/api/tests/test_users.py +++ b/pydis_site/apps/api/tests/test_users.py @@ -4,7 +4,7 @@ from django.urls import reverse from .base import AuthenticatedAPITestCase -from pydis_site.apps.api.models import Infraction, Role, User +from pydis_site.apps.api.models import Infraction, Role, User, UserAltRelationship from pydis_site.apps.api.models.bot.metricity import NotFoundError from pydis_site.apps.api.viewsets.bot.user import UserListPagination @@ -651,3 +651,447 @@ def test_search_lookup_of_unknown_user(self) -> None: result = response.json() self.assertEqual(result['count'], 0) self.assertEqual(result['results'], []) + + +class UserAltUpdateTests(AuthenticatedAPITestCase): + @classmethod + def setUpTestData(cls): + cls.user_1 = User.objects.create( + id=12095219, + name=f"Test user {random.randint(100, 1000)}", + discriminator=random.randint(1, 9999), + in_guild=True, + ) + cls.user_2 = User.objects.create( + id=18259125, + name=f"Test user {random.randint(100, 1000)}", + discriminator=random.randint(1, 9999), + in_guild=True, + ) + + def test_adding_existing_alt(self) -> None: + url = reverse('api:bot:user-alts', args=(self.user_1.id,)) + data = { + 'target': self.user_2.id, + 'actor': self.user_1.id, + 'context': "Joe's trolling account" + } + response = self.client.post(url, data) + self.assertEqual(response.status_code, 204) + + self.user_1.refresh_from_db() + self.user_2.refresh_from_db() + + self.assertQuerySetEqual(self.user_1.alts.all(), [self.user_2]) + self.assertQuerySetEqual(self.user_2.alts.all(), [self.user_1]) + + detail_url = reverse('api:bot:user-detail', args=(self.user_1.id,)) + detail_response = self.client.get(detail_url) + self.assertEqual(detail_response.status_code, 200) + + parsed_detail = detail_response.json() + [parsed_alt] = parsed_detail.pop('alts') + parsed_alt.pop('created_at') + parsed_alt.pop('updated_at') + + self.assertEqual( + parsed_detail, + { + 'id': self.user_1.id, + 'name': self.user_1.name, + 'display_name': self.user_1.display_name, + 'discriminator': self.user_1.discriminator, + 'roles': self.user_1.roles, + 'in_guild': self.user_1.in_guild, + } + ) + self.assertEqual( + parsed_alt, + { + 'source': self.user_1.id, + 'target': data['target'], + 'alts': [self.user_1.id], + 'actor': data['actor'], + 'context': data['context'], + } + ) + + def test_adding_existing_alt_twice_via_source(self) -> None: + self.verify_adding_existing_alt(add_on_source=True) + + def test_adding_existing_alt_twice_via_target(self) -> None: + self.verify_adding_existing_alt(add_on_source=False) + + def verify_adding_existing_alt(self, add_on_source: bool) -> None: + url = reverse('api:bot:user-alts', args=(self.user_1.id,)) + data = { + 'target': self.user_2.id, + 'actor': self.user_1.id, + 'context': "Lemon's trolling account" + } + initial_response = self.client.post(url, data) + self.assertEqual(initial_response.status_code, 204) + + if add_on_source: + repeated_url = url + repeated_data = data + else: + repeated_url = reverse('api:bot:user-alts', args=(self.user_2.id,)) + repeated_data = { + 'target': self.user_1.id, + 'actor': self.user_1.id, + 'context': data['context'] + } + + response = self.client.post(repeated_url, repeated_data) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), { + 'source': ["This relationship has already been established"] + }) + + def test_removing_existing_alt_source_from_target(self) -> None: + self.verify_deletion(delete_on_source=False) + + def test_removing_existing_alt_target_from_source(self) -> None: + self.verify_deletion(delete_on_source=True) + + def verify_deletion(self, delete_on_source: bool) -> None: + url = reverse('api:bot:user-alts', args=(self.user_1.id,)) + data = { + 'target': self.user_2.id, + 'actor': self.user_1.id, + 'context': "Lemon's trolling account" + } + initial_response = self.client.post(url, data) + self.assertEqual(initial_response.status_code, 204) + + self.assertTrue(self.user_1.alts.all().exists()) + self.assertTrue(self.user_2.alts.all().exists()) + + if delete_on_source: + data = self.user_2.id + alts_url = reverse('api:bot:user-alts', args=(self.user_1.id,)) + else: + data = self.user_1.id + alts_url = reverse('api:bot:user-alts', args=(self.user_2.id,)) + + response = self.client.delete(alts_url, data) + + self.assertEqual(response.status_code, 204) + + self.user_1.refresh_from_db() + self.user_2.refresh_from_db() + + self.assertFalse(self.user_1.alts.all().exists()) + self.assertFalse(self.user_2.alts.all().exists()) + + def test_removing_unknown_alt(self) -> None: + data = self.user_1.id + self.user_2.id + url = reverse('api:bot:user-alts', args=(self.user_1.id,)) + response = self.client.delete(url, data) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), { + 'non_field_errors': ["Specified account is not a known alternate account of this user"] + }) + + def test_add_alt_returns_error_for_missing_keys_in_request_body(self) -> None: + url = reverse('api:bot:user-alts', args=(self.user_1.id,)) + data = {'hello': 'joe'} + response = self.client.post(url, data) + self.assertEqual(response.status_code, 400) + + def test_remove_alt_returns_error_for_non_int_request_body(self) -> None: + url = reverse('api:bot:user-alts', args=(self.user_1.id,)) + data = {'hello': 'joe'} + response = self.client.delete(url, data) + self.assertEqual(response.status_code, 400) + + def test_adding_alt_to_user_that_does_not_exist(self) -> None: + """Patching a user's alts for a user that doesn't exist should return a 404.""" + url = reverse('api:bot:user-alts', args=(self.user_1.id + self.user_2.id,)) + data = { + 'target': self.user_2.id, + 'actor': self.user_1.id, + 'context': "Chris's trolling account" + } + response = self.client.post(url, data) + self.assertEqual(response.status_code, 404) + + def test_adding_alt_that_does_not_exist_to_user(self) -> None: + """Patching a user's alts with an alt that is unknown should return a 400.""" + url = reverse('api:bot:user-alts', args=(self.user_1.id,)) + data = { + 'target': self.user_1.id + self.user_2.id, + 'actor': self.user_1.id, + 'context': "Hello, Joe" + } + response = self.client.post(url, data) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), { + 'target': [f'Invalid pk "{data["target"]}" - object does not exist.'] + }) + + def test_cannot_add_self_as_alt_account(self) -> None: + """The existing account may not be an alt of itself.""" + url = reverse('api:bot:user-alts', args=(self.user_1.id,)) + data = { + 'target': self.user_1.id, + 'actor': self.user_1.id, + 'context': "Schizophrenia" + } + response = self.client.post(url, data) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), { + 'source': ["The user may not be an alternate account of itself"] + }) + + def test_cannot_update_alts_on_regular_user_patch_route(self) -> None: + """The regular user update route does not allow editing the alts.""" + url = reverse('api:bot:user-detail', args=(self.user_1.id,)) + data = {'alts': [self.user_2.id]} + response = self.client.patch(url, data) + self.assertEqual(response.status_code, 200) # XXX: This seems to be a DRF bug + + self.user_1.refresh_from_db() + self.assertQuerySetEqual(self.user_1.alts.all(), ()) + self.user_2.refresh_from_db() + self.assertQuerySetEqual(self.user_2.alts.all(), ()) + + def test_cannot_update_alts_on_bulk_user_patch_route(self) -> None: + """The bulk user update route does not allow editing the alts.""" + url = reverse('api:bot:user-bulk-patch') + data = [{'id': self.user_1.id, 'alts': [self.user_2.id]}] + response = self.client.patch(url, data) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), {'non_field_errors': ['Insufficient data provided.']}) + + self.user_1.refresh_from_db() + self.assertQuerySetEqual(self.user_1.alts.all(), ()) + self.user_2.refresh_from_db() + self.assertQuerySetEqual(self.user_2.alts.all(), ()) + + def test_user_bulk_patch_does_not_discard_alts(self) -> None: + """The bulk user update route should not modify the alts.""" + alts_url = reverse('api:bot:user-alts', args=(self.user_1.id,)) + data = { + 'target': self.user_2.id, + 'actor': self.user_2.id, + 'context': "This is my testing account" + } + alts_response = self.client.post(alts_url, data) + self.assertEqual(alts_response.status_code, 204) + + url = reverse('api:bot:user-bulk-patch') + self.user_1.alts.set((self.user_2,)) + data = [{'id': self.user_1.id, 'name': "Joe Armstrong"}] + response = self.client.patch(url, data) + self.assertEqual(response.status_code, 200) + + self.user_1.refresh_from_db() + self.assertQuerySetEqual(self.user_1.alts.all(), (self.user_2,)) + self.user_2.refresh_from_db() + self.assertQuerySetEqual(self.user_2.alts.all(), (self.user_1,)) + + +class UserAltUpdateWithExistingAltsTests(AuthenticatedAPITestCase): + @classmethod + def setUpTestData(cls): + cls.user_1 = User.objects.create( + id=12095219, + name=f"Test user {random.randint(100, 1000)}", + discriminator=random.randint(1, 9999), + in_guild=True, + ) + cls.user_2 = User.objects.create( + id=18259125, + name=f"Test user {random.randint(100, 1000)}", + discriminator=random.randint(1, 9999), + in_guild=True, + ) + cls.relationship_1 = UserAltRelationship.objects.create( + source=cls.user_1, + target=cls.user_2, + context="Test user's trolling account", + actor=cls.user_1 + ) + cls.relationship_2 = UserAltRelationship.objects.create( + source=cls.user_2, + target=cls.user_1, + context="Test user's trolling account", + actor=cls.user_1 + ) + + def test_returns_404_for_unknown_user(self) -> None: + url = reverse('api:bot:user-alts', args=(self.user_1.id + self.user_2.id,)) + response = self.client.patch(url, {'target': self.user_2.id, 'context': "Dinoman"}) + self.assertEqual(response.status_code, 404) + + def test_returns_400_for_unknown_alt_from_source(self) -> None: + self.verify_returns_400_for_unknown_alt(from_source=True) + + def test_returns_400_for_unknown_alt_from_target(self) -> None: + self.verify_returns_400_for_unknown_alt(from_source=False) + + def verify_returns_400_for_unknown_alt(self, from_source: bool) -> None: + if from_source: + source = self.user_1.id + else: + source = self.user_2.id + + target = self.user_1.id + self.user_2.id + + url = reverse('api:bot:user-alts', args=(source,)) + data = {'target': target, 'context': "Still a trolling account"} + response = self.client.patch(url, data) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), { + 'target': ["User is not an associated alt account"] + }) + + def test_returns_400_for_missing_fields(self) -> None: + url = reverse('api:bot:user-alts', args=(self.user_1.id,)) + payloads = [{'target': self.user_2.id}, {'context': "Confirmed"}] + for payload in payloads: + key = next(iter(payload)) + (missing_key,) = tuple({'target', 'context'} - {key}) + with self.subTest(specified_key=next(iter(payload))): + response = self.client.patch(url, payload) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), { + missing_key: ["This field is required."] + }) + + def test_accepts_valid_update_from_source(self) -> None: + self.verify_accepts_valid_update(from_source=True) + + def test_accepts_valid_update_from_target(self) -> None: + self.verify_accepts_valid_update(from_source=False) + + def verify_accepts_valid_update(self, from_source: bool) -> None: + if from_source: + source = self.user_1.id + target = self.user_2.id + else: + source = self.user_2.id + target = self.user_1.id + + url = reverse('api:bot:user-alts', args=(source,)) + data = {'target': target, 'context': "Still a trolling account"} + response = self.client.patch(url, data) + self.assertEqual(response.status_code, 204) + + self.relationship_1.refresh_from_db() + self.relationship_2.refresh_from_db() + self.assertEqual(self.relationship_1.context, data['context']) + self.assertEqual(self.relationship_2.context, data['context']) + + def test_retrieving_alts_via_source(self) -> None: + self.verify_retrieving_alts(from_source=True) + + def test_retrieving_alts_via_target(self) -> None: + self.verify_retrieving_alts(from_source=False) + + def verify_retrieving_alts(self, from_source: bool) -> None: + if from_source: + source = self.user_1.id + target = self.user_2.id + else: + source = self.user_2.id + target = self.user_1.id + + url = reverse('api:bot:user-detail', args=(source,)) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + body = response.json() + [alt] = body['alts'] + alt.pop('created_at') + alt.pop('updated_at') + self.assertEqual(alt, + { + 'actor': self.relationship_1.actor.id, + 'source': source, + 'alts': [source], + 'target': target, + 'context': self.relationship_1.context + } + ) + + +class UserAltUpdateWithExistingTransitiveAltsTests(AuthenticatedAPITestCase): + """ + Test user alt methods via transitive alternate account relationships. + + Specifically, user 2 is an alt account of user 1, and user 3 is an alt + account of user 2. However, user 3 should not be an alt account of + user 1 in this case. + """ + + @classmethod + def setUpTestData(cls): + cls.user_1 = User.objects.create( + id=12095219, + name=f"Test user {random.randint(100, 1000)}", + discriminator=random.randint(1, 9999), + in_guild=True, + ) + cls.user_2 = User.objects.create( + id=18259125, + name=f"Test user {random.randint(100, 1000)}", + discriminator=random.randint(1, 9999), + in_guild=True, + ) + cls.user_3 = User.objects.create( + id=18294612591, + name=f"Test user {random.randint(100, 1000)}", + discriminator=random.randint(1, 9999), + in_guild=True, + ) + cls.relationship_1 = UserAltRelationship.objects.create( + source=cls.user_1, + target=cls.user_2, + context="Test user's trolling account (rel 1, U1 -> U2)", + actor=cls.user_1 + ) + cls.relationship_2 = UserAltRelationship.objects.create( + source=cls.user_2, + target=cls.user_1, + context="Test user's trolling account (rel 2, U2 -> U1)", + actor=cls.user_1 + ) + cls.relationship_3 = UserAltRelationship.objects.create( + source=cls.user_2, + target=cls.user_3, + context="Test user's trolling account (rel 3, U2 -> U3)", + actor=cls.user_2 + ) + cls.relationship_4 = UserAltRelationship.objects.create( + source=cls.user_3, + target=cls.user_2, + context="Test user's trolling account (rel 4, U3 -> U2)", + actor=cls.user_2 + ) + + def test_retrieving_alts_via_source(self) -> None: + subtests = [ + # Source user, Expected sub-alts of each alt + # U1, ({U2 -> {U1, U3}},) + (self.user_1.id, ({self.user_1.id, self.user_3.id},)), + # U2, ({U1 -> U2}, {U3 -> U2},) + (self.user_2.id, ({self.user_2.id}, {self.user_2.id})), + # U3, ({U2 -> {U1, U3}},) + (self.user_3.id, ({self.user_1.id, self.user_3.id},)), + ] + + for (source, expected_subalts) in subtests: + with self.subTest(source=source): + url = reverse('api:bot:user-detail', args=(source,)) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + body = response.json() + for alt, subalts in zip(body['alts'], expected_subalts, strict=True): + alt.pop('created_at') + alt.pop('updated_at') + + self.assertEqual(len(set(alt['alts'])), len(subalts)) + self.assertEqual(set(alt['alts']), subalts) + self.assertEqual(alt['source'], source) diff --git a/pydis_site/apps/api/viewsets/bot/user.py b/pydis_site/apps/api/viewsets/bot/user.py index d06eb8682..c0b4ca0f4 100644 --- a/pydis_site/apps/api/viewsets/bot/user.py +++ b/pydis_site/apps/api/viewsets/bot/user.py @@ -1,5 +1,7 @@ -from collections import OrderedDict +from collections import ChainMap, OrderedDict +from django.core.exceptions import ObjectDoesNotExist +from django.db import IntegrityError, transaction from django.db.models import Q from django_filters.rest_framework import DjangoFilterBackend from rest_framework import fields, status @@ -13,8 +15,12 @@ from pydis_site.apps.api.models.bot.infraction import Infraction from pydis_site.apps.api.models.bot.metricity import Metricity, NotFoundError -from pydis_site.apps.api.models.bot.user import User -from pydis_site.apps.api.serializers import UserSerializer +from pydis_site.apps.api.models.bot.user import User, UserAltRelationship +from pydis_site.apps.api.serializers import ( + UserSerializer, + UserAltRelationshipSerializer, + UserWithAltsSerializer +) class UserListPagination(PageNumberPagination): @@ -93,6 +99,17 @@ class UserViewSet(ModelViewSet): #### Response format >>> { + ... 'alts': [ + ... { + ... 'actor': 1234, + ... # Note that alt relationships are not transitive. + ... 'alts': [409107086526644234, 9012571029], + ... 'context': "Testing account", + ... 'source': 409107086526644234, + ... 'target': 128025 + ... }, + ... # ... + ... ], ... 'id': 409107086526644234, ... 'name': "python", ... 'display_name': "Python", @@ -208,8 +225,9 @@ class UserViewSet(ModelViewSet): - 404: if the user with the given `snowflake` could not be found ### PATCH /bot/users/ - Update the user with the given `snowflake`. - All fields in the request body are optional. + Update the user with the given `snowflake`. All fields in the request body + are optional. Note that editing the `'alts'` field is not possible this + way, use the `alts` endpoint documented below for this. #### Request body >>> { @@ -226,9 +244,61 @@ class UserViewSet(ModelViewSet): - 400: if the request body was invalid, see response body for details - 404: if the user with the given `snowflake` could not be found + ### POST /bot/users//alts + Add the alternative account given in the request body to the alternative + accounts for the given user snowflake. Users will be linked symmetrically. + + #### Request body + >>> { + ... # The target alternate account to associate the user (from the URL) with. + ... 'target': int, + ... # A description for why this relationship was established. + ... 'context': str, + ... # The moderator that associated the accounts together. + ... 'actor': int + ... } + + #### Status codes + - 204: returned on success + - 400: if the request body was invalid, including if the user in the + request body could not be found in the database + - 404: if the user with the given `snowflake` could not be found + + ### PATCH /bot/users//alts + Update the context of the given alt account on the given user with the + context specified in the request body. + + #### Request body + >>> { + ... 'context': str, + ... 'target': int + ... } + + #### Status codes + - 204: returned on success + - 400: if the request body was invalid, including if the alternate + account could not be found as an associated account with the parent + user account record + - 404: if the user with the given `snowflake` could not be found + + ### DELETE /bot/users//alts + Remove the alternative account given in the request body from the + alternative accounts of the given user snowflake. The request body contains + the user ID to remove from the association to this user, as a plain + integer. Users will be linked symmetrically. Returns the updated user. + + #### Request body + >>> int + + #### Status codes + - 204: returned on success + - 400: if the user in the request body was not found as an alt account + - 404: if the user with the given `snowflake` could not be found + ### BULK PATCH /bot/users/bulk_patch - Update users with the given `ids` and `details`. - `id` field and at least one other field is mandatory. + Update users with the given `ids` and `details`. `id` field and at least + one other field is mandatory. Note that editing the `'alts'` field is not + possible using this endpoint. #### Request body >>> [ @@ -271,10 +341,15 @@ class UserViewSet(ModelViewSet): filterset_fields = ('name', 'discriminator', 'display_name') def get_serializer(self, *args, **kwargs) -> ModelSerializer: - """Set Serializer many attribute to True if request body contains a list.""" + """Customize serializers used based on the requested action.""" if isinstance(kwargs.get('data', {}), list): kwargs['many'] = True + # If we are retrieving a single user, user the expanded serializer + # which also includes context information for each alternate account. + if self.action == 'retrieve' and self.detail: + return UserWithAltsSerializer(*args, **kwargs) + return super().get_serializer(*args, **kwargs) @action(detail=False, methods=["PATCH"], name='user-bulk-patch') @@ -292,6 +367,98 @@ def bulk_patch(self, request: Request) -> Response: return Response(serializer.data, status=status.HTTP_200_OK) + @action(detail=True, methods=['POST'], name="Add alternate account", + url_name='alts', url_path='alts') + def add_alt(self, request: Request, pk: str) -> Response: + """Associate the given account to the user's alternate accounts.""" + user_id = self.get_object().id + source_serializer_data = ChainMap({'source': user_id}, request.data) + source_serializer = UserAltRelationshipSerializer(data=source_serializer_data) + source_serializer.is_valid(raise_exception=True) + + # This code is somewhat vulnerable to a race condition, in case the first validation + # from above passes and directly before validating again, one of the users that + # are referenced here are deleted. Unfortunately, since Django on its own does not + # query in "both directions", we just need to live with a race condition for validation. + # For inserts atomicity is guaranteed as we run it in a transaction. + target_id = source_serializer.validated_data['target'].id + target_serializer_data = ChainMap({'source': target_id, 'target': user_id}, request.data) + target_serializer = UserAltRelationshipSerializer(data=target_serializer_data) + target_serializer.is_valid(raise_exception=True) # should not fail, or inconsistent db + + with transaction.atomic(): + try: + source_serializer.save() + target_serializer.save() + except IntegrityError as err: + if err.__cause__.diag.constraint_name == 'api_useraltrelationship_prevent_alt_to_self': + raise ParseError(detail={ + "source": ["The user may not be an alternate account of itself"] + }) + if err.__cause__.diag.constraint_name == 'api_useraltrelationship_unique_relationships': + raise ParseError(detail={ + "source": ["This relationship has already been established"] + }) + + raise # pragma: no cover + + return Response(status=status.HTTP_204_NO_CONTENT) + + # See https://www.django-rest-framework.org/api-guide/viewsets/#routing-additional-http-methods-for-extra-actions + # I would really like to include the alternate account to patch in the URL + # here, but unfortunately it's not possible to do so because the mapping + # does not accept arguments, and if I were to use the same @action decorator, + # it would overwrite the other methods and keep them from working. + @add_alt.mapping.patch + def update_alt(self, request: Request, pk: str) -> Response: + """Update the context of a single alt.""" + for field in ('target', 'context'): + if field not in request.data: + raise ParseError(detail={field: ["This field is required."]}) + + source = self.get_object() + target = request.data['target'] + with transaction.atomic(): + associated_accounts = ( + UserAltRelationship.objects + .filter(Q(source=source, target=target) | Q(source=target, target=source)) + .select_for_update() + ) + count = associated_accounts.count() + # Sanity check + assert count in (0, 2), f"Inconsistent database for alts of {source.id} -> {target}" + + if count == 0: + raise ParseError(detail={'target': ["User is not an associated alt account"]}) + + updated = associated_accounts.update(context=request.data['context']) + assert updated == 2 + + return Response(status=status.HTTP_204_NO_CONTENT) + + @add_alt.mapping.delete + def remove_alt(self, request: Request, pk: str) -> Response: + """Disassociate the given account from the user's alternate accounts.""" + user = self.get_object() + if not isinstance(request.data, int): + raise ParseError(detail={"non_field_errors": ["Request body should be an integer"]}) + try: + alt = user.alts.get(id=request.data) + except ObjectDoesNotExist: + raise ParseError(detail={ + 'non_field_errors': ["Specified account is not a known alternate account of this user"] + }) + + # It is mandatory that this query performs a symmetrical delete, + # because our custom ManyToManyField through model specifies more than + # two foreign keys to the user model, causing Django to no longer + # uphold the symmetry of the model. Correct function is verified by + # the tests, but in case you end up changing it, make sure that it + # works as expected! + user.alts.remove(alt) + + return Response(status=status.HTTP_204_NO_CONTENT) + @action(detail=True) def metricity_data(self, request: Request, pk: str | None = None) -> Response: """Request handler for metricity_data endpoint.""" diff --git a/pyproject.toml b/pyproject.toml index 62c91d7db..b2321d4da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ ignore = [ "DJ001", "DJ008", "RET504", "RUF005", "RUF012", - "S311", + "S101", "S311", "SIM102", "SIM108", ] select = ["ANN", "B", "C4", "D", "DJ", "DTZ", "E", "F", "ISC", "INT", "N", "PGH", "PIE", "RET", "RSE", "RUF", "S", "SIM", "T20", "TID", "UP", "W"]