aboutsummaryrefslogtreecommitdiffstats
path: root/pydis_site/apps
diff options
context:
space:
mode:
Diffstat (limited to 'pydis_site/apps')
-rw-r--r--pydis_site/apps/home/__init__.py1
-rw-r--r--pydis_site/apps/home/apps.py9
-rw-r--r--pydis_site/apps/home/signals.py156
-rw-r--r--pydis_site/apps/home/urls.py1
-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.py24
8 files changed, 224 insertions, 1 deletions
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..055d721b 100644
--- a/pydis_site/apps/home/apps.py
+++ b/pydis_site/apps/home/apps.py
@@ -4,4 +4,11 @@ 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 SignalListener
+
+ self.signal_listener = SignalListener()
diff --git a/pydis_site/apps/home/signals.py b/pydis_site/apps/home/signals.py
new file mode 100644
index 00000000..687a99fe
--- /dev/null
+++ b/pydis_site/apps/home/signals.py
@@ -0,0 +1,156 @@
+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
+
+from pydis_site.apps.api.models import User as DiscordUser
+from pydis_site.apps.staff.models import RoleMapping
+
+
+class SignalListener:
+ """
+ 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.model_updated, sender=DiscordUser)
+
+ 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:
+ """Handles signals relating to Allauth logins."""
+ 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:
+ """Handle signals relating to new/existing social accounts."""
+ 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:
+ """Handle signals relating to removal of social accounts."""
+ 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, True)
+
+ def model_updated(self, sender: Type[DiscordUser], **kwargs) -> None:
+ """Handle signals related to the updating of Discord User model entries."""
+ 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))
+ 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: Optional[SocialAccount], deletion: bool = False
+ ) -> None:
+ 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
+
+ remove_groups = [
+ mapping.group for mapping in mappings if mapping.group not in new_groups
+ ]
+
+ add_groups = [group for group in new_groups if group not in current_groups]
+
+ if remove_groups:
+ account.user.groups.remove(*remove_groups)
+
+ if add_groups:
+ account.user.groups.add(*add_groups)
diff --git a/pydis_site/apps/home/urls.py b/pydis_site/apps/home/urls.py
index e65abea4..b9e7341b 100644
--- a/pydis_site/apps/home/urls.py
+++ b/pydis_site/apps/home/urls.py
@@ -9,6 +9,7 @@ app_name = 'home'
urlpatterns = [
path('', HomeView.as_view(), name='home'),
path('pages/', include('wiki.urls')),
+ path('accounts/', include('allauth.urls'), name='auth'),
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..5c728283
--- /dev/null
+++ b/pydis_site/apps/staff/models/role_mapping.py
@@ -0,0 +1,24 @@
+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."
+ )
+
+ group = models.OneToOneField(
+ Group,
+ on_delete=models.CASCADE,
+ help_text="The Django permissions group to use for this mapping."
+ )
+
+ def __str__(self):
+ """Returns the mapping, for display purposes."""
+ return f"@{self.role.name} -> {self.group.name}"