Skip to content

Commit

Permalink
Add alternate accounts to the user model
Browse files Browse the repository at this point in the history
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..
  • Loading branch information
jchristgit committed May 20, 2024
1 parent 350c2b7 commit 447ce10
Show file tree
Hide file tree
Showing 10 changed files with 785 additions and 15 deletions.
41 changes: 41 additions & 0 deletions pydis_site/apps/api/migrations/0093_user_alts.py
Original file line number Diff line number Diff line change
@@ -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'),
),
]
Original file line number Diff line number Diff line change
@@ -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 = [
]
3 changes: 2 additions & 1 deletion pydis_site/apps/api/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@
OffTopicChannelName,
Reminder,
Role,
User
User,
UserAltRelationship
)
2 changes: 1 addition & 1 deletion pydis_site/apps/api/models/bot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
51 changes: 50 additions & 1 deletion pydis_site/apps/api/models/bot/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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."""
Expand All @@ -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")),
),
]
56 changes: 54 additions & 2 deletions pydis_site/apps/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
ListSerializer,
ModelSerializer,
PrimaryKeyRelatedField,
SerializerMethodField,
ValidationError
)
from rest_framework.settings import api_settings
Expand All @@ -35,7 +36,8 @@
OffensiveMessage,
Reminder,
Role,
User
User,
UserAltRelationship
)

class FrozenFieldsMixin:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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."""

Expand All @@ -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."""

Expand Down
2 changes: 2 additions & 0 deletions pydis_site/apps/api/tests/test_infractions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down

0 comments on commit 447ce10

Please sign in to comment.