Skip to content

Commit 089b7f4

Browse files
authored
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..
1 parent 08c3456 commit 089b7f4

File tree

10 files changed

+785
-15
lines changed

10 files changed

+785
-15
lines changed
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Generated by Django 5.0 on 2023-12-14 13:14
2+
3+
import django.db.models.deletion
4+
import pydis_site.apps.api.models.mixins
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('api', '0092_remove_redirect_filter_list'),
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name='UserAltRelationship',
17+
fields=[
18+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
19+
('created_at', models.DateTimeField(auto_now_add=True)),
20+
('updated_at', models.DateTimeField(auto_now=True)),
21+
('context', models.TextField(help_text='The reason for why this account was associated as an alt.', max_length=1900)),
22+
('actor', models.ForeignKey(help_text='The moderator that associated these accounts together.', on_delete=django.db.models.deletion.CASCADE, related_name='+', to='api.user')),
23+
('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')),
24+
('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')),
25+
],
26+
bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model),
27+
),
28+
migrations.AddField(
29+
model_name='user',
30+
name='alts',
31+
field=models.ManyToManyField(help_text='Known alternate accounts of this user. Manually linked.', through='api.UserAltRelationship', to='api.user', verbose_name='Alternative accounts'),
32+
),
33+
migrations.AddConstraint(
34+
model_name='useraltrelationship',
35+
constraint=models.UniqueConstraint(fields=('source', 'target'), name='api_useraltrelationship_unique_relationships'),
36+
),
37+
migrations.AddConstraint(
38+
model_name='useraltrelationship',
39+
constraint=models.CheckConstraint(check=models.Q(('source', models.F('target')), _negated=True), name='api_useraltrelationship_prevent_alt_to_self'),
40+
),
41+
]
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Generated by Django 5.0 on 2024-05-20 05:14
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('api', '0093_user_alts'),
10+
('api', '0095_user_display_name'),
11+
]
12+
13+
operations = [
14+
]

pydis_site/apps/api/models/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,6 @@
1919
OffTopicChannelName,
2020
Reminder,
2121
Role,
22-
User
22+
User,
23+
UserAltRelationship
2324
)

pydis_site/apps/api/models/bot/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,4 @@
1616
from .offensive_message import OffensiveMessage
1717
from .reminder import Reminder
1818
from .role import Role
19-
from .user import User
19+
from .user import User, UserAltRelationship

pydis_site/apps/api/models/bot/user.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from django.db import models
55

66
from pydis_site.apps.api.models.bot.role import Role
7-
from pydis_site.apps.api.models.mixins import ModelReprMixin
7+
from pydis_site.apps.api.models.mixins import ModelReprMixin, ModelTimestampMixin
88

99

1010
def _validate_existing_role(value: int) -> None:
@@ -66,6 +66,13 @@ class User(ModelReprMixin, models.Model):
6666
help_text="Whether this user is in our server.",
6767
verbose_name="In Guild"
6868
)
69+
alts = models.ManyToManyField(
70+
'self',
71+
through='UserAltRelationship',
72+
through_fields=('source', 'target'),
73+
help_text="Known alternate accounts of this user. Manually linked.",
74+
verbose_name="Alternative accounts"
75+
)
6976

