aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar jchristgit <[email protected]>2024-05-23 22:49:34 +0200
committerGravatar GitHub <[email protected]>2024-05-23 21:49:34 +0100
commit089b7f4504c8d1316c5ad97f019735a090848582 (patch)
tree0364567ae31d79b6fd866a6f82cd89bfa4d2178e
parentMerge pull request #1325 from python-discord/dependabot/pip/ruff-0.4.5 (diff)
Add alternate accounts to the user model
Introduce a way to store alternate accounts on the user, and add the `PATCH /bot/users/<id:str>/alts` endpoint, which allows updating the user's alt accounts to the alt accounts in the request..
-rw-r--r--pydis_site/apps/api/migrations/0093_user_alts.py41
-rw-r--r--pydis_site/apps/api/migrations/0096_merge_0093_user_alts_0095_user_display_name.py14
-rw-r--r--pydis_site/apps/api/models/__init__.py3
-rw-r--r--pydis_site/apps/api/models/bot/__init__.py2
-rw-r--r--pydis_site/apps/api/models/bot/user.py51
-rw-r--r--pydis_site/apps/api/serializers.py56
-rw-r--r--pydis_site/apps/api/tests/test_infractions.py2
-rw-r--r--pydis_site/apps/api/tests/test_users.py446
-rw-r--r--pydis_site/apps/api/viewsets/bot/user.py183
-rw-r--r--pyproject.toml2
10 files changed, 785 insertions, 15 deletions
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 00000000..fa5f2102
--- /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 00000000..eaf2b66e
--- /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 5901c978..7ac463b1 100644
--- a/pydis_site/apps/api/models/__init__.py
+++ b/pydis_site/apps/api/models/__init__.py
@@ -19,5 +19,6 @@ from .bot import (
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 c07a3238..a17c70ff 100644
--- a/pydis_site/apps/api/models/bot/__init__.py
+++ b/pydis_site/apps/api/models/bot/__init__.py
@@ -16,4 +16,4 @@ from .off_topic_channel_name import OffTopicChannelName
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 1cb10988..4d317b8e 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.core.validators import MaxValueValidator, MinValueValidator
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 @@ class User(ModelReprMixin, models.Model):
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 60d3637c..2b4bf395 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -11,6 +11,7 @@ from rest_framework.serializers import (
ListSerializer,
ModelSerializer,
PrimaryKeyRelatedField,
+ SerializerMethodField,
ValidationError
)
from rest_framework.settings import api_settings
@@ -35,7 +36,8 @@ from .models import (
OffensiveMessage,
Reminder,
Role,
- User
+ User,
+ UserAltRelationship
)
class FrozenFieldsMixin:
@@ -507,7 +509,7 @@ class ExpandedInfractionSerializer(InfractionSerializer):
"""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 @@ class UserListSerializer(ListSerializer):
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 @@ class UserSerializer(ModelSerializer):
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 f1e54b1e..82722285 100644
--- a/pydis_site/apps/api/tests/test_infractions.py
+++ b/pydis_site/apps/api/tests/test_infractions.py
@@ -749,6 +749,8 @@ class ExpandedTests(AuthenticatedAPITestCase):
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 5dda6344..3799b631 100644
--- a/pydis_site/apps/api/tests/test_users.py
+++ b/pydis_site/apps/api/tests/test_users.py
@@ -4,7 +4,7 @@ from unittest.mock import Mock, patch
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 @@ class UserViewSetTests(AuthenticatedAPITestCase):
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 d06eb868..c0b4ca0f 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 rest_framework.viewsets import ModelViewSet
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/<snowflake:int>
- 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/<snowflake:int>/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/<snowflake:int>/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/<snowflake:int>/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 @@ class UserViewSet(ModelViewSet):
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 4ca2f02b..2a8130e0 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"]