aboutsummaryrefslogtreecommitdiffstats
path: root/pydis_site/apps/home/signals.py
diff options
context:
space:
mode:
authorGravatar Sebastiaan Zeeff <[email protected]>2019-10-18 12:34:09 +0200
committerGravatar Sebastiaan Zeeff <[email protected]>2019-10-18 12:34:09 +0200
commit6670a3ba48dad0b2e6e79d77d780c5ee77773e3e (patch)
tree30fdc507353e902f194fa84bfcb7516ea72903fd /pydis_site/apps/home/signals.py
parentPrevent double active infractions with constraint (diff)
parentAdd Code of Conduct to navbar submenu (diff)
Merge branch 'master' into active-infractions-validation
Diffstat (limited to 'pydis_site/apps/home/signals.py')
-rw-r--r--pydis_site/apps/home/signals.py260
1 files changed, 260 insertions, 0 deletions
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]
+ )