diff options
Diffstat (limited to 'pydis_site/apps')
-rw-r--r-- | pydis_site/apps/home/__init__.py | 1 | ||||
-rw-r--r-- | pydis_site/apps/home/apps.py | 9 | ||||
-rw-r--r-- | pydis_site/apps/home/signals.py | 156 | ||||
-rw-r--r-- | pydis_site/apps/home/urls.py | 1 | ||||
-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 | 24 |
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}" |