7077
def __str__(self):
7178
"""Returns the name and discriminator for the current user, for display purposes."""
@@ -91,3 +98,45 @@ def username(self) -> str:
9198
For usability in read-only fields such as Django Admin.
9299
"""
93100
return str(self)
101+
102+
103+
class UserAltRelationship(ModelReprMixin, ModelTimestampMixin, models.Model):
104+
"""A relationship between a Discord user and its alts."""
105+
106+
source = models.ForeignKey(
107+
User,
108+
on_delete=models.CASCADE,
109+
verbose_name="Source",
110+
help_text="The source of this user to alternate account relationship",
111+
)
112+
target = models.ForeignKey(
113+
User,
114+
on_delete=models.CASCADE,
115+
verbose_name="Target",
116+
related_name='+',
117+
help_text="The target of this user to alternate account relationship",
118+
)
119+
context = models.TextField(
120+
help_text="The reason for why this account was associated as an alt.",
121+
max_length=1900
122+
)
123+
actor = models.ForeignKey(
124+
User,
125+
on_delete=models.CASCADE,
126+
related_name='+',
127+
help_text="The moderator that associated these accounts together."
128+
)
129+
130+
class Meta:
131+
"""Add constraints to prevent users from being an alt of themselves."""
132+
133+
constraints = [
134+
models.UniqueConstraint(
135+
name="%(app_label)s_%(class)s_unique_relationships",
136+
fields=["source", "target"]
137+
),
138+
models.CheckConstraint(
139+
name="%(app_label)s_%(class)s_prevent_alt_to_self",
140+
check=~models.Q(source=models.F("target")),
141+
),
142+
]

pydis_site/apps/api/serializers.py

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
ListSerializer,
1212
ModelSerializer,
1313
PrimaryKeyRelatedField,
14+
SerializerMethodField,
1415
ValidationError
1516
)
1617
from rest_framework.settings import api_settings
@@ -35,7 +36,8 @@
3536
OffensiveMessage,
3637
Reminder,
3738
Role,
38-
User
39+
User,
40+
UserAltRelationship
3941
)
4042

4143
class FrozenFieldsMixin:
@@ -507,7 +509,7 @@ def to_representation(self, instance: Infraction) -> dict:
507509
"""Return the dictionary representation of this infraction."""
508510
ret = super().to_representation(instance)
509511

510-
ret['user'] = UserSerializer(instance.user).data
512+
ret['user'] = UserWithAltsSerializer(instance.user).data
511513
ret['actor'] = UserSerializer(instance.actor).data
512514

513515
return ret
@@ -663,6 +665,36 @@ def update(self, queryset: QuerySet, validated_data: list) -> list:
663665
return updated
664666

665667

668+
class UserAltRelationshipSerializer(FrozenFieldsMixin, ModelSerializer):
669+
"""A class providing (de-)serialization of `UserAltRelationship` instances."""
670+
671+
actor = PrimaryKeyRelatedField(queryset=User.objects.all())
672+
source = PrimaryKeyRelatedField(queryset=User.objects.all())
673+
target = PrimaryKeyRelatedField(queryset=User.objects.all())
674+
675+
class Meta:
676+
"""Metadata defined for the Django REST Framework."""
677+
678+
model = UserAltRelationship
679+
fields = ('source', 'target', 'actor', 'context', 'created_at', 'updated_at')
680+
frozen_fields = ('source', 'target', 'actor')
681+
depth = 1
682+
683+
def to_representation(self, instance: UserAltRelationship) -> dict:
684+
"""Add the alts of the target to the representation."""
685+
representation = super().to_representation(instance)
686+
representation['alts'] = tuple(
687+
user_id
688+
for (user_id,) in (
689+
UserAltRelationship.objects
690+
.filter(source=instance.target)
691+
.values_list('target_id')
692+
)
693+
)
694+
return representation
695+
696+
697+
666698
class UserSerializer(ModelSerializer):
667699
"""A class providing (de-)serialization of `User` instances."""
668700

@@ -685,6 +717,26 @@ def create(self, validated_data: dict) -> User:
685717
raise ValidationError({"id": ["User with ID already present."]})
686718

687719

720+
class UserWithAltsSerializer(FrozenFieldsMixin, UserSerializer):
721+
"""A class providing (de-)serialization of `User` instances, expanding their alternate accounts."""
722+
723+
alts = SerializerMethodField()
724+
725+
class Meta:
726+
"""Metadata defined for the Django REST Framework."""
727+
728+
model = User
729+
fields = ('id', 'name', 'display_name', 'discriminator', 'roles', 'in_guild', 'alts')
730+
frozen_fields = ('alts',)
731+
732+
def get_alts(self, user: User) -> list[dict]:
733+
"""Retrieve the alts with all additional data on them."""
734+
return [
735+
UserAltRelationshipSerializer(alt).data
736+
for alt in user.alts.through.objects.filter(source=user)
737+
]
738+
739+
688740
class NominationEntrySerializer(ModelSerializer):
689741
"""A class providing (de-)serialization of `NominationEntry` instances."""
690742

pydis_site/apps/api/tests/test_infractions.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,8 @@ def check_expanded_fields(self, infraction):
749749
obj = infraction[key]
750750
for field in ('id', 'name', 'discriminator', 'roles', 'in_guild'):
751751
self.assertTrue(field in obj, msg=f'field "{field}" missing from {key}')
752+
if key == 'user':
753+
self.assertIn('alts', obj)
752754

753755
def test_list_expanded(self):
754756
url = reverse('api:bot:infraction-list-expanded')

0 commit comments

Comments
 (0)