From ec2ae9d1ef38d44cfeeb4abbe3bda035afe93e2b Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 4 Oct 2019 08:13:18 +0200 Subject: Add validation rules to Infraction serializer https://github.com/python-discord/site/issues/273 This commit adds validation rules to the Infraction serializer that validate if a given infraction should be accepted based on its status of being considered `active`. If the validation fails, the API will reject the request and return a 400 status. Specifically, this validator checks that: - infractions that can never be active do not have `active=True` set; - a user can never receive a second active infraction of the same type. Tests have been added to `test_infractions.py` to ensure that the validators work as expected. This commit implements the first part of #273 --- pydis_site/apps/api/serializers.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) (limited to 'pydis_site/apps/api/serializers.py') diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index 326e20e1..784bb614 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -1,4 +1,5 @@ """Converters from Django models to data interchange formats and back.""" +import logging from rest_framework.serializers import ModelSerializer, PrimaryKeyRelatedField, ValidationError from rest_framework_bulk import BulkSerializerMixin @@ -12,6 +13,8 @@ from .models import ( Tag, User ) +log = logging.getLogger(__name__) + class BotSettingSerializer(ModelSerializer): """A class providing (de-)serialization of `BotSetting` instances.""" @@ -110,6 +113,16 @@ class InfractionSerializer(ModelSerializer): """Validate data constraints for the given data and abort if it is invalid.""" infr_type = attrs.get('type') + active = attrs.get('active') + if active and infr_type in ('note', 'warning', 'kick'): + raise ValidationError({'active': [f'{infr_type} infractions cannot be active.']}) + + user = attrs.get('user') + if active and Infraction.objects.filter(user=user, type=infr_type, active=True).exists(): + raise ValidationError( + {'active': [f'This user already has an active {infr_type} infraction']} + ) + expires_at = attrs.get('expires_at') if expires_at and infr_type in ('kick', 'warning'): raise ValidationError({'expires_at': [f'{infr_type} infractions cannot expire.']}) -- cgit v1.2.3 From 1ce639c6e560ff7aff405ad055e80b78efee8dfc Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sat, 19 Oct 2019 17:17:39 +0200 Subject: Apply feedback for pull request #278 https://github.com/python-discord/site/pull/278 This commit applies the feedback given to PR #278. I have made a number of significant changes: - I have added tests for the `MigrationsTestCase` class, including a test to see if the test case travels to the specified points in the migration history. - The dictionary of infracted users in the active migration tests now uses informative strings as keys. This makes it easier to relate a specific test to the data it's testing. - I have simplified the migration query logic by replacing unneeded Q objects by simpler keyword arguments to `filter` method calls. - I have removed the custom validation logic for allowing only one active infraction of a given type per user and replaced it by a `UniqueTogetherValidator` with a custom validation error message. - `test_unique_constraint_accepts_active_infraction_after_inactive_ infraction` relied on an Error results instead of a Fail result to indicate an issue with the database constraint. Since a fail should lead to a Fail status, I've replaced it by a try-except block that signals a test failure if an `IntegrityError` was caught by it. --- .../0046_active_infractions_migration.py | 7 +- pydis_site/apps/api/serializers.py | 18 +- .../migrations/test_active_infraction_migration.py | 415 +++++++++------------ pydis_site/apps/api/tests/migrations/test_base.py | 135 +++++++ pydis_site/apps/api/tests/test_infractions.py | 71 ++-- 5 files changed, 373 insertions(+), 273 deletions(-) create mode 100644 pydis_site/apps/api/tests/migrations/test_base.py (limited to 'pydis_site/apps/api/serializers.py') diff --git a/pydis_site/apps/api/migrations/0046_active_infractions_migration.py b/pydis_site/apps/api/migrations/0046_active_infractions_migration.py index 7e5a631c..cc53f58f 100644 --- a/pydis_site/apps/api/migrations/0046_active_infractions_migration.py +++ b/pydis_site/apps/api/migrations/0046_active_infractions_migration.py @@ -1,7 +1,7 @@ # Generated by Django 2.2.6 on 2019-10-07 15:59 from django.db import migrations -from django.db.models import Count, Prefetch, Q, QuerySet +from django.db.models import Count, Prefetch, QuerySet class ExpirationWrapper: @@ -33,9 +33,8 @@ class ExpirationWrapper: def migrate_inactive_types_to_inactive(apps, schema_editor): """Migrates infractions of non-active types to inactive.""" - inactive_infraction_types = Q(type="note") | Q(type="warning") | Q(type="kick") infraction_model = apps.get_model('api', 'Infraction') - infraction_model.objects.filter(inactive_infraction_types).update(active=False) + infraction_model.objects.filter(type__in=('note', 'warning', 'kick')).update(active=False) def get_query(user_model, infraction_model, infr_type: str) -> QuerySet: @@ -51,7 +50,7 @@ def get_query(user_model, infraction_model, infr_type: str) -> QuerySet: # Get users with active infraction(s) of the provided `infr_type` query = user_model.objects.filter( - Q(infractions_received__type=infr_type, infractions_received__active=True) + infractions_received__type=infr_type, infractions_received__active=True ) # Prefetch their active received infractions of `infr_type` and attach `.infractions` attribute diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index 784bb614..c3d5f696 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -1,7 +1,6 @@ """Converters from Django models to data interchange formats and back.""" -import logging - from rest_framework.serializers import ModelSerializer, PrimaryKeyRelatedField, ValidationError +from rest_framework.validators import UniqueTogetherValidator from rest_framework_bulk import BulkSerializerMixin from .models import ( @@ -13,8 +12,6 @@ from .models import ( Tag, User ) -log = logging.getLogger(__name__) - class BotSettingSerializer(ModelSerializer): """A class providing (de-)serialization of `BotSetting` instances.""" @@ -108,6 +105,13 @@ class InfractionSerializer(ModelSerializer): fields = ( 'id', 'inserted_at', 'expires_at', 'active', 'user', 'actor', 'type', 'reason', 'hidden' ) + validators = [ + UniqueTogetherValidator( + queryset=Infraction.objects.filter(active=True), + fields=['user', 'type'], + message='This user already has an active infraction of this type.', + ) + ] def validate(self, attrs: dict) -> dict: """Validate data constraints for the given data and abort if it is invalid.""" @@ -117,12 +121,6 @@ class InfractionSerializer(ModelSerializer): if active and infr_type in ('note', 'warning', 'kick'): raise ValidationError({'active': [f'{infr_type} infractions cannot be active.']}) - user = attrs.get('user') - if active and Infraction.objects.filter(user=user, type=infr_type, active=True).exists(): - raise ValidationError( - {'active': [f'This user already has an active {infr_type} infraction']} - ) - expires_at = attrs.get('expires_at') if expires_at and infr_type in ('kick', 'warning'): raise ValidationError({'expires_at': [f'{infr_type} infractions cannot expire.']}) diff --git a/pydis_site/apps/api/tests/migrations/test_active_infraction_migration.py b/pydis_site/apps/api/tests/migrations/test_active_infraction_migration.py index 61574b7e..c17a2083 100644 --- a/pydis_site/apps/api/tests/migrations/test_active_infraction_migration.py +++ b/pydis_site/apps/api/tests/migrations/test_active_infraction_migration.py @@ -114,20 +114,15 @@ class InfractionFactoryTests(MigrationsTestCase): def test_infraction_factory_multiple_users(self): """Does the test database hold as many infractions as we tried to create?""" - created_infractions = {} - for user in range(5): - created_infractions.update( - { - user: InfractionFactory.create( - actor=self.actor, - infractions=( - {'type': 'kick', 'active': False, 'hidden': True}, - {'type': 'ban', 'active': True, 'hidden': False}, - ), - infraction_model=self.infraction_model, - user_model=self.user_model, - ) - } + for _user in range(5): + InfractionFactory.create( + actor=self.actor, + infractions=( + {'type': 'kick', 'active': False, 'hidden': True}, + {'type': 'ban', 'active': True, 'hidden': False}, + ), + infraction_model=self.infraction_model, + user_model=self.user_model, ) # Check if infractions and users are recorded properly in the database @@ -204,245 +199,199 @@ class ActiveInfractionMigrationTests(MigrationsTestCase): ) # User #1: clean user with no infractions - cls.created_infractions.update( - { - 1: InfractionFactory.create( - actor=cls.user_moderator, - infractions=[], - infraction_model=cls.infraction_model, - user_model=cls.user_model, - ) - } + cls.created_infractions["no infractions"] = InfractionFactory.create( + actor=cls.user_moderator, + infractions=[], + infraction_model=cls.infraction_model, + user_model=cls.user_model, ) # User #2: One inactive note infraction - cls.created_infractions.update( - { - 2: InfractionFactory.create( - actor=cls.user_moderator, - infractions=( - {'type': 'note', 'active': False, 'hidden': True}, - ), - infraction_model=cls.infraction_model, - user_model=cls.user_model, - ) - } + cls.created_infractions["one inactive note"] = InfractionFactory.create( + actor=cls.user_moderator, + infractions=( + {'type': 'note', 'active': False, 'hidden': True}, + ), + infraction_model=cls.infraction_model, + user_model=cls.user_model, ) # User #3: One active note infraction - cls.created_infractions.update( - { - 3: InfractionFactory.create( - actor=cls.user_moderator, - infractions=( - {'type': 'note', 'active': True, 'hidden': True}, - ), - infraction_model=cls.infraction_model, - user_model=cls.user_model, - ) - } + cls.created_infractions["one active note"] = InfractionFactory.create( + actor=cls.user_moderator, + infractions=( + {'type': 'note', 'active': True, 'hidden': True}, + ), + infraction_model=cls.infraction_model, + user_model=cls.user_model, ) # User #4: One active and one inactive note infraction - cls.created_infractions.update( - { - 4: InfractionFactory.create( - actor=cls.user_moderator, - infractions=( - {'type': 'note', 'active': False, 'hidden': True}, - {'type': 'note', 'active': True, 'hidden': True}, - ), - infraction_model=cls.infraction_model, - user_model=cls.user_model, - ) - } + cls.created_infractions["one active and one inactive note"] = InfractionFactory.create( + actor=cls.user_moderator, + infractions=( + {'type': 'note', 'active': False, 'hidden': True}, + {'type': 'note', 'active': True, 'hidden': True}, + ), + infraction_model=cls.infraction_model, + user_model=cls.user_model, ) # User #5: Once active note, one active kick, once active warning - cls.created_infractions.update( - { - 5: InfractionFactory.create( - actor=cls.user_moderator, - infractions=( - {'type': 'note', 'active': True, 'hidden': True}, - {'type': 'kick', 'active': True, 'hidden': True}, - {'type': 'warning', 'active': True, 'hidden': True}, - ), - infraction_model=cls.infraction_model, - user_model=cls.user_model, - ) - } + cls.created_infractions["active note, kick, warning"] = InfractionFactory.create( + actor=cls.user_moderator, + infractions=( + {'type': 'note', 'active': True, 'hidden': True}, + {'type': 'kick', 'active': True, 'hidden': True}, + {'type': 'warning', 'active': True, 'hidden': True}, + ), + infraction_model=cls.infraction_model, + user_model=cls.user_model, ) # User #6: One inactive ban and one active ban - cls.created_infractions.update( - { - 6: InfractionFactory.create( - actor=cls.user_moderator, - infractions=( - {'type': 'ban', 'active': False, 'hidden': True}, - {'type': 'ban', 'active': True, 'hidden': True}, - ), - infraction_model=cls.infraction_model, - user_model=cls.user_model, - ) - } + cls.created_infractions["one inactive and one active ban"] = InfractionFactory.create( + actor=cls.user_moderator, + infractions=( + {'type': 'ban', 'active': False, 'hidden': True}, + {'type': 'ban', 'active': True, 'hidden': True}, + ), + infraction_model=cls.infraction_model, + user_model=cls.user_model, ) # User #7: Two active permanent bans - cls.created_infractions.update( - { - 7: InfractionFactory.create( - actor=cls.user_moderator, - infractions=( - {'type': 'ban', 'active': True, 'hidden': True}, - {'type': 'ban', 'active': True, 'hidden': True}, - ), - infraction_model=cls.infraction_model, - user_model=cls.user_model, - ) - } + cls.created_infractions["two active perm bans"] = InfractionFactory.create( + actor=cls.user_moderator, + infractions=( + {'type': 'ban', 'active': True, 'hidden': True}, + {'type': 'ban', 'active': True, 'hidden': True}, + ), + infraction_model=cls.infraction_model, + user_model=cls.user_model, ) # User #8: Multiple active temporary bans - cls.created_infractions.update( - { - 8: InfractionFactory.create( - actor=cls.user_moderator, - infractions=( - { - 'type': 'ban', - 'active': True, - 'hidden': True, - 'expires_at': timezone.now() + timedelta(days=1) - }, - { - 'type': 'ban', - 'active': True, - 'hidden': True, - 'expires_at': timezone.now() + timedelta(days=10) - }, - { - 'type': 'ban', - 'active': True, - 'hidden': True, - 'expires_at': timezone.now() + timedelta(days=20) - }, - { - 'type': 'ban', - 'active': True, - 'hidden': True, - 'expires_at': timezone.now() + timedelta(days=5) - }, - ), - infraction_model=cls.infraction_model, - user_model=cls.user_model, - ) - } + cls.created_infractions["multiple active temp bans"] = InfractionFactory.create( + actor=cls.user_moderator, + infractions=( + { + 'type': 'ban', + 'active': True, + 'hidden': True, + 'expires_at': timezone.now() + timedelta(days=1) + }, + { + 'type': 'ban', + 'active': True, + 'hidden': True, + 'expires_at': timezone.now() + timedelta(days=10) + }, + { + 'type': 'ban', + 'active': True, + 'hidden': True, + 'expires_at': timezone.now() + timedelta(days=20) + }, + { + 'type': 'ban', + 'active': True, + 'hidden': True, + 'expires_at': timezone.now() + timedelta(days=5) + }, + ), + infraction_model=cls.infraction_model, + user_model=cls.user_model, ) # User #9: One active permanent ban, two active temporary bans - cls.created_infractions.update( - { - 9: InfractionFactory.create( - actor=cls.user_moderator, - infractions=( - { - 'type': 'ban', - 'active': True, - 'hidden': True, - 'expires_at': timezone.now() + timedelta(days=10) - }, - { - 'type': 'ban', - 'active': True, - 'hidden': True, - 'expires_at': None, - }, - { - 'type': 'ban', - 'active': True, - 'hidden': True, - 'expires_at': timezone.now() + timedelta(days=7) - }, - ), - infraction_model=cls.infraction_model, - user_model=cls.user_model, - ) - } + cls.created_infractions["active perm, two active temp bans"] = InfractionFactory.create( + actor=cls.user_moderator, + infractions=( + { + 'type': 'ban', + 'active': True, + 'hidden': True, + 'expires_at': timezone.now() + timedelta(days=10) + }, + { + 'type': 'ban', + 'active': True, + 'hidden': True, + 'expires_at': None, + }, + { + 'type': 'ban', + 'active': True, + 'hidden': True, + 'expires_at': timezone.now() + timedelta(days=7) + }, + ), + infraction_model=cls.infraction_model, + user_model=cls.user_model, ) # User #10: One inactive permanent ban, two active temporary bans - cls.created_infractions.update( - { - 10: InfractionFactory.create( - actor=cls.user_moderator, - infractions=( - { - 'type': 'ban', - 'active': True, - 'hidden': True, - 'expires_at': timezone.now() + timedelta(days=10) - }, - { - 'type': 'ban', - 'active': False, - 'hidden': True, - 'expires_at': None, - }, - { - 'type': 'ban', - 'active': True, - 'hidden': True, - 'expires_at': timezone.now() + timedelta(days=7) - }, - ), - infraction_model=cls.infraction_model, - user_model=cls.user_model, - ) - } + cls.created_infractions["one inactive perm ban, two active temp bans"] = ( + InfractionFactory.create( + actor=cls.user_moderator, + infractions=( + { + 'type': 'ban', + 'active': True, + 'hidden': True, + 'expires_at': timezone.now() + timedelta(days=10) + }, + { + 'type': 'ban', + 'active': False, + 'hidden': True, + 'expires_at': None, + }, + { + 'type': 'ban', + 'active': True, + 'hidden': True, + 'expires_at': timezone.now() + timedelta(days=7) + }, + ), + infraction_model=cls.infraction_model, + user_model=cls.user_model, + ) ) # User #11: Active ban, active mute, active superstar - cls.created_infractions.update( - { - 11: InfractionFactory.create( - actor=cls.user_moderator, - infractions=( - {'type': 'ban', 'active': True, 'hidden': True}, - {'type': 'mute', 'active': True, 'hidden': True}, - {'type': 'superstar', 'active': True, 'hidden': True}, - {'type': 'watch', 'active': True, 'hidden': True}, - ), - infraction_model=cls.infraction_model, - user_model=cls.user_model, - ) - } + cls.created_infractions["active ban, mute, and superstar"] = InfractionFactory.create( + actor=cls.user_moderator, + infractions=( + {'type': 'ban', 'active': True, 'hidden': True}, + {'type': 'mute', 'active': True, 'hidden': True}, + {'type': 'superstar', 'active': True, 'hidden': True}, + {'type': 'watch', 'active': True, 'hidden': True}, + ), + infraction_model=cls.infraction_model, + user_model=cls.user_model, ) # User #12: Multiple active bans, active mutes, active superstars - cls.created_infractions.update( - { - 12: InfractionFactory.create( - actor=cls.user_moderator, - infractions=( - {'type': 'ban', 'active': True, 'hidden': True}, - {'type': 'ban', 'active': True, 'hidden': True}, - {'type': 'ban', 'active': True, 'hidden': True}, - {'type': 'mute', 'active': True, 'hidden': True}, - {'type': 'mute', 'active': True, 'hidden': True}, - {'type': 'mute', 'active': True, 'hidden': True}, - {'type': 'superstar', 'active': True, 'hidden': True}, - {'type': 'superstar', 'active': True, 'hidden': True}, - {'type': 'superstar', 'active': True, 'hidden': True}, - {'type': 'watch', 'active': True, 'hidden': True}, - {'type': 'watch', 'active': True, 'hidden': True}, - {'type': 'watch', 'active': True, 'hidden': True}, - ), - infraction_model=cls.infraction_model, - user_model=cls.user_model, - ) - } + cls.created_infractions["multiple active bans, mutes, stars"] = InfractionFactory.create( + actor=cls.user_moderator, + infractions=( + {'type': 'ban', 'active': True, 'hidden': True}, + {'type': 'ban', 'active': True, 'hidden': True}, + {'type': 'ban', 'active': True, 'hidden': True}, + {'type': 'mute', 'active': True, 'hidden': True}, + {'type': 'mute', 'active': True, 'hidden': True}, + {'type': 'mute', 'active': True, 'hidden': True}, + {'type': 'superstar', 'active': True, 'hidden': True}, + {'type': 'superstar', 'active': True, 'hidden': True}, + {'type': 'superstar', 'active': True, 'hidden': True}, + {'type': 'watch', 'active': True, 'hidden': True}, + {'type': 'watch', 'active': True, 'hidden': True}, + {'type': 'watch', 'active': True, 'hidden': True}, + ), + infraction_model=cls.infraction_model, + user_model=cls.user_model, ) def test_all_never_active_types_became_inactive(self): @@ -454,14 +403,14 @@ class ActiveInfractionMigrationTests(MigrationsTestCase): def test_migration_left_clean_user_without_infractions(self): """Do users without infractions have no infractions after the migration?""" - user_id, infraction_history = self.created_infractions[1] + user_id, infraction_history = self.created_infractions["no infractions"] self.assertFalse( self.infraction_model.objects.filter(user__id=user_id).exists() ) def test_migration_left_user_with_inactive_note_untouched(self): """Did the migration leave users with only an inactive note untouched?""" - user_id, infraction_history = self.created_infractions[2] + user_id, infraction_history = self.created_infractions["one inactive note"] inactive_note = infraction_history[0] self.assertTrue( self.infraction_model.objects.filter(**model_to_dict(inactive_note)).exists() @@ -469,7 +418,7 @@ class ActiveInfractionMigrationTests(MigrationsTestCase): def test_migration_only_touched_active_field_of_active_note(self): """Does the migration only change the `active` field?""" - user_id, infraction_history = self.created_infractions[3] + user_id, infraction_history = self.created_infractions["one active note"] note = model_to_dict(infraction_history[0]) note['active'] = False self.assertTrue( @@ -478,7 +427,7 @@ class ActiveInfractionMigrationTests(MigrationsTestCase): def test_migration_only_touched_active_field_of_active_note_left_inactive_untouched(self): """Does the migration only change the `active` field of active notes?""" - user_id, infraction_history = self.created_infractions[4] + user_id, infraction_history = self.created_infractions["one active and one inactive note"] for note in infraction_history: with self.subTest(active=note.active): note = model_to_dict(note) @@ -489,14 +438,14 @@ class ActiveInfractionMigrationTests(MigrationsTestCase): def test_migration_migrates_all_nonactive_types_to_inactive(self): """Do we set the `active` field of all non-active infractions to `False`?""" - user_id, infraction_history = self.created_infractions[5] + user_id, infraction_history = self.created_infractions["active note, kick, warning"] self.assertFalse( self.infraction_model.objects.filter(user__id=user_id, active=True).exists() ) def test_migration_leaves_user_with_one_active_ban_untouched(self): """Do we leave a user with one active and one inactive ban untouched?""" - user_id, infraction_history = self.created_infractions[6] + user_id, infraction_history = self.created_infractions["one inactive and one active ban"] for infraction in infraction_history: with self.subTest(active=infraction.active): self.assertTrue( @@ -505,37 +454,39 @@ class ActiveInfractionMigrationTests(MigrationsTestCase): def test_migration_turns_double_active_perm_ban_into_single_active_perm_ban(self): """Does the migration turn two active permanent bans into one active permanent ban?""" - user_id, infraction_history = self.created_infractions[7] + user_id, infraction_history = self.created_infractions["two active perm bans"] active_count = self.infraction_model.objects.filter(user__id=user_id, active=True).count() self.assertEqual(active_count, 1) def test_migration_leaves_temporary_ban_with_longest_duration_active(self): """Does the migration turn two active permanent bans into one active permanent ban?""" - user_id, infraction_history = self.created_infractions[8] + user_id, infraction_history = self.created_infractions["multiple active temp bans"] active_ban = self.infraction_model.objects.get(user__id=user_id, active=True) self.assertEqual(active_ban.expires_at, infraction_history[2].expires_at) def test_migration_leaves_permanent_ban_active(self): """Does the migration leave the permanent ban active?""" - user_id, infraction_history = self.created_infractions[9] + user_id, infraction_history = self.created_infractions["active perm, two active temp bans"] active_ban = self.infraction_model.objects.get(user__id=user_id, active=True) self.assertIsNone(active_ban.expires_at) def test_migration_leaves_longest_temp_ban_active_with_inactive_permanent_ban(self): """Does the longest temp ban stay active, even with an inactive perm ban present?""" - user_id, infraction_history = self.created_infractions[10] + user_id, infraction_history = self.created_infractions[ + "one inactive perm ban, two active temp bans" + ] active_ban = self.infraction_model.objects.get(user__id=user_id, active=True) self.assertEqual(active_ban.expires_at, infraction_history[0].expires_at) def test_migration_leaves_all_active_types_active_if_one_of_each_exists(self): """Do all active infractions stay active if only one of each is present?""" - user_id, infraction_history = self.created_infractions[11] + user_id, infraction_history = self.created_infractions["active ban, mute, and superstar"] active_count = self.infraction_model.objects.filter(user__id=user_id, active=True).count() self.assertEqual(active_count, 4) def test_migration_reduces_all_active_types_to_a_single_active_infraction(self): """Do we reduce all of the infraction types to one active infraction?""" - user_id, infraction_history = self.created_infractions[12] + user_id, infraction_history = self.created_infractions["multiple active bans, mutes, stars"] active_infractions = self.infraction_model.objects.filter(user__id=user_id, active=True) self.assertEqual(len(active_infractions), 4) types_observed = [infraction.type for infraction in active_infractions] diff --git a/pydis_site/apps/api/tests/migrations/test_base.py b/pydis_site/apps/api/tests/migrations/test_base.py new file mode 100644 index 00000000..12677720 --- /dev/null +++ b/pydis_site/apps/api/tests/migrations/test_base.py @@ -0,0 +1,135 @@ +import logging +from unittest.mock import call, patch + +from django.db.migrations.loader import MigrationLoader +from django.test import TestCase + +from .base import MigrationsTestCase, connection + +log = logging.getLogger(__name__) + + +class SpanishInquisition(MigrationsTestCase): + app = "api" + migration_prior = "scragly" + migration_target = "kosa" + + +@patch("pydis_site.apps.api.tests.migrations.base.MigrationExecutor") +class MigrationsTestCaseNoSideEffectsTests(TestCase): + """Tests the MigrationTestCase class with actual migration side effects disabled.""" + + def setUp(self): + """Set up an instance of MigrationsTestCase for use in tests.""" + self.test_case = SpanishInquisition() + + def test_missing_app_class_raises_value_error(self, _migration_executor): + """A MigrationsTestCase subclass should set the class-attribute `app`.""" + class Spam(MigrationsTestCase): + pass + + spam = Spam() + with self.assertRaises(ValueError, msg="The `app` attribute was not set."): + spam.setUpTestData() + + def test_missing_migration_class_attributes_raise_value_error(self, _migration_executor): + """A MigrationsTestCase subclass should set both `migration_prior` and `migration_target`""" + class Eggs(MigrationsTestCase): + app = "api" + migration_target = "lemon" + + class Bacon(MigrationsTestCase): + app = "api" + migration_prior = "mark" + + instances = (Eggs(), Bacon()) + + exception_message = "Both ` migration_prior` and `migration_target` need to be set." + for instance in instances: + with self.subTest( + migration_prior=instance.migration_prior, + migration_target=instance.migration_target, + ): + with self.assertRaises(ValueError, msg=exception_message): + instance.setUpTestData() + + @patch(f"{__name__}.SpanishInquisition.setUpMigrationData") + @patch(f"{__name__}.SpanishInquisition.setUpPostMigrationData") + def test_migration_data_hooks_are_called_once(self, pre_hook, post_hook, _migration_executor): + """The `setUpMigrationData` and `setUpPostMigrationData` hooks should be called once.""" + self.test_case.setUpTestData() + for hook in (pre_hook, post_hook): + with self.subTest(hook=repr(hook)): + hook.assert_called_once() + + def test_migration_executor_is_instantiated_twice(self, migration_executor): + """The `MigrationExecutor` should be instantiated with the database connection twice.""" + self.test_case.setUpTestData() + + expected_args = [call(connection), call(connection)] + self.assertEqual(migration_executor.call_args_list, expected_args) + + def test_project_state_is_loaded_for_correct_migration_files_twice(self, migration_executor): + """The `project_state` should first be loaded with `migrate_from`, then `migrate_to`.""" + self.test_case.setUpTestData() + + expected_args = [call(self.test_case.migrate_from), call(self.test_case.migrate_to)] + self.assertEqual(migration_executor().loader.project_state.call_args_list, expected_args) + + def test_loader_build_graph_gets_called_once(self, migration_executor): + """We should rebuild the before applying the second set of migrations.""" + self.test_case.setUpTestData() + + migration_executor().loader.build_graph.assert_called_once() + + def test_migration_executor_migrate_method_is_called_correctly_twice(self, migration_executor): + """The migrate method of the executor should be called twice with the correct arguments.""" + self.test_case.setUpTestData() + + self.assertEqual(migration_executor().migrate.call_count, 2) + calls = [call([('api', 'scragly')]), call([('api', 'kosa')])] + migration_executor().migrate.assert_has_calls(calls) + + +class LifeOfBrian(MigrationsTestCase): + app = "api" + migration_prior = "0045_add_plural_name_for_log_entry" + migration_target = "0047_add_infractions_unique_constraints_active" + + @classmethod + def log_last_migration(cls): + """Parses the applied migrations dictionary to log the last applied migration.""" + loader = MigrationLoader(connection) + api_migrations = [ + migration for app, migration in loader.applied_migrations if app == cls.app + ] + last_migration = max(api_migrations, key=lambda name: int(name[:4])) + log.info(f"The last applied migration: {last_migration}") + + @classmethod + def setUpMigrationData(cls, apps): + """Method that logs the last applied migration at this point.""" + cls.log_last_migration() + + @classmethod + def setUpPostMigrationData(cls, apps): + """Method that logs the last applied migration at this point.""" + cls.log_last_migration() + + +class MigrationsTestCaseMigrationTest(TestCase): + """Tests if `MigrationsTestCase` travels to the right points in the migration history.""" + + def test_migrations_test_case_travels_to_correct_migrations_in_history(self): + """The test case should first revert to `migration_prior`, then go to `migration_target`.""" + brian = LifeOfBrian() + + with self.assertLogs(log, level=logging.INFO) as logs: + brian.setUpTestData() + + self.assertEqual(len(logs.records), 2) + + for time_point, record in zip(("migration_prior", "migration_target"), logs.records): + with self.subTest(time_point=time_point): + message = f"The last applied migration: {getattr(brian, time_point)}" + self.assertEqual(record.getMessage(), message) diff --git a/pydis_site/apps/api/tests/test_infractions.py b/pydis_site/apps/api/tests/test_infractions.py index 69d9ebea..f1591b4b 100644 --- a/pydis_site/apps/api/tests/test_infractions.py +++ b/pydis_site/apps/api/tests/test_infractions.py @@ -1,4 +1,5 @@ from datetime import datetime as dt, timedelta, timezone +from unittest.mock import patch from urllib.parse import quote from django.db.utils import IntegrityError @@ -330,9 +331,10 @@ class CreationTests(APISubdomainTestCase): } response = self.client.post(url, data=invalid_infraction) self.assertEqual(response.status_code, 400) - self.assertEqual(response.json(), { - 'active': [f'{infraction_type} infractions cannot be active.'] - }) + self.assertEqual( + response.json(), + {'active': [f'{infraction_type} infractions cannot be active.']} + ) def test_returns_400_for_second_active_infraction_of_the_same_type(self): """Test if the API rejects a second active infraction of the same type for a given user.""" @@ -364,9 +366,14 @@ class CreationTests(APISubdomainTestCase): } second_response = self.client.post(url, data=second_active_infraction) self.assertEqual(second_response.status_code, 400) - self.assertEqual(second_response.json(), { - 'active': [f'This user already has an active {infraction_type} infraction'] - }) + self.assertEqual( + second_response.json(), + { + 'non_field_errors': [ + 'This user already has an active infraction of this type.' + ] + } + ) def test_returns_201_for_second_active_infraction_of_different_type(self): """Test if the API accepts a second active infraction of a different type than the first.""" @@ -417,27 +424,37 @@ class CreationTests(APISubdomainTestCase): def test_unique_constraint_accepts_active_infraction_after_inactive_infraction(self): """Do we accept an active infraction if the others of the same type are inactive?""" - Infraction.objects.create( - user=self.user, - actor=self.user, - type="ban", - active=False, - reason="The first inactive ban" - ) - Infraction.objects.create( - user=self.user, - actor=self.user, - type="ban", - active=False, - reason="The second inactive ban" - ) - Infraction.objects.create( - user=self.user, - actor=self.user, - type="ban", - active=True, - reason="The first active ban" - ) + try: + Infraction.objects.create( + user=self.user, + actor=self.user, + type="ban", + active=False, + reason="The first inactive ban" + ) + Infraction.objects.create( + user=self.user, + actor=self.user, + type="ban", + active=False, + reason="The second inactive ban" + ) + Infraction.objects.create( + user=self.user, + actor=self.user, + type="ban", + active=True, + reason="The first active ban" + ) + except IntegrityError: + self.fail("An unexpected IntegrityError was raised.") + + @patch(f"{__name__}.Infraction") + def test_the_accepts_active_infraction_after_inactive_infractions_test(self, infraction_patch): + """Does the test properly catch the IntegrityError and raise an AssertionError?""" + infraction_patch.objects.create.side_effect = IntegrityError + with self.assertRaises(AssertionError, msg="An unexpected IntegrityError was raised."): + self.test_unique_constraint_accepts_active_infraction_after_inactive_infraction() def test_unique_constraint_accepts_second_active_of_different_type(self): """Do we accept a second active infraction of a different type for a given user?""" -- cgit v1.2.3 From 731a3ca4cefd7ed9bc6619b0d98cb0c28f14f290 Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Tue, 22 Oct 2019 18:05:09 +0200 Subject: Create an OffensiveMessage model This model will be used to store message that triggered a filter and that will be deleted one week after it was sent. --- .../apps/api/migrations/0046_offensivemessage.py | 25 ++++++++ pydis_site/apps/api/models/__init__.py | 1 + pydis_site/apps/api/models/bot/__init__.py | 1 + .../apps/api/models/bot/offensive_message.py | 44 +++++++++++++++ pydis_site/apps/api/serializers.py | 11 ++++ pydis_site/apps/api/urls.py | 7 ++- pydis_site/apps/api/viewsets/__init__.py | 1 + pydis_site/apps/api/viewsets/bot/__init__.py | 1 + .../apps/api/viewsets/bot/offensive_message.py | 66 ++++++++++++++++++++++ 9 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 pydis_site/apps/api/migrations/0046_offensivemessage.py create mode 100644 pydis_site/apps/api/models/bot/offensive_message.py create mode 100644 pydis_site/apps/api/viewsets/bot/offensive_message.py (limited to 'pydis_site/apps/api/serializers.py') diff --git a/pydis_site/apps/api/migrations/0046_offensivemessage.py b/pydis_site/apps/api/migrations/0046_offensivemessage.py new file mode 100644 index 00000000..0ce65946 --- /dev/null +++ b/pydis_site/apps/api/migrations/0046_offensivemessage.py @@ -0,0 +1,25 @@ +# Generated by Django 2.2.6 on 2019-10-24 17:57 + +import django.core.validators +from django.db import migrations, models +import pydis_site.apps.api.models.bot.offensive_message +import pydis_site.apps.api.models.utils + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0045_add_plural_name_for_log_entry'), + ] + + operations = [ + migrations.CreateModel( + name='OffensiveMessage', + fields=[ + ('id', models.BigIntegerField(help_text='The message ID as taken from Discord.', primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='Message IDs cannot be negative.')])), + ('channel_id', models.BigIntegerField(help_text='The channel ID that the message was sent in, taken from Discord.', validators=[django.core.validators.MinValueValidator(limit_value=0, message='Channel IDs cannot be negative.')])), + ('delete_date', models.DateField(help_text='The date on which the message will be auto-deleted.', validators=[pydis_site.apps.api.models.bot.offensive_message.future_date_validator])), + ], + bases=(pydis_site.apps.api.models.utils.ModelReprMixin, models.Model), + ), + ] diff --git a/pydis_site/apps/api/models/__init__.py b/pydis_site/apps/api/models/__init__.py index a4656bc3..450d18cd 100644 --- a/pydis_site/apps/api/models/__init__.py +++ b/pydis_site/apps/api/models/__init__.py @@ -7,6 +7,7 @@ from .bot import ( Message, MessageDeletionContext, Nomination, + OffensiveMessage, OffTopicChannelName, Reminder, Role, diff --git a/pydis_site/apps/api/models/bot/__init__.py b/pydis_site/apps/api/models/bot/__init__.py index 46219ea2..8ae47746 100644 --- a/pydis_site/apps/api/models/bot/__init__.py +++ b/pydis_site/apps/api/models/bot/__init__.py @@ -7,6 +7,7 @@ from .message import Message from .message_deletion_context import MessageDeletionContext from .nomination import Nomination from .off_topic_channel_name import OffTopicChannelName +from .offensive_message import OffensiveMessage from .reminder import Reminder from .role import Role from .tag import Tag diff --git a/pydis_site/apps/api/models/bot/offensive_message.py b/pydis_site/apps/api/models/bot/offensive_message.py new file mode 100644 index 00000000..523fd482 --- /dev/null +++ b/pydis_site/apps/api/models/bot/offensive_message.py @@ -0,0 +1,44 @@ +import datetime + +from django.core.exceptions import ValidationError +from django.core.validators import MinValueValidator +from django.db import models + +from pydis_site.apps.api.models.utils import ModelReprMixin + + +def future_date_validator(date: datetime.date) -> None: + """Raise ValidationError if the date isn't a future date.""" + if date < datetime.date.today(): + raise ValidationError("Date must be a future date") + + +class OffensiveMessage(ModelReprMixin, models.Model): + """A message that triggered a filter and that will be deleted one week after it was sent.""" + + id = models.BigIntegerField( + primary_key=True, + help_text="The message ID as taken from Discord.", + validators=( + MinValueValidator( + limit_value=0, + message="Message IDs cannot be negative." + ), + ) + ) + channel_id = models.BigIntegerField( + help_text=( + "The channel ID that the message was " + "sent in, taken from Discord." + ), + validators=( + MinValueValidator( + limit_value=0, + message="Channel IDs cannot be negative." + ), + ) + ) + delete_date = models.DateField( + help_text="The date on which the message will be auto-deleted.", + validators=(future_date_validator,) + ) diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index 326e20e1..4ef7ec78 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -8,6 +8,7 @@ from .models import ( DocumentationLink, Infraction, LogEntry, MessageDeletionContext, Nomination, OffTopicChannelName, + OffensiveMessage, Reminder, Role, Tag, User ) @@ -236,3 +237,13 @@ class NominationSerializer(ModelSerializer): fields = ( 'id', 'active', 'actor', 'reason', 'user', 'inserted_at', 'end_reason', 'ended_at') + + +class OffensiveMessageSerializer(ModelSerializer): + """A class providing (de-)serialization of `OffensiveMessage` instances.""" + + class Meta: + """Metadata defined for the Django REST Framework.""" + + model = OffensiveMessage + fields = ('id', 'channel_id', 'delete_date') diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py index ac6704c8..b1864ba7 100644 --- a/pydis_site/apps/api/urls.py +++ b/pydis_site/apps/api/urls.py @@ -6,7 +6,8 @@ from .viewsets import ( BotSettingViewSet, DeletedMessageViewSet, DocumentationLinkViewSet, InfractionViewSet, LogEntryViewSet, NominationViewSet, - OffTopicChannelNameViewSet, ReminderViewSet, + OffTopicChannelNameViewSet, + OffensiveMessageViewSet, ReminderViewSet, RoleViewSet, TagViewSet, UserViewSet ) @@ -33,6 +34,10 @@ bot_router.register( 'nominations', NominationViewSet ) +bot_router.register( + 'offensive-message', + OffensiveMessageViewSet +) bot_router.register( 'off-topic-channel-names', OffTopicChannelNameViewSet, diff --git a/pydis_site/apps/api/viewsets/__init__.py b/pydis_site/apps/api/viewsets/__init__.py index f9a186d9..3cf9f641 100644 --- a/pydis_site/apps/api/viewsets/__init__.py +++ b/pydis_site/apps/api/viewsets/__init__.py @@ -5,6 +5,7 @@ from .bot import ( DocumentationLinkViewSet, InfractionViewSet, NominationViewSet, + OffensiveMessageViewSet, OffTopicChannelNameViewSet, ReminderViewSet, RoleViewSet, diff --git a/pydis_site/apps/api/viewsets/bot/__init__.py b/pydis_site/apps/api/viewsets/bot/__init__.py index f1851e32..b3e0fa4d 100644 --- a/pydis_site/apps/api/viewsets/bot/__init__.py +++ b/pydis_site/apps/api/viewsets/bot/__init__.py @@ -5,6 +5,7 @@ from .documentation_link import DocumentationLinkViewSet from .infraction import InfractionViewSet from .nomination import NominationViewSet from .off_topic_channel_name import OffTopicChannelNameViewSet +from .offensive_message import OffensiveMessageViewSet from .reminder import ReminderViewSet from .role import RoleViewSet from .tag import TagViewSet diff --git a/pydis_site/apps/api/viewsets/bot/offensive_message.py b/pydis_site/apps/api/viewsets/bot/offensive_message.py new file mode 100644 index 00000000..cd200efe --- /dev/null +++ b/pydis_site/apps/api/viewsets/bot/offensive_message.py @@ -0,0 +1,66 @@ +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.filters import SearchFilter +from rest_framework.mixins import ( + CreateModelMixin, + DestroyModelMixin, + ListModelMixin, + UpdateModelMixin +) +from rest_framework.viewsets import GenericViewSet + +from pydis_site.apps.api.models.bot.offensive_message import OffensiveMessage +from pydis_site.apps.api.serializers import OffensiveMessageSerializer + + +class OffensiveMessageViewSet( + CreateModelMixin, ListModelMixin, DestroyModelMixin, UpdateModelMixin, GenericViewSet +): + """ + View providing CRUD access to reminders. + + ## Routes + ### GET /bot/offensive-message + Returns all offensive messages in the database. + + #### Response format + >>> [ + ... { + ... 'id': '631953598091100200', + ... 'channel_id': '291284109232308226', + ... 'delete_date': '2020-01-01' + ... }, + ... ... + ... ] + + #### Status codes + - 200: returned on success + + ### POST /bot/offensive-message + Create a new offensive message object. + + #### Request body + >>> { + ... 'id': int, + ... 'channel_id': int, + ... 'delete_date': datetime.date # ISO-8601-formatted date + ... } + + #### Status codes + - 201: returned on success + - 400: if the body format is invalid + + ### DELETE /bot/offensive-message/ + Delete the offensive message object with the given `id`. + + #### Status codes + - 204: returned on success + - 404: if a offensive message object with the given `id` does not exist + + ## Authentication + Requires an API token. + """ + + serializer_class = OffensiveMessageSerializer + queryset = OffensiveMessage.objects.all() + filter_backends = (DjangoFilterBackend, SearchFilter) + filter_fields = ('delete_date',) -- cgit v1.2.3 From ee3f4f937c99ef3db92afc2fcba746003ec694f0 Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Mon, 28 Oct 2019 18:20:07 +0100 Subject: Add a attachments field to the message model --- pydis_site/apps/api/models/bot/message.py | 6 ++++++ pydis_site/apps/api/serializers.py | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) (limited to 'pydis_site/apps/api/serializers.py') diff --git a/pydis_site/apps/api/models/bot/message.py b/pydis_site/apps/api/models/bot/message.py index 31316a01..72b0b61a 100644 --- a/pydis_site/apps/api/models/bot/message.py +++ b/pydis_site/apps/api/models/bot/message.py @@ -51,6 +51,12 @@ class Message(ModelReprMixin, models.Model): ), help_text="Embeds attached to this message." ) + attachments = pgfields.ArrayField( + models.URLField( + max_length=512 + ), + help_text="Attachments attached to this message." + ) @property def timestamp(self) -> datetime: diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index 8a605612..e091ff4f 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -49,7 +49,8 @@ class DeletedMessageSerializer(ModelSerializer): fields = ( 'id', 'author', 'channel_id', 'content', - 'embeds', 'deletion_context' + 'embeds', 'deletion_context', + 'attachments' ) -- cgit v1.2.3