diff options
Diffstat (limited to 'pydis_site/apps')
| -rw-r--r-- | pydis_site/apps/home/signals.py | 84 | ||||
| -rw-r--r-- | pydis_site/apps/home/tests/test_signal_listener.py | 70 | ||||
| -rw-r--r-- | pydis_site/apps/home/urls.py | 8 | ||||
| -rw-r--r-- | pydis_site/apps/home/views/__init__.py | 3 | ||||
| -rw-r--r-- | pydis_site/apps/home/views/account/__init__.py | 4 | ||||
| -rw-r--r-- | pydis_site/apps/home/views/account/delete.py | 20 | ||||
| -rw-r--r-- | pydis_site/apps/home/views/account/settings.py | 20 | ||||
| -rw-r--r-- | pydis_site/apps/staff/models/role_mapping.py | 5 | 
8 files changed, 191 insertions, 23 deletions
| diff --git a/pydis_site/apps/home/signals.py b/pydis_site/apps/home/signals.py index 9f286882..4cb4564b 100644 --- a/pydis_site/apps/home/signals.py +++ b/pydis_site/apps/home/signals.py @@ -1,3 +1,4 @@ +from contextlib import suppress  from typing import List, Optional, Type  from allauth.account.signals import user_logged_in @@ -8,7 +9,7 @@ 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 django.db.models.signals import post_delete, post_save, pre_save  from pydis_site.apps.api.models import User as DiscordUser  from pydis_site.apps.staff.models import RoleMapping @@ -37,7 +38,7 @@ class AllauthSignalListener:      def __init__(self):          post_save.connect(self.user_model_updated, sender=DiscordUser) -        pre_delete.connect(self.mapping_model_deleted, sender=RoleMapping) +        post_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) @@ -133,13 +134,29 @@ class AllauthSignalListener:          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. +        they shouldn't have assigned to them when a RoleMapping is deleted from the database, +        and to remove their staff status if they should no longer have it.          """          instance: RoleMapping = kwargs["instance"]          for user in instance.group.user_set.all(): +            # Firstly, remove their related user group              user.groups.remove(instance.group) +            with suppress(SocialAccount.DoesNotExist, DiscordUser.DoesNotExist): +                # If we get either exception, then the user could not have been assigned staff +                # with our system in the first place. + +                social_account = SocialAccount.objects.get(user=user, provider=DiscordProvider.id) +                discord_user = DiscordUser.objects.get(id=int(social_account.uid)) + +                mappings = RoleMapping.objects.filter(role__in=discord_user.roles.all()).all() +                is_staff = any(m.is_staff for m in mappings) + +                if user.is_staff != is_staff: +                    user.is_staff = is_staff +                    user.save(update_fields=("is_staff", )) +      def mapping_model_updated(self, sender: Type[RoleMapping], **kwargs) -> None:          """          Processes update signals from the RoleMapping model. @@ -174,6 +191,21 @@ class AllauthSignalListener:          for account in accounts:              account.user.groups.add(instance.group) +            if instance.is_staff and not account.user.is_staff: +                account.user.is_staff = instance.is_staff +                account.user.save(update_fields=("is_staff", )) +            else: +                discord_user = DiscordUser.objects.get(id=int(account.uid)) + +                mappings = RoleMapping.objects.filter( +                    role__in=discord_user.roles.all() +                ).exclude(id=instance.id).all() +                is_staff = any(m.is_staff for m in mappings) + +                if account.user.is_staff != is_staff: +                    account.user.is_staff = is_staff +                    account.user.save(update_fields=("is_staff",)) +      def user_model_updated(self, sender: Type[DiscordUser], **kwargs) -> None:          """          Processes update signals from the Discord User model, assigning perms as required. @@ -230,31 +262,53 @@ class AllauthSignalListener:          except SocialAccount.user.RelatedObjectDoesNotExist:              return  # There's no user account yet, this will be handled by another receiver +        # Ensure that the username on this account is correct +        new_username = f"{user.name}#{user.discriminator}" + +        if account.user.username != new_username: +            account.user.username = new_username +            account.user.first_name = new_username +          if not user.in_guild:              deletion = True          if deletion:              # They've unlinked Discord or left the server, so we have to remove their groups +            # and their staff status -            if not current_groups: -                return  # They have no groups anyway, no point in processing +            if current_groups: +                # They do have groups, so let's remove them +                account.user.groups.remove( +                    *(mapping.group for mapping in mappings) +                ) -            account.user.groups.remove( -                *(mapping.group for mapping in mappings) -            ) +            if account.user.is_staff: +                # They're marked as a staff user and they shouldn't be, so let's fix that +                account.user.is_staff = False          else:              new_groups = [] +            is_staff = False              for role in user.roles.all():                  try: -                    new_groups.append(mappings.get(role=role).group) +                    mapping = mappings.get(role=role)                  except RoleMapping.DoesNotExist:                      continue  # No mapping exists -                account.user.groups.add( -                    *[group for group in new_groups if group not in current_groups] -                ) +                new_groups.append(mapping.group) -                account.user.groups.remove( -                    *[mapping.group for mapping in mappings if mapping.group not in new_groups] -                ) +                if mapping.is_staff: +                    is_staff = True + +            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] +            ) + +            if account.user.is_staff != is_staff: +                account.user.is_staff = is_staff + +        account.user.save() diff --git a/pydis_site/apps/home/tests/test_signal_listener.py b/pydis_site/apps/home/tests/test_signal_listener.py index 27fc7710..66a67252 100644 --- a/pydis_site/apps/home/tests/test_signal_listener.py +++ b/pydis_site/apps/home/tests/test_signal_listener.py @@ -67,12 +67,14 @@ class SignalListenerTests(TestCase):          cls.admin_mapping = RoleMapping.objects.create(              role=cls.admin_role, -            group=cls.admin_group +            group=cls.admin_group, +            is_staff=True          )          cls.moderator_mapping = RoleMapping.objects.create(              role=cls.moderator_role, -            group=cls.moderator_group +            group=cls.moderator_group, +            is_staff=False          )          cls.discord_user = DiscordUser.objects.create( @@ -166,7 +168,7 @@ class SignalListenerTests(TestCase):          cls.django_moderator = DjangoUser.objects.create(              username="moderator", -            is_staff=True, +            is_staff=False,              is_superuser=False          ) @@ -339,6 +341,33 @@ class SignalListenerTests(TestCase):          self.discord_admin.roles.add(self.admin_role)          self.discord_admin.save() +    def test_apply_groups_moderator(self): +        """Test application of groups by role, relating to a non-`is_staff` moderator user.""" +        handler = AllauthSignalListener() + +        self.assertEqual(self.django_user_discordless.groups.all().count(), 0) + +        # Apply groups based on moderator role being present on Discord +        handler._apply_groups(self.discord_moderator, self.social_moderator) +        self.assertTrue(self.moderator_group in self.django_moderator.groups.all()) + +        # Remove groups based on the user apparently leaving the server +        handler._apply_groups(self.discord_moderator, self.social_moderator, True) +        self.assertEqual(self.django_user_discordless.groups.all().count(), 0) + +        # Apply the moderator role again +        handler._apply_groups(self.discord_moderator, self.social_moderator) + +        # Remove all of the roles from the user +        self.discord_moderator.roles.clear() + +        # Remove groups based on the user no longer having the moderator role on Discord +        handler._apply_groups(self.discord_moderator, self.social_moderator) +        self.assertEqual(self.django_user_discordless.groups.all().count(), 0) + +        self.discord_moderator.roles.add(self.moderator_role) +        self.discord_moderator.save() +      def test_apply_groups_other(self):          """Test application of groups by role, relating to non-standard cases."""          handler = AllauthSignalListener() @@ -373,10 +402,25 @@ class SignalListenerTests(TestCase):          self.assertEqual(self.django_moderator.groups.all().count(), 1)          self.assertEqual(self.django_admin.groups.all().count(), 1) +        # Test is_staff changes +        self.admin_mapping.is_staff = False +        self.admin_mapping.save() + +        self.assertFalse(self.django_moderator.is_staff) +        self.assertFalse(self.django_admin.is_staff) + +        self.admin_mapping.is_staff = True +        self.admin_mapping.save() + +        self.django_admin.refresh_from_db(fields=("is_staff", )) +        self.assertTrue(self.django_admin.is_staff) +          # Test mapping deletion          self.admin_mapping.delete() +        self.django_admin.refresh_from_db(fields=("is_staff",))          self.assertEqual(self.django_admin.groups.all().count(), 0) +        self.assertFalse(self.django_admin.is_staff)          # Test mapping update          self.moderator_mapping.group = self.admin_group @@ -388,12 +432,30 @@ class SignalListenerTests(TestCase):          # Test mapping creation          new_mapping = RoleMapping.objects.create(              role=self.admin_role, -            group=self.moderator_group +            group=self.moderator_group, +            is_staff=True +        ) + +        self.assertEqual(self.django_admin.groups.all().count(), 1) +        self.assertTrue(self.moderator_group in self.django_admin.groups.all()) + +        self.django_admin.refresh_from_db(fields=("is_staff",)) +        self.assertTrue(self.django_admin.is_staff) + +        new_mapping.delete() + +        # Test mapping creation (without is_staff) +        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()) +        self.django_admin.refresh_from_db(fields=("is_staff",)) +        self.assertFalse(self.django_admin.is_staff) +          # Test that nothing happens when fixtures are loaded          pre_save.send(RoleMapping, instance=new_mapping, raw=True) diff --git a/pydis_site/apps/home/urls.py b/pydis_site/apps/home/urls.py index 211a7ad1..70a41177 100644 --- a/pydis_site/apps/home/urls.py +++ b/pydis_site/apps/home/urls.py @@ -1,5 +1,4 @@  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 @@ -7,7 +6,7 @@ from django.contrib.messages import ERROR  from django.urls import include, path  from pydis_site.utils.views import MessageRedirectView -from .views import HomeView +from .views import AccountDeleteView, AccountSettingsView, HomeView  app_name = 'home'  urlpatterns = [ @@ -15,6 +14,7 @@ urlpatterns = [      path('pages/', include('wiki.urls')),      path('accounts/', include('allauth.socialaccount.providers.discord.urls')), +    path('accounts/', include('allauth.socialaccount.providers.github.urls')),      path(          'accounts/login/cancelled', MessageRedirectView.as_view( @@ -28,7 +28,9 @@ urlpatterns = [          ), name='socialaccount_login_error'      ), -    path('connections', ConnectionsView.as_view()), +    path('accounts/settings', AccountSettingsView.as_view(), name="account_settings"), +    path('accounts/delete', AccountDeleteView.as_view(), name="account_delete"), +      path('logout', LogoutView.as_view(), name="logout"),      path('admin/', admin.site.urls), diff --git a/pydis_site/apps/home/views/__init__.py b/pydis_site/apps/home/views/__init__.py index 971d73a3..801fd398 100644 --- a/pydis_site/apps/home/views/__init__.py +++ b/pydis_site/apps/home/views/__init__.py @@ -1,3 +1,4 @@ +from .account import DeleteView as AccountDeleteView, SettingsView as AccountSettingsView  from .home import HomeView -__all__ = ["HomeView"] +__all__ = ["AccountDeleteView", "AccountSettingsView", "HomeView"] diff --git a/pydis_site/apps/home/views/account/__init__.py b/pydis_site/apps/home/views/account/__init__.py new file mode 100644 index 00000000..3b3250ea --- /dev/null +++ b/pydis_site/apps/home/views/account/__init__.py @@ -0,0 +1,4 @@ +from .delete import DeleteView +from .settings import SettingsView + +__all__ = ["DeleteView", "SettingsView"] diff --git a/pydis_site/apps/home/views/account/delete.py b/pydis_site/apps/home/views/account/delete.py new file mode 100644 index 00000000..f80089d5 --- /dev/null +++ b/pydis_site/apps/home/views/account/delete.py @@ -0,0 +1,20 @@ +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import HttpRequest, HttpResponse +from django.shortcuts import render +from django.urls import reverse +from django.views import View + + +class DeleteView(LoginRequiredMixin, View): +    """Account deletion view, for removing linked user accounts from the DB.""" + +    def __init__(self, *args, **kwargs): +        self.login_url = reverse("home") +        super().__init__(*args, **kwargs) + +    def get(self, request: HttpRequest) -> HttpResponse: +        """HTTP GET: Return the view template.""" +        return render(request, "home/account/delete.html") + +    def post(self, request: HttpRequest) -> HttpResponse: +        """HTTP POST: Process the deletion, as requested by the user.""" diff --git a/pydis_site/apps/home/views/account/settings.py b/pydis_site/apps/home/views/account/settings.py new file mode 100644 index 00000000..aa272552 --- /dev/null +++ b/pydis_site/apps/home/views/account/settings.py @@ -0,0 +1,20 @@ +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import HttpRequest, HttpResponse +from django.shortcuts import render +from django.urls import reverse +from django.views import View + + +class SettingsView(LoginRequiredMixin, View): +    """Account settings view, for managing and deleting user accounts and connections.""" + +    def __init__(self, *args, **kwargs): +        self.login_url = reverse("home") +        super().__init__(*args, **kwargs) + +    def get(self, request: HttpRequest) -> HttpResponse: +        """HTTP GET: Return the view template.""" +        return render(request, "home/account/settings.html") + +    def post(self, request: HttpRequest) -> HttpResponse: +        """HTTP POST: Process account changes, as requested by the user.""" diff --git a/pydis_site/apps/staff/models/role_mapping.py b/pydis_site/apps/staff/models/role_mapping.py index 10c09cf1..dff8081a 100644 --- a/pydis_site/apps/staff/models/role_mapping.py +++ b/pydis_site/apps/staff/models/role_mapping.py @@ -21,6 +21,11 @@ class RoleMapping(models.Model):          unique=True,  # Unique in order to simplify group assignment logic      ) +    is_staff = models.BooleanField( +        help_text="Whether this role mapping related to a Django staff group", +        default=False +    ) +      def __str__(self):          """Returns the mapping, for display purposes."""          return f"@{self.role.name} -> {self.group.name}" | 
