diff options
Diffstat (limited to 'pydis_site/apps')
-rw-r--r-- | pydis_site/apps/api/admin.py | 55 | ||||
-rw-r--r-- | pydis_site/apps/api/migrations/0044_migrate_nominations_from_infraction_to_nomination_model.py | 64 | ||||
-rw-r--r-- | pydis_site/apps/api/migrations/0045_add_plural_name_for_log_entry.py | 17 | ||||
-rw-r--r-- | pydis_site/apps/api/models/bot/nomination.py | 5 | ||||
-rw-r--r-- | pydis_site/apps/api/models/log_entry.py | 5 | ||||
-rw-r--r-- | pydis_site/apps/api/tests/test_models.py | 39 | ||||
-rw-r--r-- | pydis_site/apps/home/__init__.py | 1 | ||||
-rw-r--r-- | pydis_site/apps/home/apps.py | 33 | ||||
-rw-r--r-- | pydis_site/apps/home/signals.py | 260 | ||||
-rw-r--r-- | pydis_site/apps/home/tests/test_signal_listener.py | 401 | ||||
-rw-r--r-- | pydis_site/apps/home/tests/test_views.py | 17 | ||||
-rw-r--r-- | pydis_site/apps/home/urls.py | 22 | ||||
-rw-r--r-- | pydis_site/apps/staff/admin.py | 6 | ||||
-rw-r--r-- | pydis_site/apps/staff/migrations/0001_initial.py | 25 | ||||
-rw-r--r-- | pydis_site/apps/staff/models/__init__.py | 3 | ||||
-rw-r--r-- | pydis_site/apps/staff/models/role_mapping.py | 26 |
16 files changed, 967 insertions, 12 deletions
diff --git a/pydis_site/apps/api/admin.py b/pydis_site/apps/api/admin.py index c3784317..059f52eb 100644 --- a/pydis_site/apps/api/admin.py +++ b/pydis_site/apps/api/admin.py @@ -1,18 +1,63 @@ +from typing import Optional + from django.contrib import admin +from django.http import HttpRequest from .models import ( - BotSetting, DeletedMessage, - DocumentationLink, Infraction, - MessageDeletionContext, Nomination, - OffTopicChannelName, Role, - Tag, User + BotSetting, + DeletedMessage, + DocumentationLink, + Infraction, + LogEntry, + MessageDeletionContext, + Nomination, + OffTopicChannelName, + Role, + Tag, + User ) +class LogEntryAdmin(admin.ModelAdmin): + """Allows viewing logs in the Django Admin without allowing edits.""" + + actions = None + list_display = ('timestamp', 'application', 'level', 'message') + fieldsets = ( + ('Overview', {'fields': ('timestamp', 'application', 'logger_name')}), + ('Metadata', {'fields': ('level', 'module', 'line')}), + ('Contents', {'fields': ('message',)}) + ) + list_filter = ('application', 'level', 'timestamp') + search_fields = ('message',) + readonly_fields = ( + 'application', + 'logger_name', + 'timestamp', + 'level', + 'module', + 'line', + 'message' + ) + + def has_add_permission(self, request: HttpRequest) -> bool: + """Deny manual LogEntry creation.""" + return False + + def has_delete_permission( + self, + request: HttpRequest, + obj: Optional[LogEntry] = None + ) -> bool: + """Deny LogEntry deletion.""" + return False + + admin.site.register(BotSetting) admin.site.register(DeletedMessage) admin.site.register(DocumentationLink) admin.site.register(Infraction) +admin.site.register(LogEntry, LogEntryAdmin) admin.site.register(MessageDeletionContext) admin.site.register(Nomination) admin.site.register(OffTopicChannelName) diff --git a/pydis_site/apps/api/migrations/0044_migrate_nominations_from_infraction_to_nomination_model.py b/pydis_site/apps/api/migrations/0044_migrate_nominations_from_infraction_to_nomination_model.py new file mode 100644 index 00000000..a56450c0 --- /dev/null +++ b/pydis_site/apps/api/migrations/0044_migrate_nominations_from_infraction_to_nomination_model.py @@ -0,0 +1,64 @@ +# Generated by Django 2.2.5 on 2019-09-30 12:15 +import logging + +from django.db import migrations +from django.db.models import Q + +log = logging.getLogger('nomination_migration') + + +def migrate_nominations_to_new_model(apps, schema_editor): + """ + Migrations nominations from the infraction model to the nomination model. + + This migration works by replaying the nomination history in chronological order, adding and + ending nominations as we've recorded them. + """ + Infraction = apps.get_model('api', 'Infraction') + Nomination = apps.get_model('api', 'Nomination') + + all_nominations = ( + Q(reason__startswith="Helper nomination:") | Q(reason__startswith="Unwatched (talent-pool):") + ) + + for infraction in Infraction.objects.filter(all_nominations).order_by('inserted_at'): + if infraction.reason.startswith("Helper nomination:"): + if Nomination.objects.filter(user=infraction.user, active=True).exists(): + log.error( + f"User `{infraction.user.id}` already has an active nomination, aborting." + ) + continue + nomination = Nomination( + user=infraction.user, + inserted_at=infraction.inserted_at, + reason=infraction.reason[19:], # Strip "Helper nomination: " prefix + actor=infraction.actor, + active=True, + ) + nomination.save() + infraction.delete() + elif infraction.reason.startswith("Unwatched (talent-pool):"): + if not Nomination.objects.filter(user=infraction.user, active=True).exists(): + log.error( + f"User `{infraction.user.id}` has no active nomination, can't end it!" + ) + continue + nomination = Nomination.objects.get(user=infraction.user, active=True) + nomination.end_reason = infraction.reason[25:] # Strip "Unwatched (talent-pool):" + nomination.ended_at = infraction.inserted_at + nomination.active = False + nomination.save() + infraction.delete() + else: + log.error(f"I don't understand this infraction: {infraction}") + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0043_infraction_hidden_warnings_to_notes'), + ] + + operations = [ + migrations.RunPython(migrate_nominations_to_new_model), + ] diff --git a/pydis_site/apps/api/migrations/0045_add_plural_name_for_log_entry.py b/pydis_site/apps/api/migrations/0045_add_plural_name_for_log_entry.py new file mode 100644 index 00000000..6b9933d8 --- /dev/null +++ b/pydis_site/apps/api/migrations/0045_add_plural_name_for_log_entry.py @@ -0,0 +1,17 @@ +# Generated by Django 2.2.3 on 2019-10-11 17:48 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0044_migrate_nominations_from_infraction_to_nomination_model'), + ] + + operations = [ + migrations.AlterModelOptions( + name='logentry', + options={'verbose_name_plural': 'Log entries'}, + ), + ] diff --git a/pydis_site/apps/api/models/bot/nomination.py b/pydis_site/apps/api/models/bot/nomination.py index 8a8f4d36..cd9951aa 100644 --- a/pydis_site/apps/api/models/bot/nomination.py +++ b/pydis_site/apps/api/models/bot/nomination.py @@ -39,3 +39,8 @@ class Nomination(ModelReprMixin, models.Model): help_text="When the nomination was ended.", null=True ) + + def __str__(self): + """Representation that makes the target and state of the nomination immediately evident.""" + status = "active" if self.active else "ended" + return f"Nomination of {self.user} ({status})" diff --git a/pydis_site/apps/api/models/log_entry.py b/pydis_site/apps/api/models/log_entry.py index acd7953a..488af48e 100644 --- a/pydis_site/apps/api/models/log_entry.py +++ b/pydis_site/apps/api/models/log_entry.py @@ -48,3 +48,8 @@ class LogEntry(ModelReprMixin, models.Model): message = models.TextField( help_text="The textual content of the log line." ) + + class Meta: + """Customizes the default generated plural name to valid English.""" + + verbose_name_plural = 'Log entries' diff --git a/pydis_site/apps/api/tests/test_models.py b/pydis_site/apps/api/tests/test_models.py index 2120b056..bce76942 100644 --- a/pydis_site/apps/api/tests/test_models.py +++ b/pydis_site/apps/api/tests/test_models.py @@ -1,13 +1,21 @@ -from datetime import datetime as dt, timezone +from datetime import datetime as dt from django.test import SimpleTestCase +from django.utils import timezone from ..models import ( - DeletedMessage, DocumentationLink, - Infraction, Message, - MessageDeletionContext, ModelReprMixin, - OffTopicChannelName, Reminder, - Role, Tag, User + DeletedMessage, + DocumentationLink, + Infraction, + Message, + MessageDeletionContext, + ModelReprMixin, + Nomination, + OffTopicChannelName, + Reminder, + Role, + Tag, + User ) @@ -27,6 +35,19 @@ class ReprMixinTests(SimpleTestCase): class StringDunderMethodTests(SimpleTestCase): def setUp(self): + self.nomination = Nomination( + id=123, + actor=User( + id=9876, name='Mr. Hemlock', + discriminator=6666, avatar_hash=None + ), + user=User( + id=9876, name="Hemlock's Cat", + discriminator=7777, avatar_hash=None + ), + reason="He purrrrs like the best!", + ) + self.objects = ( DeletedMessage( id=45, @@ -102,3 +123,9 @@ class StringDunderMethodTests(SimpleTestCase): def test_returns_string(self): for instance in self.objects: self.assertIsInstance(str(instance), str) + + def test_nomination_str_representation(self): + self.assertEqual( + "Nomination of Hemlock's Cat#7777 (active)", + str(self.nomination) + ) diff --git a/pydis_site/apps/home/__init__.py b/pydis_site/apps/home/__init__.py index e69de29b..ecfab449 100644 --- a/pydis_site/apps/home/__init__.py +++ b/pydis_site/apps/home/__init__.py @@ -0,0 +1 @@ +default_app_config = "pydis_site.apps.home.apps.HomeConfig" diff --git a/pydis_site/apps/home/apps.py b/pydis_site/apps/home/apps.py index 9a3d213c..55a393a9 100644 --- a/pydis_site/apps/home/apps.py +++ b/pydis_site/apps/home/apps.py @@ -1,7 +1,38 @@ +from typing import Any, Dict + from django.apps import AppConfig class HomeConfig(AppConfig): """Django AppConfig for the home app.""" - name = 'home' + name = 'pydis_site.apps.home' + signal_listener = None + + def ready(self) -> None: + """Run when the app has been loaded and is ready to serve requests.""" + from pydis_site.apps.home.signals import AllauthSignalListener + + self.signal_listener = AllauthSignalListener() + self.patch_allauth() + + def patch_allauth(self) -> None: + """Monkey-patches Allauth classes so we never collect email addresses.""" + # Imported here because we can't import it before our apps are loaded up + from allauth.socialaccount.providers.base import Provider + + def extract_extra_data(_: Provider, data: Dict[str, Any]) -> Dict[str, Any]: + """ + Extracts extra data for a SocialAccount provided by Allauth. + + This is our version of this function that strips the email address from incoming extra + data. We do this so that we never have to store it. + + This is monkey-patched because most OAuth providers - or at least the ones we care + about - all use the function from the base Provider class. This means we don't have + to make a new Django app for each one we want to work with. + """ + data["email"] = "" + return data + + Provider.extract_extra_data = extract_extra_data diff --git a/pydis_site/apps/home/signals.py b/pydis_site/apps/home/signals.py new file mode 100644 index 00000000..9f286882 --- /dev/null +++ b/pydis_site/apps/home/signals.py @@ -0,0 +1,260 @@ +from typing import List, Optional, Type + +from allauth.account.signals import user_logged_in +from allauth.socialaccount.models import SocialAccount, SocialLogin +from allauth.socialaccount.providers.base import Provider +from allauth.socialaccount.providers.discord.provider import DiscordProvider +from allauth.socialaccount.signals import ( + pre_social_login, social_account_added, social_account_removed, + social_account_updated) +from django.contrib.auth.models import Group, User as DjangoUser +from django.db.models.signals import post_save, pre_delete, pre_save + +from pydis_site.apps.api.models import User as DiscordUser +from pydis_site.apps.staff.models import RoleMapping + + +class AllauthSignalListener: + """ + Listens to and processes events via the Django Signals system. + + Django Signals is basically an event dispatcher. It consists of Signals (which are the events) + and Receivers, which listen for and handle those events. Signals are triggered by Senders, + which are essentially just any class at all, and Receivers can filter the Signals they listen + for by choosing a Sender, if required. + + Signals themselves define a set of arguments that they will provide to Receivers when the + Signal is sent. They are always keyword arguments, and Django recommends that all Receiver + functions accept them as `**kwargs` (and will supposedly error if you don't do this), + supposedly because Signals can change in the future and your receivers should still work. + + Signals do provide a list of their arguments when they're initially constructed, but this + is purely for documentation purposes only and Django does not enforce it. + + The Django Signals docs are here: https://docs.djangoproject.com/en/2.2/topics/signals/ + """ + + def __init__(self): + post_save.connect(self.user_model_updated, sender=DiscordUser) + + pre_delete.connect(self.mapping_model_deleted, sender=RoleMapping) + pre_save.connect(self.mapping_model_updated, sender=RoleMapping) + + pre_social_login.connect(self.social_account_updated) + social_account_added.connect(self.social_account_updated) + social_account_updated.connect(self.social_account_updated) + social_account_removed.connect(self.social_account_removed) + + user_logged_in.connect(self.user_logged_in) + + def user_logged_in(self, sender: Type[DjangoUser], **kwargs) -> None: + """ + Processes Allauth login signals to ensure a user has the correct perms. + + This method tries to find a Discord SocialAccount for a user - this should always + be the case, but the admin user likely won't have one, so we do check for it. + + After that, we try to find the user's stored Discord account details, provided by the + bot on the server. Finally, we pass the relevant information over to the + `_apply_groups()` method for final processing. + """ + user: DjangoUser = kwargs["user"] + + try: + account: SocialAccount = SocialAccount.objects.get( + user=user, provider=DiscordProvider.id + ) + except SocialAccount.DoesNotExist: + return # User's never linked a Discord account + + try: + discord_user: DiscordUser = DiscordUser.objects.get(id=int(account.uid)) + except DiscordUser.DoesNotExist: + return + + self._apply_groups(discord_user, account) + + def social_account_updated(self, sender: Type[SocialLogin], **kwargs) -> None: + """ + Processes Allauth social account update signals to ensure a user has the correct perms. + + In this case, a SocialLogin is provided that we can check against. We check that this + is a Discord login in order to ensure that future OAuth logins using other providers + don't break things. + + Like most of the other methods that handle signals, this method defers to the + `_apply_groups()` method for final processing. + """ + social_login: SocialLogin = kwargs["sociallogin"] + + account: SocialAccount = social_login.account + provider: Provider = account.get_provider() + + if not isinstance(provider, DiscordProvider): + return + + try: + user: DiscordUser = DiscordUser.objects.get(id=int(account.uid)) + except DiscordUser.DoesNotExist: + return + + self._apply_groups(user, account) + + def social_account_removed(self, sender: Type[SocialLogin], **kwargs) -> None: + """ + Processes Allauth social account reomval signals to ensure a user has the correct perms. + + In this case, a SocialAccount is provided that we can check against. If this is a + Discord OAuth being removed from the account, we want to ensure that the user loses + their permissions groups as well. + + While this isn't a realistic scenario to reach in our current setup, I've provided it + for the sake of covering any edge cases and ensuring that SocialAccounts can be removed + from Django users in the future if required. + + Like most of the other methods that handle signals, this method defers to the + `_apply_groups()` method for final processing. + """ + account: SocialAccount = kwargs["socialaccount"] + provider: Provider = account.get_provider() + + if not isinstance(provider, DiscordProvider): + return + + try: + user: DiscordUser = DiscordUser.objects.get(id=int(account.uid)) + except DiscordUser.DoesNotExist: + return + + self._apply_groups(user, account, deletion=True) + + def mapping_model_deleted(self, sender: Type[RoleMapping], **kwargs) -> None: + """ + Processes deletion signals from the RoleMapping model, removing perms from users. + + We need to do this to ensure that users aren't left with permissions groups that + they shouldn't have assigned to them when a RoleMapping is deleted from the database. + """ + instance: RoleMapping = kwargs["instance"] + + for user in instance.group.user_set.all(): + user.groups.remove(instance.group) + + def mapping_model_updated(self, sender: Type[RoleMapping], **kwargs) -> None: + """ + Processes update signals from the RoleMapping model. + + This method is in charge of figuring out what changed when a RoleMapping is updated + (via the Django admin or otherwise). It operates based on what was changed, and can + handle changes to both the role and permissions group assigned to it. + """ + instance: RoleMapping = kwargs["instance"] + raw: bool = kwargs["raw"] + + if raw: + # Fixtures are being loaded, so don't touch anything + return + + old_instance: Optional[RoleMapping] = None + + if instance.id is not None: + # We don't try to catch DoesNotExist here because we can't test for it, + # it should never happen (unless we have a bad DB failure) but I'm still + # kind of antsy about not having the extra security here. + + old_instance = RoleMapping.objects.get(id=instance.id) + + if old_instance: + self.mapping_model_deleted(RoleMapping, instance=old_instance) + + accounts = SocialAccount.objects.filter( + uid__in=(u.id for u in instance.role.user_set.all()) + ) + + for account in accounts: + account.user.groups.add(instance.group) + + def user_model_updated(self, sender: Type[DiscordUser], **kwargs) -> None: + """ + Processes update signals from the Discord User model, assigning perms as required. + + When a user's roles are changed on the Discord server, this method will ensure that + the user has only the permissions groups that they should have based on the RoleMappings + that have been set up in the Django admin. + + Like some of the other signal handlers, this method ensures that a SocialAccount exists + for this Discord User, and defers to `_apply_groups()` to do the heavy lifting of + ensuring the permissions groups are correct. + """ + instance: DiscordUser = kwargs["instance"] + raw: bool = kwargs["raw"] + + # `update_fields` could be used for checking changes, but it's None here due to how the + # model is saved without using that argument - so we can't use it. + + if raw: + # Fixtures are being loaded, so don't touch anything + return + + try: + account: SocialAccount = SocialAccount.objects.get( + uid=str(instance.id), provider=DiscordProvider.id + ) + except SocialAccount.DoesNotExist: + return # User has never logged in with Discord on the site + + self._apply_groups(instance, account) + + def _apply_groups( + self, user: DiscordUser, account: SocialAccount, deletion: bool = False + ) -> None: + """ + Ensures that the correct permissions are set for a Django user based on the RoleMappings. + + This (private) method is designed to check a Discord User against a given SocialAccount, + and makes sure that the Django user associated with the SocialAccount has the correct + permissions groups. + + While it would be possible to get the Discord User object with just the SocialAccount + object, the current approach results in less queries. + + The `deletion` parameter is used to signify that the user's SocialAccount is about + to be removed, and so we should always remove all of their permissions groups. The + same thing will happen if the user is no longer actually on the Discord server, as + leaving the server does not currently remove their SocialAccount from the database. + """ + mappings = RoleMapping.objects.all() + + try: + current_groups: List[Group] = list(account.user.groups.all()) + except SocialAccount.user.RelatedObjectDoesNotExist: + return # There's no user account yet, this will be handled by another receiver + + if not user.in_guild: + deletion = True + + if deletion: + # They've unlinked Discord or left the server, so we have to remove their groups + + if not current_groups: + return # They have no groups anyway, no point in processing + + account.user.groups.remove( + *(mapping.group for mapping in mappings) + ) + else: + new_groups = [] + + for role in user.roles.all(): + try: + new_groups.append(mappings.get(role=role).group) + except RoleMapping.DoesNotExist: + continue # No mapping exists + + account.user.groups.add( + *[group for group in new_groups if group not in current_groups] + ) + + account.user.groups.remove( + *[mapping.group for mapping in mappings if mapping.group not in new_groups] + ) diff --git a/pydis_site/apps/home/tests/test_signal_listener.py b/pydis_site/apps/home/tests/test_signal_listener.py new file mode 100644 index 00000000..27fc7710 --- /dev/null +++ b/pydis_site/apps/home/tests/test_signal_listener.py @@ -0,0 +1,401 @@ +from unittest import mock + +from allauth.account.signals import user_logged_in +from allauth.socialaccount.models import SocialAccount, SocialLogin +from allauth.socialaccount.providers import registry +from allauth.socialaccount.providers.discord.provider import DiscordProvider +from allauth.socialaccount.providers.github.provider import GitHubProvider +from allauth.socialaccount.signals import ( + pre_social_login, social_account_added, social_account_removed, + social_account_updated) +from django.contrib.auth.models import Group, User as DjangoUser +from django.db.models.signals import post_save, pre_save +from django.test import TestCase + +from pydis_site.apps.api.models import Role, User as DiscordUser +from pydis_site.apps.home.signals import AllauthSignalListener +from pydis_site.apps.staff.models import RoleMapping + + +class SignalListenerTests(TestCase): + @classmethod + def setUpTestData(cls): + """ + Executed when testing begins in order to set up database fixtures required for testing. + + This sets up quite a lot of stuff, in order to try to cover every eventuality while + ensuring that everything works when every possible situation is in the database + at the same time. + + That does unfortunately mean that half of this file is just test fixtures, but I couldn't + think of a better way to do this. + """ + # This needs to be registered so we can test the role linking logic with a user that + # doesn't have a Discord account linked, but is logged in somehow with another account + # type anyway. The logic this is testing was designed so that the system would be + # robust enough to handle that case, but it's impossible to fully test (and therefore + # to have coverage of) those lines without an extra provider, and GH was the second + # provider it was built with in mind. + registry.register(GitHubProvider) + + cls.admin_role = Role.objects.create( + id=0, + name="admin", + colour=0, + permissions=0, + position=0 + ) + + cls.moderator_role = Role.objects.create( + id=1, + name="moderator", + colour=0, + permissions=0, + position=1 + ) + + cls.unmapped_role = Role.objects.create( + id=2, + name="unmapped", + colour=0, + permissions=0, + position=1 + ) + + cls.admin_group = Group.objects.create(name="admin") + cls.moderator_group = Group.objects.create(name="moderator") + + cls.admin_mapping = RoleMapping.objects.create( + role=cls.admin_role, + group=cls.admin_group + ) + + cls.moderator_mapping = RoleMapping.objects.create( + role=cls.moderator_role, + group=cls.moderator_group + ) + + cls.discord_user = DiscordUser.objects.create( + id=0, + name="user", + discriminator=0, + avatar_hash=None + ) + + cls.discord_unmapped = DiscordUser.objects.create( + id=2, + name="unmapped", + discriminator=0, + avatar_hash=None + ) + + cls.discord_unmapped.roles.add(cls.unmapped_role) + cls.discord_unmapped.save() + + cls.discord_not_in_guild = DiscordUser.objects.create( + id=3, + name="not-in-guild", + discriminator=0, + avatar_hash=None, + in_guild=False + ) + + cls.discord_admin = DiscordUser.objects.create( + id=1, + name="admin", + discriminator=0, + avatar_hash=None + ) + + cls.discord_admin.roles.set([cls.admin_role]) + cls.discord_admin.save() + + cls.discord_moderator = DiscordUser.objects.create( + id=4, + name="admin", + discriminator=0, + avatar_hash=None + ) + + cls.discord_moderator.roles.set([cls.moderator_role]) + cls.discord_moderator.save() + + cls.django_user_discordless = DjangoUser.objects.create(username="no-discord") + cls.django_user_never_joined = DjangoUser.objects.create(username="never-joined") + + cls.social_never_joined = SocialAccount.objects.create( + user=cls.django_user_never_joined, + provider=DiscordProvider.id, + uid=5 + ) + + cls.django_user = DjangoUser.objects.create(username="user") + + cls.social_user = SocialAccount.objects.create( + user=cls.django_user, + provider=DiscordProvider.id, + uid=cls.discord_user.id + ) + + cls.social_user_github = SocialAccount.objects.create( + user=cls.django_user, + provider=GitHubProvider.id, + uid=cls.discord_user.id + ) + + cls.social_unmapped = SocialAccount( + # We instantiate it and don't put it in the DB. This is (surprisingly) + # a realistic test case, so we need to check for it + + provider=DiscordProvider.id, + uid=5, + user_id=None # No relation exists at all + ) + + cls.django_admin = DjangoUser.objects.create( + username="admin", + is_staff=True, + is_superuser=True + ) + + cls.social_admin = SocialAccount.objects.create( + user=cls.django_admin, + provider=DiscordProvider.id, + uid=cls.discord_admin.id + ) + + cls.django_moderator = DjangoUser.objects.create( + username="moderator", + is_staff=True, + is_superuser=False + ) + + cls.social_moderator = SocialAccount.objects.create( + user=cls.django_moderator, + provider=DiscordProvider.id, + uid=cls.discord_moderator.id + ) + + def test_model_save(self): + """Test signal handling for when Discord user model objects are saved to DB.""" + mock_obj = mock.Mock() + + with mock.patch.object(AllauthSignalListener, "_apply_groups", mock_obj): + AllauthSignalListener() + + post_save.send( + DiscordUser, + instance=self.discord_user, + raw=True, + created=None, # Not realistic, but we don't use it + using=None, # Again, we don't use it + update_fields=False # Always false during integration testing + ) + + mock_obj.assert_not_called() + + post_save.send( + DiscordUser, + instance=self.discord_user, + raw=False, + created=None, # Not realistic, but we don't use it + using=None, # Again, we don't use it + update_fields=False # Always false during integration testing + ) + + mock_obj.assert_called_with(self.discord_user, self.social_user) + + def test_pre_social_login(self): + """Test the pre-social-login Allauth signal handling.""" + mock_obj = mock.Mock() + + discord_login = SocialLogin(self.django_user, self.social_user) + github_login = SocialLogin(self.django_user, self.social_user_github) + unmapped_login = SocialLogin(self.django_user, self.social_unmapped) + + with mock.patch.object(AllauthSignalListener, "_apply_groups", mock_obj): + AllauthSignalListener() + + # Don't attempt to apply groups if the user doesn't have a linked Discord account + pre_social_login.send(SocialLogin, sociallogin=github_login) + mock_obj.assert_not_called() + + # Don't attempt to apply groups if the user hasn't joined the Discord server + pre_social_login.send(SocialLogin, sociallogin=unmapped_login) + mock_obj.assert_not_called() + + # Attempt to apply groups if everything checks out + pre_social_login.send(SocialLogin, sociallogin=discord_login) + mock_obj.assert_called_with(self.discord_user, self.social_user) + + def test_social_added(self): + """Test the social-account-added Allauth signal handling.""" + mock_obj = mock.Mock() + + discord_login = SocialLogin(self.django_user, self.social_user) + github_login = SocialLogin(self.django_user, self.social_user_github) + unmapped_login = SocialLogin(self.django_user, self.social_unmapped) + + with mock.patch.object(AllauthSignalListener, "_apply_groups", mock_obj): + AllauthSignalListener() + + # Don't attempt to apply groups if the user doesn't have a linked Discord account + social_account_added.send(SocialLogin, sociallogin=github_login) + mock_obj.assert_not_called() + + # Don't attempt to apply groups if the user hasn't joined the Discord server + social_account_added.send(SocialLogin, sociallogin=unmapped_login) + mock_obj.assert_not_called() + + # Attempt to apply groups if everything checks out + social_account_added.send(SocialLogin, sociallogin=discord_login) + mock_obj.assert_called_with(self.discord_user, self.social_user) + + def test_social_updated(self): + """Test the social-account-updated Allauth signal handling.""" + mock_obj = mock.Mock() + + discord_login = SocialLogin(self.django_user, self.social_user) + github_login = SocialLogin(self.django_user, self.social_user_github) + unmapped_login = SocialLogin(self.django_user, self.social_unmapped) + + with mock.patch.object(AllauthSignalListener, "_apply_groups", mock_obj): + AllauthSignalListener() + + # Don't attempt to apply groups if the user doesn't have a linked Discord account + social_account_updated.send(SocialLogin, sociallogin=github_login) + mock_obj.assert_not_called() + + # Don't attempt to apply groups if the user hasn't joined the Discord server + social_account_updated.send(SocialLogin, sociallogin=unmapped_login) + mock_obj.assert_not_called() + + # Attempt to apply groups if everything checks out + social_account_updated.send(SocialLogin, sociallogin=discord_login) + mock_obj.assert_called_with(self.discord_user, self.social_user) + + def test_social_removed(self): + """Test the social-account-removed Allauth signal handling.""" + mock_obj = mock.Mock() + + with mock.patch.object(AllauthSignalListener, "_apply_groups", mock_obj): + AllauthSignalListener() + + # Don't attempt to remove groups if the user doesn't have a linked Discord account + social_account_removed.send(SocialLogin, socialaccount=self.social_user_github) + mock_obj.assert_not_called() + + # Don't attempt to remove groups if the social account doesn't map to a Django user + social_account_removed.send(SocialLogin, socialaccount=self.social_unmapped) + mock_obj.assert_not_called() + + # Attempt to remove groups if everything checks out + social_account_removed.send(SocialLogin, socialaccount=self.social_user) + mock_obj.assert_called_with(self.discord_user, self.social_user, deletion=True) + + def test_logged_in(self): + """Test the user-logged-in Allauth signal handling.""" + mock_obj = mock.Mock() + + with mock.patch.object(AllauthSignalListener, "_apply_groups", mock_obj): + AllauthSignalListener() + + # Don't attempt to apply groups if the user doesn't have a linked Discord account + user_logged_in.send(DjangoUser, user=self.django_user_discordless) + mock_obj.assert_not_called() + + # Don't attempt to apply groups if the user hasn't joined the Discord server + user_logged_in.send(DjangoUser, user=self.django_user_never_joined) + mock_obj.assert_not_called() + + # Attempt to apply groups if everything checks out + user_logged_in.send(DjangoUser, user=self.django_user) + mock_obj.assert_called_with(self.discord_user, self.social_user) + + def test_apply_groups_admin(self): + """Test application of groups by role, relating to an admin user.""" + handler = AllauthSignalListener() + + self.assertEqual(self.django_user_discordless.groups.all().count(), 0) + + # Apply groups based on admin role being present on Discord + handler._apply_groups(self.discord_admin, self.social_admin) + self.assertTrue(self.admin_group in self.django_admin.groups.all()) + + # Remove groups based on the user apparently leaving the server + handler._apply_groups(self.discord_admin, self.social_admin, True) + self.assertEqual(self.django_user_discordless.groups.all().count(), 0) + + # Apply the admin role again + handler._apply_groups(self.discord_admin, self.social_admin) + + # Remove all of the roles from the user + self.discord_admin.roles.clear() + + # Remove groups based on the user no longer having the admin role on Discord + handler._apply_groups(self.discord_admin, self.social_admin) + self.assertEqual(self.django_user_discordless.groups.all().count(), 0) + + self.discord_admin.roles.add(self.admin_role) + self.discord_admin.save() + + def test_apply_groups_other(self): + """Test application of groups by role, relating to non-standard cases.""" + handler = AllauthSignalListener() + + self.assertEqual(self.django_user_discordless.groups.all().count(), 0) + + # No groups should be applied when there's no user account yet + handler._apply_groups(self.discord_unmapped, self.social_unmapped) + self.assertEqual(self.django_user_discordless.groups.all().count(), 0) + + # No groups should be applied when there are only unmapped roles to match + handler._apply_groups(self.discord_unmapped, self.social_user) + self.assertEqual(self.django_user.groups.all().count(), 0) + + # No groups should be applied when the user isn't in the guild + handler._apply_groups(self.discord_not_in_guild, self.social_user) + self.assertEqual(self.django_user.groups.all().count(), 0) + + def test_role_mapping_str(self): + """Test that role mappings stringify correctly.""" + self.assertEqual( + str(self.admin_mapping), + f"@{self.admin_role.name} -> {self.admin_group.name}" + ) + + def test_role_mapping_changes(self): + """Test that role mapping listeners work when changes are made.""" + # Set up (just for this test) + self.django_moderator.groups.add(self.moderator_group) + self.django_admin.groups.add(self.admin_group) + + self.assertEqual(self.django_moderator.groups.all().count(), 1) + self.assertEqual(self.django_admin.groups.all().count(), 1) + + # Test mapping deletion + self.admin_mapping.delete() + + self.assertEqual(self.django_admin.groups.all().count(), 0) + + # Test mapping update + self.moderator_mapping.group = self.admin_group + self.moderator_mapping.save() + + self.assertEqual(self.django_moderator.groups.all().count(), 1) + self.assertTrue(self.admin_group in self.django_moderator.groups.all()) + + # Test mapping creation + new_mapping = RoleMapping.objects.create( + role=self.admin_role, + group=self.moderator_group + ) + + self.assertEqual(self.django_admin.groups.all().count(), 1) + self.assertTrue(self.moderator_group in self.django_admin.groups.all()) + + # Test that nothing happens when fixtures are loaded + pre_save.send(RoleMapping, instance=new_mapping, raw=True) + + self.assertEqual(self.django_admin.groups.all().count(), 1) + self.assertTrue(self.moderator_group in self.django_admin.groups.all()) diff --git a/pydis_site/apps/home/tests/test_views.py b/pydis_site/apps/home/tests/test_views.py index 73678b0a..7aeaddd2 100644 --- a/pydis_site/apps/home/tests/test_views.py +++ b/pydis_site/apps/home/tests/test_views.py @@ -4,6 +4,23 @@ from django_hosts.resolvers import reverse class TestIndexReturns200(TestCase): def test_index_returns_200(self): + """Check that the index page returns a HTTP 200 response.""" url = reverse('home') resp = self.client.get(url) self.assertEqual(resp.status_code, 200) + + +class TestLoginCancelledReturns302(TestCase): + def test_login_cancelled_returns_302(self): + """Check that the login cancelled redirect returns a HTTP 302 response.""" + url = reverse('socialaccount_login_cancelled') + resp = self.client.get(url) + self.assertEqual(resp.status_code, 302) + + +class TestLoginErrorReturns302(TestCase): + def test_login_error_returns_302(self): + """Check that the login error redirect returns a HTTP 302 response.""" + url = reverse('socialaccount_login_error') + resp = self.client.get(url) + self.assertEqual(resp.status_code, 302) diff --git a/pydis_site/apps/home/urls.py b/pydis_site/apps/home/urls.py index e65abea4..211a7ad1 100644 --- a/pydis_site/apps/home/urls.py +++ b/pydis_site/apps/home/urls.py @@ -1,14 +1,36 @@ +from allauth.account.views import LogoutView +from allauth.socialaccount.views import ConnectionsView from django.conf import settings from django.conf.urls.static import static from django.contrib import admin +from django.contrib.messages import ERROR from django.urls import include, path +from pydis_site.utils.views import MessageRedirectView from .views import HomeView app_name = 'home' urlpatterns = [ path('', HomeView.as_view(), name='home'), path('pages/', include('wiki.urls')), + + path('accounts/', include('allauth.socialaccount.providers.discord.urls')), + + path( + 'accounts/login/cancelled', MessageRedirectView.as_view( + pattern_name="home", message="Login cancelled." + ), name='socialaccount_login_cancelled' + ), + path( + 'accounts/login/error', MessageRedirectView.as_view( + pattern_name="home", message="Login encountered an unknown error, please try again.", + message_level=ERROR + ), name='socialaccount_login_error' + ), + + path('connections', ConnectionsView.as_view()), + path('logout', LogoutView.as_view(), name="logout"), + path('admin/', admin.site.urls), path('notifications/', include('django_nyt.urls')), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/pydis_site/apps/staff/admin.py b/pydis_site/apps/staff/admin.py new file mode 100644 index 00000000..94cd83c5 --- /dev/null +++ b/pydis_site/apps/staff/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin + +from .models import RoleMapping + + +admin.site.register(RoleMapping) diff --git a/pydis_site/apps/staff/migrations/0001_initial.py b/pydis_site/apps/staff/migrations/0001_initial.py new file mode 100644 index 00000000..7748e553 --- /dev/null +++ b/pydis_site/apps/staff/migrations/0001_initial.py @@ -0,0 +1,25 @@ +# Generated by Django 2.2.6 on 2019-10-03 18:24 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0011_update_proxy_permissions'), + ('api', '0043_infraction_hidden_warnings_to_notes'), + ] + + operations = [ + migrations.CreateModel( + name='RoleMapping', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('group', models.OneToOneField(help_text='The Django permissions group to use for this mapping.', on_delete=django.db.models.deletion.CASCADE, to='auth.Group')), + ('role', models.OneToOneField(help_text='The Discord role to use for this mapping.', on_delete=django.db.models.deletion.CASCADE, to='api.Role')), + ], + ), + ] diff --git a/pydis_site/apps/staff/models/__init__.py b/pydis_site/apps/staff/models/__init__.py index e69de29b..b49b6fd0 100644 --- a/pydis_site/apps/staff/models/__init__.py +++ b/pydis_site/apps/staff/models/__init__.py @@ -0,0 +1,3 @@ +from .role_mapping import RoleMapping + +__all__ = ["RoleMapping"] diff --git a/pydis_site/apps/staff/models/role_mapping.py b/pydis_site/apps/staff/models/role_mapping.py new file mode 100644 index 00000000..10c09cf1 --- /dev/null +++ b/pydis_site/apps/staff/models/role_mapping.py @@ -0,0 +1,26 @@ +from django.contrib.auth.models import Group +from django.db import models + +from pydis_site.apps.api.models import Role + + +class RoleMapping(models.Model): + """A mapping between a Discord role and Django permissions group.""" + + role = models.OneToOneField( + Role, + on_delete=models.CASCADE, + help_text="The Discord role to use for this mapping.", + unique=True, # Unique in order to simplify group assignment logic + ) + + group = models.OneToOneField( + Group, + on_delete=models.CASCADE, + help_text="The Django permissions group to use for this mapping.", + unique=True, # Unique in order to simplify group assignment logic + ) + + def __str__(self): + """Returns the mapping, for display purposes.""" + return f"@{self.role.name} -> {self.group.name}" |