aboutsummaryrefslogtreecommitdiffstats
path: root/pydis_site/apps
diff options
context:
space:
mode:
Diffstat (limited to 'pydis_site/apps')
-rw-r--r--pydis_site/apps/api/admin.py55
-rw-r--r--pydis_site/apps/api/migrations/0044_migrate_nominations_from_infraction_to_nomination_model.py64
-rw-r--r--pydis_site/apps/api/migrations/0045_add_plural_name_for_log_entry.py17
-rw-r--r--pydis_site/apps/api/models/bot/nomination.py5
-rw-r--r--pydis_site/apps/api/models/log_entry.py5
-rw-r--r--pydis_site/apps/api/tests/test_models.py39
-rw-r--r--pydis_site/apps/home/__init__.py1
-rw-r--r--pydis_site/apps/home/apps.py33
-rw-r--r--pydis_site/apps/home/signals.py260
-rw-r--r--pydis_site/apps/home/tests/test_signal_listener.py401
-rw-r--r--pydis_site/apps/home/tests/test_views.py17
-rw-r--r--pydis_site/apps/home/urls.py22
-rw-r--r--pydis_site/apps/staff/admin.py6
-rw-r--r--pydis_site/apps/staff/migrations/0001_initial.py25
-rw-r--r--pydis_site/apps/staff/models/__init__.py3
-rw-r--r--pydis_site/apps/staff/models/role_mapping.py26
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}"