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/models/bot/user.py | 2 +- pydis_site/apps/api/serializers.py | 13 ++++ pydis_site/apps/api/tests/test_infractions.py | 85 +++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/pydis_site/apps/api/models/bot/user.py b/pydis_site/apps/api/models/bot/user.py index 21617dc4..5140d2bf 100644 --- a/pydis_site/apps/api/models/bot/user.py +++ b/pydis_site/apps/api/models/bot/user.py @@ -50,7 +50,7 @@ class User(ModelReprMixin, models.Model): def __str__(self): """Returns the name and discriminator for the current user, for display purposes.""" - return f"{self.name}#{self.discriminator}" + return f"{self.name}#{self.discriminator:0>4}" @property def top_role(self) -> Role: 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.']}) diff --git a/pydis_site/apps/api/tests/test_infractions.py b/pydis_site/apps/api/tests/test_infractions.py index c58c32e2..8279b6a6 100644 --- a/pydis_site/apps/api/tests/test_infractions.py +++ b/pydis_site/apps/api/tests/test_infractions.py @@ -305,6 +305,91 @@ class CreationTests(APISubdomainTestCase): 'hidden': [f'{data["type"]} infractions must be hidden.'] }) + def test_returns_400_for_active_infraction_of_type_that_cannot_be_active(self): + """Test if the API rejects active infractions for types that cannot be active.""" + url = reverse('bot:infraction-list', host='api') + restricted_types = ('note', 'warning', 'kick') + + for infraction_type in restricted_types: + with self.subTest(infraction_type=infraction_type): + invalid_infraction = { + 'user': self.user.id, + 'actor': self.user.id, + 'type': infraction_type, + 'reason': 'Take me on!', + 'hidden': True, + 'active': True, + 'expires_at': '2019-10-04T12:52:00+00:00' + } + 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.'] + }) + + 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.""" + url = reverse('bot:infraction-list', host='api') + active_infraction_types = ('mute', 'ban', 'superstar') + + for infraction_type in active_infraction_types: + with self.subTest(infraction_type=infraction_type): + first_active_infraction = { + 'user': self.user.id, + 'actor': self.user.id, + 'type': infraction_type, + 'reason': 'Take me on!', + 'active': True, + 'expires_at': '2019-10-04T12:52:00+00:00' + } + + # Post the first active infraction of a type and confirm it's accepted. + first_response = self.client.post(url, data=first_active_infraction) + self.assertEqual(first_response.status_code, 201) + + second_active_infraction = { + 'user': self.user.id, + 'actor': self.user.id, + 'type': infraction_type, + 'reason': 'Take on me!', + 'active': True, + 'expires_at': '2019-10-04T12:52:00+00:00' + } + 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'] + }) + + 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.""" + url = reverse('bot:infraction-list', host='api') + first_active_infraction = { + 'user': self.user.id, + 'actor': self.user.id, + 'type': 'mute', + 'reason': 'Be silent!', + 'hidden': True, + 'active': True, + 'expires_at': '2019-10-04T12:52:00+00:00' + } + second_active_infraction = { + 'user': self.user.id, + 'actor': self.user.id, + 'type': 'ban', + 'reason': 'Be gone!', + 'hidden': True, + 'active': True, + 'expires_at': '2019-10-05T12:52:00+00:00' + } + # Post the first active infraction of a type and confirm it's accepted. + first_response = self.client.post(url, data=first_active_infraction) + self.assertEqual(first_response.status_code, 201) + + # Post the first active infraction of a type and confirm it's accepted. + second_response = self.client.post(url, data=second_active_infraction) + self.assertEqual(second_response.status_code, 201) + class ExpandedTests(APISubdomainTestCase): @classmethod -- cgit v1.2.3 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 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 From 0d383cbe925fdec97cb678c0168ecf7e90d3d729 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Tue, 8 Oct 2019 01:22:15 +0200 Subject: Prevent double active infractions with constraint https://github.com/python-discord/site/issues/273 This commits adds a UniqueConstraint for active infractions on a combination of the `user` and `type` field. This means that a user can only have one active infraction of a given type in the database at any time. I've also added tests to make sure that this behaves as expected. --- ...45_add_infractions_unique_constraints_active.py | 17 +++++ pydis_site/apps/api/models/bot/infraction.py | 7 ++ pydis_site/apps/api/tests/test_infractions.py | 89 +++++++++++++++++++++- 3 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 pydis_site/apps/api/migrations/0045_add_infractions_unique_constraints_active.py diff --git a/pydis_site/apps/api/migrations/0045_add_infractions_unique_constraints_active.py b/pydis_site/apps/api/migrations/0045_add_infractions_unique_constraints_active.py new file mode 100644 index 00000000..bacb56b8 --- /dev/null +++ b/pydis_site/apps/api/migrations/0045_add_infractions_unique_constraints_active.py @@ -0,0 +1,17 @@ +# Generated by Django 2.2.6 on 2019-10-07 18:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0044_active_infractions_migration'), + ] + + operations = [ + migrations.AddConstraint( + model_name='infraction', + constraint=models.UniqueConstraint(condition=models.Q(active=True), fields=('user', 'type'), name='unique_active_infraction_per_type_per_user'), + ), + ] diff --git a/pydis_site/apps/api/models/bot/infraction.py b/pydis_site/apps/api/models/bot/infraction.py index dfb32a97..108fd3a2 100644 --- a/pydis_site/apps/api/models/bot/infraction.py +++ b/pydis_site/apps/api/models/bot/infraction.py @@ -71,3 +71,10 @@ class Infraction(ModelReprMixin, models.Model): """Defines the meta options for the infraction model.""" ordering = ['-inserted_at'] + constraints = ( + models.UniqueConstraint( + fields=["user", "type"], + condition=models.Q(active=True), + name="unique_active_infraction_per_type_per_user" + ), + ) diff --git a/pydis_site/apps/api/tests/test_infractions.py b/pydis_site/apps/api/tests/test_infractions.py index 8279b6a6..69d9ebea 100644 --- a/pydis_site/apps/api/tests/test_infractions.py +++ b/pydis_site/apps/api/tests/test_infractions.py @@ -1,6 +1,7 @@ from datetime import datetime as dt, timedelta, timezone from urllib.parse import quote +from django.db.utils import IntegrityError from django_hosts.resolvers import reverse from .base import APISubdomainTestCase @@ -167,6 +168,12 @@ class CreationTests(APISubdomainTestCase): discriminator=1, avatar_hash=None ) + cls.second_user = User.objects.create( + id=6, + name='carl', + discriminator=2, + avatar_hash=None + ) def test_accepts_valid_data(self): url = reverse('bot:infraction-list', host='api') @@ -390,6 +397,82 @@ class CreationTests(APISubdomainTestCase): second_response = self.client.post(url, data=second_active_infraction) self.assertEqual(second_response.status_code, 201) + def test_unique_constraint_raises_integrity_error_on_second_active_of_same_type(self): + """Do we raise `IntegrityError` for the second active infraction of a type for a user?""" + Infraction.objects.create( + user=self.user, + actor=self.user, + type="ban", + active=True, + reason="The first active ban" + ) + with self.assertRaises(IntegrityError): + Infraction.objects.create( + user=self.user, + actor=self.user, + type="ban", + active=True, + reason="The second active ban" + ) + + 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" + ) + + 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?""" + Infraction.objects.create( + user=self.user, + actor=self.user, + type="ban", + active=True, + reason="The first active ban" + ) + Infraction.objects.create( + user=self.user, + actor=self.user, + type="mute", + active=True, + reason="The first active mute" + ) + + def test_unique_constraint_accepts_active_infractions_for_different_users(self): + """Do we accept two active infractions of the same type for two different users?""" + Infraction.objects.create( + user=self.user, + actor=self.user, + type="ban", + active=True, + reason="An active ban for the first user" + ) + Infraction.objects.create( + user=self.second_user, + actor=self.second_user, + type="ban", + active=False, + reason="An active ban for the second user" + ) + class ExpandedTests(APISubdomainTestCase): @classmethod @@ -403,12 +486,14 @@ class ExpandedTests(APISubdomainTestCase): cls.kick = Infraction.objects.create( user_id=cls.user.id, actor_id=cls.user.id, - type='kick' + type='kick', + active=False ) cls.warning = Infraction.objects.create( user_id=cls.user.id, actor_id=cls.user.id, - type='warning' + type='warning', + active=False, ) def check_expanded_fields(self, infraction): -- cgit v1.2.3 From e415428386caae19aa0398aea66ee5053569abcf Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 18 Oct 2019 12:52:11 +0200 Subject: Solve migration conflict by renaming migrations The migration files were generated and named before the migrations added by other pull requests. This caused the migration path to diverge. Since the migrations did not touch the same models, the solution was to rename the migration files to place them at the end of the migration history. --- .../0044_active_infractions_migration.py | 106 --------------------- ...45_add_infractions_unique_constraints_active.py | 17 ---- .../0046_active_infractions_migration.py | 106 +++++++++++++++++++++ ...47_add_infractions_unique_constraints_active.py | 17 ++++ .../migrations/test_active_infraction_migration.py | 8 +- 5 files changed, 127 insertions(+), 127 deletions(-) delete mode 100644 pydis_site/apps/api/migrations/0044_active_infractions_migration.py delete mode 100644 pydis_site/apps/api/migrations/0045_add_infractions_unique_constraints_active.py create mode 100644 pydis_site/apps/api/migrations/0046_active_infractions_migration.py create mode 100644 pydis_site/apps/api/migrations/0047_add_infractions_unique_constraints_active.py diff --git a/pydis_site/apps/api/migrations/0044_active_infractions_migration.py b/pydis_site/apps/api/migrations/0044_active_infractions_migration.py deleted file mode 100644 index 14746712..00000000 --- a/pydis_site/apps/api/migrations/0044_active_infractions_migration.py +++ /dev/null @@ -1,106 +0,0 @@ -# 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/migrations/0045_add_infractions_unique_constraints_active.py b/pydis_site/apps/api/migrations/0045_add_infractions_unique_constraints_active.py deleted file mode 100644 index bacb56b8..00000000 --- a/pydis_site/apps/api/migrations/0045_add_infractions_unique_constraints_active.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 2.2.6 on 2019-10-07 18:27 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0044_active_infractions_migration'), - ] - - operations = [ - migrations.AddConstraint( - model_name='infraction', - constraint=models.UniqueConstraint(condition=models.Q(active=True), fields=('user', 'type'), name='unique_active_infraction_per_type_per_user'), - ), - ] diff --git a/pydis_site/apps/api/migrations/0046_active_infractions_migration.py b/pydis_site/apps/api/migrations/0046_active_infractions_migration.py new file mode 100644 index 00000000..7e5a631c --- /dev/null +++ b/pydis_site/apps/api/migrations/0046_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', '0045_add_plural_name_for_log_entry'), + ] + + 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/migrations/0047_add_infractions_unique_constraints_active.py b/pydis_site/apps/api/migrations/0047_add_infractions_unique_constraints_active.py new file mode 100644 index 00000000..dc266474 --- /dev/null +++ b/pydis_site/apps/api/migrations/0047_add_infractions_unique_constraints_active.py @@ -0,0 +1,17 @@ +# Generated by Django 2.2.6 on 2019-10-07 18:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0046_active_infractions_migration'), + ] + + operations = [ + migrations.AddConstraint( + model_name='infraction', + constraint=models.UniqueConstraint(condition=models.Q(active=True), fields=('user', 'type'), name='unique_active_infraction_per_type_per_user'), + ), + ] 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 cfae4219..61574b7e 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 @@ -81,8 +81,8 @@ 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" + migration_prior = "0045_add_plural_name_for_log_entry" + migration_target = "0045_add_plural_name_for_log_entry" @classmethod def setUpPostMigrationData(cls, apps): @@ -183,8 +183,8 @@ class ActiveInfractionMigrationTests(MigrationsTestCase): """ app = "api" - migration_prior = "0043_infraction_hidden_warnings_to_notes" - migration_target = "0044_active_infractions_migration" + migration_prior = "0045_add_plural_name_for_log_entry" + migration_target = "0046_active_infractions_migration" @classmethod def setUpMigrationData(cls, apps): -- cgit v1.2.3 From 09f923b3324b685ea3e9c3cfa715eeb877dd9bd0 Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Sat, 19 Oct 2019 12:13:37 +0100 Subject: Initial navbar change to add settings button next to logout --- pydis_site/static/css/base/base.css | 3 +-- pydis_site/templates/base/navbar.html | 9 ++++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/pydis_site/static/css/base/base.css b/pydis_site/static/css/base/base.css index 3ca6b2a7..24282628 100644 --- a/pydis_site/static/css/base/base.css +++ b/pydis_site/static/css/base/base.css @@ -84,7 +84,6 @@ div.card.has-equal-height { /* Fix for logout form submit button in navbar */ -button.is-size-navbar-menu { +button.is-size-navbar-menu, a.is-size-navbar-menu { font-size: 14px; } - diff --git a/pydis_site/templates/base/navbar.html b/pydis_site/templates/base/navbar.html index 8cdac0de..f0041b6b 100644 --- a/pydis_site/templates/base/navbar.html +++ b/pydis_site/templates/base/navbar.html @@ -102,7 +102,14 @@ {% else %}
{% csrf_token %} - +
{% endif %} -- cgit v1.2.3 From 7bdd61049e952671e1bd3c809545bb8cecb3956f Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Sat, 19 Oct 2019 15:15:40 +0100 Subject: Bring navbar styling in line on mobile as well --- pydis_site/static/css/base/base.css | 9 +++++++++ pydis_site/templates/base/navbar.html | 1 + 2 files changed, 10 insertions(+) diff --git a/pydis_site/static/css/base/base.css b/pydis_site/static/css/base/base.css index 24282628..7db9499d 100644 --- a/pydis_site/static/css/base/base.css +++ b/pydis_site/static/css/base/base.css @@ -86,4 +86,13 @@ div.card.has-equal-height { button.is-size-navbar-menu, a.is-size-navbar-menu { font-size: 14px; + padding-left: 1.5rem; + padding-right: 1.5rem; +} + +@media screen and (min-width: 1088px) { + button.is-size-navbar-menu, a.is-size-navbar-menu { + padding-left: 1rem; + padding-right: 1rem; + } } diff --git a/pydis_site/templates/base/navbar.html b/pydis_site/templates/base/navbar.html index f0041b6b..f1a3f928 100644 --- a/pydis_site/templates/base/navbar.html +++ b/pydis_site/templates/base/navbar.html @@ -102,6 +102,7 @@ {% else %}
{% csrf_token %} +