From cf1a0755580b109d5f35331b6d5cbfef0d92fa97 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 7 Oct 2019 08:59:23 +0200 Subject: Migrate undesirable active infraction to inactive https://github.com/python-discord/site/issues/273 This commit adds a data migration to migrate active infractions that should not be active to inactive. There are two types of infractions that this migration will migrate to inactive: - Infractions of types that should never be active (e.g. notes) - Secondary active infractions if a given user already has an active infraction of the same type. Since this makes the migration file fairly complex, I have written tests to make sure the migration works as expected. In order to do this, I've subclassed `django.test.TestCase` to create a `MigrationsTestCase` that takes care of reverting the database back to a state prior to the migrations we want to test and injects test data before applying the migrations we want to test. For more information, see `pydis_site.apps.api.tests.migrations.base` This implements the last part of and closes #273 --- .../0044_active_infractions_migration.py | 106 ++++ pydis_site/apps/api/tests/migrations/__init__.py | 1 + pydis_site/apps/api/tests/migrations/base.py | 102 ++++ .../migrations/test_active_infraction_migration.py | 545 +++++++++++++++++++++ 4 files changed, 754 insertions(+) create mode 100644 pydis_site/apps/api/migrations/0044_active_infractions_migration.py create mode 100644 pydis_site/apps/api/tests/migrations/__init__.py create mode 100644 pydis_site/apps/api/tests/migrations/base.py create mode 100644 pydis_site/apps/api/tests/migrations/test_active_infraction_migration.py (limited to 'pydis_site') diff --git a/pydis_site/apps/api/migrations/0044_active_infractions_migration.py b/pydis_site/apps/api/migrations/0044_active_infractions_migration.py new file mode 100644 index 00000000..14746712 --- /dev/null +++ b/pydis_site/apps/api/migrations/0044_active_infractions_migration.py @@ -0,0 +1,106 @@ +# 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 + + +class ExpirationWrapper: + """Wraps an expiration date to properly compare permanent and temporary infractions.""" + + def __init__(self, infraction): + self.expiration_date = infraction.expires_at + + def __lt__(self, other): + """An `expiration_date` is considered smaller when it comes earlier than the `other`.""" + if self.expiration_date is None: + # A permanent infraction can never end sooner than another infraction + return False + elif other.expiration_date is None: + # If `self` is temporary, but `other` is permanent, `self` is smaller + return True + else: + return self.expiration_date < other.expiration_date + + def __eq__(self, other): + """If both expiration dates are permanent they're equal, otherwise compare dates.""" + if self.expiration_date is None and other.expiration_date is None: + return True + elif self.expiration_date is None or other.expiration_date is None: + return False + else: + return self.expiration_date == other.expiration_date + + +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) + + +def get_query(user_model, infraction_model, infr_type: str) -> QuerySet: + """ + Creates QuerySet to fetch users with multiple active infractions of the given `type`. + + The QuerySet will prefetch the infractions and attach them as an `.infractions` attribute to the + `User` instances. + """ + active_infractions = infraction_model.objects.filter(type=infr_type, active=True) + + # Build an SQL query by chaining methods together + + # 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) + ) + + # Prefetch their active received infractions of `infr_type` and attach `.infractions` attribute + query = query.prefetch_related( + Prefetch('infractions_received', queryset=active_infractions, to_attr='infractions') + ) + + # Count and only include them if they have at least 2 active infractions of the `type` + query = query.annotate(num_infractions=Count('infractions_received')) + query = query.filter(num_infractions__gte=2) + + # Make sure we return each individual only once + query = query.distinct() + + return query + + +def migrate_multiple_active_infractions_per_user_to_one(apps, schema_editor): + """ + Make sure a user only has one active infraction of a given "active" infraction type. + + If a user has multiple active infraction, we keep the one with longest expiration date active + and migrate the others to inactive. + """ + infraction_model = apps.get_model('api', 'Infraction') + user_model = apps.get_model('api', 'User') + + for infraction_type in ('ban', 'mute', 'superstar', 'watch'): + query = get_query(user_model, infraction_model, infraction_type) + for user in query: + infractions = sorted(user.infractions, key=ExpirationWrapper, reverse=True) + for infraction in infractions[1:]: + infraction.active = False + infraction.save() + + +def reverse_migration(apps, schema_editor): + """There's no need to do anything special to reverse these migrations.""" + return + + +class Migration(migrations.Migration): + """Data migration to get the database consistent with the new infraction validation rules.""" + + dependencies = [ + ('api', '0043_infraction_hidden_warnings_to_notes'), + ] + + operations = [ + migrations.RunPython(migrate_inactive_types_to_inactive, reverse_migration), + migrations.RunPython(migrate_multiple_active_infractions_per_user_to_one, reverse_migration) + ] diff --git a/pydis_site/apps/api/tests/migrations/__init__.py b/pydis_site/apps/api/tests/migrations/__init__.py new file mode 100644 index 00000000..38e42ffc --- /dev/null +++ b/pydis_site/apps/api/tests/migrations/__init__.py @@ -0,0 +1 @@ +"""This submodule contains tests for functions used in data migrations.""" diff --git a/pydis_site/apps/api/tests/migrations/base.py b/pydis_site/apps/api/tests/migrations/base.py new file mode 100644 index 00000000..0c0a5bd0 --- /dev/null +++ b/pydis_site/apps/api/tests/migrations/base.py @@ -0,0 +1,102 @@ +"""Includes utilities for testing migrations.""" +from django.db import connection +from django.db.migrations.executor import MigrationExecutor +from django.test import TestCase + + +class MigrationsTestCase(TestCase): + """ + A `TestCase` subclass to test migration files. + + To be able to properly test a migration, we will need to inject data into the test database + before the migrations we want to test are applied, but after the older migrations have been + applied. This makes sure that we are testing "as if" we were actually applying this migration + to a database in the state it was in before introducing the new migration. + + To set up a MigrationsTestCase, create a subclass of this class and set the following + class-level attributes: + + - app: The name of the app that contains the migrations (e.g., `'api'`) + - migration_prior: The name* of the last migration file before the migrations you want to test + - migration_target: The name* of the last migration file we want to test + + *) Specify the file names without a path or the `.py` file extension. + + Additionally, overwrite the `setUpMigrationData` in the subclass to inject data into the + database before the migrations we want to test are applied. Please read the docstring of the + method for more information. An optional hook, `setUpPostMigrationData` is also provided. + """ + + # These class-level attributes should be set in classes that inherit from this base class. + app = None + migration_prior = None + migration_target = None + + @classmethod + def setUpTestData(cls): + """ + Injects data into the test database prior to the migration we're trying to test. + + This class methods reverts the test database back to the state of the last migration file + prior to the migrations we want to test. It will then allow the user to inject data into the + test database by calling the `setUpMigrationData` hook. After the data has been injected, it + will apply the migrations we want to test and call the `setUpPostMigrationData` hook. The + user can now test if the migration correctly migrated the injected test data. + """ + if not cls.app: + raise ValueError("The `app` attribute was not set.") + + if not cls.migration_prior or not cls.migration_target: + raise ValueError("Both ` migration_prior` and `migration_target` need to be set.") + + cls.migrate_from = [(cls.app, cls.migration_prior)] + cls.migrate_to = [(cls.app, cls.migration_target)] + + # Reverse to database state prior to the migrations we want to test + executor = MigrationExecutor(connection) + executor.migrate(cls.migrate_from) + + # Call the data injection hook with the current state of the project + old_apps = executor.loader.project_state(cls.migrate_from).apps + cls.setUpMigrationData(old_apps) + + # Run the migrations we want to test + executor = MigrationExecutor(connection) + executor.loader.build_graph() + executor.migrate(cls.migrate_to) + + # Save the project state so we're able to work with the correct model states + cls.apps = executor.loader.project_state(cls.migrate_to).apps + + # Call `setUpPostMigrationData` to potentially set up post migration data used in testing + cls.setUpPostMigrationData(cls.apps) + + @classmethod + def setUpMigrationData(cls, apps): + """ + Override this method to inject data into the test database before the migration is applied. + + This method will be called after setting up the database according to the migrations that + come before the migration(s) we are trying to test, but before the to-be-tested migration(s) + are applied. This allows us to simulate a database state just prior to the migrations we are + trying to test. + + To make sure we're creating objects according to the state the models were in at this point + in the migration history, use `apps.get_model(app_name: str, model_name: str)` to get the + appropriate model, e.g.: + + >>> Infraction = apps.get_model('api', 'Infraction') + """ + pass + + @classmethod + def setUpPostMigrationData(cls, apps): + """ + Set up additional test data after the target migration has been applied. + + Use `apps.get_model(app_name: str, model_name: str)` to get the correct instances of the + model classes: + + >>> Infraction = apps.get_model('api', 'Infraction') + """ + pass 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 new file mode 100644 index 00000000..cfae4219 --- /dev/null +++ b/pydis_site/apps/api/tests/migrations/test_active_infraction_migration.py @@ -0,0 +1,545 @@ +"""Tests for the data migration in `filename`.""" +import logging +from collections import ChainMap, namedtuple +from datetime import timedelta +from itertools import count +from typing import Dict, Iterable, Type, Union + +from django.db.models import Q +from django.forms.models import model_to_dict +from django.utils import timezone + +from pydis_site.apps.api.models import Infraction, User +from .base import MigrationsTestCase + +log = logging.getLogger(__name__) +log.setLevel(logging.DEBUG) + + +InfractionHistory = namedtuple('InfractionHistory', ("user_id", "infraction_history")) + + +class InfractionFactory: + """Factory that creates infractions for a User instance.""" + + infraction_id = count(1) + user_id = count(1) + default_values = { + 'active': True, + 'expires_at': None, + 'hidden': False, + } + + @classmethod + def create( + cls, + actor: User, + infractions: Iterable[Dict[str, Union[str, int, bool]]], + infraction_model: Type[Infraction] = Infraction, + user_model: Type[User] = User, + ) -> InfractionHistory: + """ + Creates `infractions` for the `user` with the given `actor`. + + The `infractions` dictionary can contain the following fields: + - `type` (required) + - `active` (default: True) + - `expires_at` (default: None; i.e, permanent) + - `hidden` (default: False). + + The parameters `infraction_model` and `user_model` can be used to pass in an instance of + both model classes from a different migration/project state. + """ + user_id = next(cls.user_id) + user = user_model.objects.create( + id=user_id, + name=f"Infracted user {user_id}", + discriminator=user_id, + avatar_hash=None, + ) + infraction_history = [] + + for infraction in infractions: + infraction = dict(infraction) + infraction["id"] = next(cls.infraction_id) + infraction = ChainMap(infraction, cls.default_values) + new_infraction = infraction_model.objects.create( + user=user, + actor=actor, + type=infraction["type"], + reason=f"`{infraction['type']}` infraction (ID: {infraction['id']} of {user}", + active=infraction['active'], + hidden=infraction['hidden'], + expires_at=infraction['expires_at'], + ) + infraction_history.append(new_infraction) + + return InfractionHistory(user_id=user_id, infraction_history=infraction_history) + + +class InfractionFactoryTests(MigrationsTestCase): + """Tests for the InfractionFactory.""" + + app = "api" + migration_prior = "0043_infraction_hidden_warnings_to_notes" + migration_target = "0043_infraction_hidden_warnings_to_notes" + + @classmethod + def setUpPostMigrationData(cls, apps): + """Create a default actor for all infractions.""" + cls.infraction_model = apps.get_model('api', 'Infraction') + cls.user_model = apps.get_model('api', 'User') + + cls.actor = cls.user_model.objects.create( + id=9999, + name="Unknown Moderator", + discriminator=1040, + avatar_hash=None, + ) + + def test_infraction_factory_total_count(self): + """Does the test database hold as many infractions as we tried to create?""" + InfractionFactory.create( + actor=self.actor, + infractions=( + {'type': 'kick', 'active': False, 'hidden': False}, + {'type': 'ban', 'active': True, 'hidden': False}, + {'type': 'note', 'active': False, 'hidden': True}, + ), + infraction_model=self.infraction_model, + user_model=self.user_model, + ) + database_count = Infraction.objects.all().count() + self.assertEqual(3, database_count) + + 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, + ) + } + ) + + # Check if infractions and users are recorded properly in the database + database_count = Infraction.objects.all().count() + self.assertEqual(database_count, 10) + + user_count = User.objects.all().count() + self.assertEqual(user_count, 5 + 1) + + def test_infraction_factory_sets_correct_fields(self): + """Does the InfractionFactory set the correct attributes?""" + infractions = ( + { + 'type': 'note', + 'active': False, + 'hidden': True, + 'expires_at': timezone.now() + }, + {'type': 'warning', 'active': False, 'hidden': False, 'expires_at': None}, + {'type': 'watch', 'active': False, 'hidden': True, 'expires_at': None}, + {'type': 'mute', 'active': True, 'hidden': False, 'expires_at': None}, + {'type': 'kick', 'active': True, 'hidden': True, 'expires_at': None}, + {'type': 'ban', 'active': True, 'hidden': False, 'expires_at': None}, + { + 'type': 'superstar', + 'active': True, + 'hidden': True, + 'expires_at': timezone.now() + }, + ) + + InfractionFactory.create( + actor=self.actor, + infractions=infractions, + infraction_model=self.infraction_model, + user_model=self.user_model, + ) + + for infraction in infractions: + with self.subTest(**infraction): + self.assertTrue(Infraction.objects.filter(**infraction).exists()) + + +class ActiveInfractionMigrationTests(MigrationsTestCase): + """ + Tests the active infraction data migration. + + The active infraction data migration should do the following things: + + 1. migrates all active notes, warnings, and kicks to an inactive status; + 2. migrates all users with multiple active infractions of a single type to have only one active + infraction of that type. The infraction with the longest duration stays active. + """ + + app = "api" + migration_prior = "0043_infraction_hidden_warnings_to_notes" + migration_target = "0044_active_infractions_migration" + + @classmethod + def setUpMigrationData(cls, apps): + """Sets up an initial database state that contains the relevant test cases.""" + # Fetch the Infraction and User model in the current migration state + cls.infraction_model = apps.get_model('api', 'Infraction') + cls.user_model = apps.get_model('api', 'User') + + cls.created_infractions = {} + + # Moderator that serves as actor for all infractions + cls.user_moderator = cls.user_model.objects.create( + id=9999, + name="Olivier de Vienne", + discriminator=1040, + avatar_hash=None, + ) + + # 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, + ) + } + ) + + # 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, + ) + } + ) + + # 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, + ) + } + ) + + # 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, + ) + } + ) + + # 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, + ) + } + ) + + # 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, + ) + } + ) + + # 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, + ) + } + ) + + # 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, + ) + } + ) + + # 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, + ) + } + ) + + # 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, + ) + } + ) + + # 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, + ) + } + ) + + # 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, + ) + } + ) + + def test_all_never_active_types_became_inactive(self): + """Are all infractions of a non-active type inactive after the migration?""" + inactive_type_query = Q(type="note") | Q(type="warning") | Q(type="kick") + self.assertFalse( + self.infraction_model.objects.filter(inactive_type_query, active=True).exists() + ) + + 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] + 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] + inactive_note = infraction_history[0] + self.assertTrue( + self.infraction_model.objects.filter(**model_to_dict(inactive_note)).exists() + ) + + 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] + note = model_to_dict(infraction_history[0]) + note['active'] = False + self.assertTrue( + self.infraction_model.objects.filter(**note).exists() + ) + + 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] + for note in infraction_history: + with self.subTest(active=note.active): + note = model_to_dict(note) + note['active'] = False + self.assertTrue( + self.infraction_model.objects.filter(**note).exists() + ) + + 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] + 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] + for infraction in infraction_history: + with self.subTest(active=infraction.active): + self.assertTrue( + self.infraction_model.objects.filter(**model_to_dict(infraction)).exists() + ) + + 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] + 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] + 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] + 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] + 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] + 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] + 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] + + for infraction_type in ('ban', 'mute', 'superstar', 'watch'): + with self.subTest(type=infraction_type): + self.assertIn(infraction_type, types_observed) -- cgit v1.2.3