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 | 38 | ||||
| -rw-r--r-- | pydis_site/apps/home/forms/__init__.py | 0 | ||||
| -rw-r--r-- | pydis_site/apps/home/forms/account_deletion.py | 10 | ||||
| -rw-r--r-- | pydis_site/apps/home/signals.py | 314 | ||||
| -rw-r--r-- | pydis_site/apps/home/tests/test_signal_listener.py | 458 | ||||
| -rw-r--r-- | pydis_site/apps/home/tests/test_views.py | 213 | ||||
| -rw-r--r-- | pydis_site/apps/home/urls.py | 30 | ||||
| -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 | 37 | ||||
| -rw-r--r-- | pydis_site/apps/home/views/account/settings.py | 59 | ||||
| -rw-r--r-- | pydis_site/apps/staff/admin.py | 6 | ||||
| -rw-r--r-- | pydis_site/apps/staff/migrations/0003_delete_rolemapping.py | 16 | ||||
| -rw-r--r-- | pydis_site/apps/staff/models/__init__.py | 3 | ||||
| -rw-r--r-- | pydis_site/apps/staff/models/role_mapping.py | 31 | 
16 files changed, 20 insertions, 1203 deletions
diff --git a/pydis_site/apps/home/__init__.py b/pydis_site/apps/home/__init__.py index ecfab449..e69de29b 100644 --- a/pydis_site/apps/home/__init__.py +++ b/pydis_site/apps/home/__init__.py @@ -1 +0,0 @@ -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 deleted file mode 100644 index 55a393a9..00000000 --- a/pydis_site/apps/home/apps.py +++ /dev/null @@ -1,38 +0,0 @@ -from typing import Any, Dict - -from django.apps import AppConfig - - -class HomeConfig(AppConfig): -    """Django AppConfig for the home app.""" - -    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/forms/__init__.py b/pydis_site/apps/home/forms/__init__.py deleted file mode 100644 index e69de29b..00000000 --- a/pydis_site/apps/home/forms/__init__.py +++ /dev/null diff --git a/pydis_site/apps/home/forms/account_deletion.py b/pydis_site/apps/home/forms/account_deletion.py deleted file mode 100644 index eec70bea..00000000 --- a/pydis_site/apps/home/forms/account_deletion.py +++ /dev/null @@ -1,10 +0,0 @@ -from django.forms import CharField, Form - - -class AccountDeletionForm(Form): -    """Account deletion form, to collect username for confirmation of removal.""" - -    username = CharField( -        label="Username", -        required=True -    ) diff --git a/pydis_site/apps/home/signals.py b/pydis_site/apps/home/signals.py deleted file mode 100644 index 8af48c15..00000000 --- a/pydis_site/apps/home/signals.py +++ /dev/null @@ -1,314 +0,0 @@ -from contextlib import suppress -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_delete, post_save, 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) - -        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) -        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, -        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__id__in=discord_user.roles).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. - -        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 DiscordUser.objects.filter(roles__contains=[instance.role.id])) -        ) - -        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__id__in=discord_user.roles -                ).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. - -        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 - -        # 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 current_groups: -                # They do have groups, so let's remove them -                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: -                try: -                    mapping = mappings.get(role__id=role) -                except RoleMapping.DoesNotExist: -                    continue  # No mapping exists - -                new_groups.append(mapping.group) - -                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 deleted file mode 100644 index d99d81a5..00000000 --- a/pydis_site/apps/home/tests/test_signal_listener.py +++ /dev/null @@ -1,458 +0,0 @@ -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, -            is_staff=True -        ) - -        cls.moderator_mapping = RoleMapping.objects.create( -            role=cls.moderator_role, -            group=cls.moderator_group, -            is_staff=False -        ) - -        cls.discord_user = DiscordUser.objects.create( -            id=0, -            name="user", -            discriminator=0, -        ) - -        cls.discord_unmapped = DiscordUser.objects.create( -            id=2, -            name="unmapped", -            discriminator=0, -        ) - -        cls.discord_unmapped.roles.append(cls.unmapped_role.id) -        cls.discord_unmapped.save() - -        cls.discord_not_in_guild = DiscordUser.objects.create( -            id=3, -            name="not-in-guild", -            discriminator=0, -            in_guild=False -        ) - -        cls.discord_admin = DiscordUser.objects.create( -            id=1, -            name="admin", -            discriminator=0, -        ) - -        cls.discord_admin.roles = [cls.admin_role.id] -        cls.discord_admin.save() - -        cls.discord_moderator = DiscordUser.objects.create( -            id=4, -            name="admin", -            discriminator=0, -        ) - -        cls.discord_moderator.roles = [cls.moderator_role.id] -        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=False, -            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.append(self.admin_role.id) -        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.append(self.moderator_role.id) -        self.discord_moderator.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 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 -        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, -            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) - -        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 572317a7..bd1671b1 100644 --- a/pydis_site/apps/home/tests/test_views.py +++ b/pydis_site/apps/home/tests/test_views.py @@ -1,198 +1,5 @@ -from allauth.socialaccount.models import SocialAccount -from django.contrib.auth.models import User -from django.http import HttpResponseRedirect  from django.test import TestCase -from django_hosts.resolvers import get_host, reverse, reverse_host - - -def check_redirect_url( -        response: HttpResponseRedirect, reversed_url: str, strip_params=True -) -> bool: -    """ -    Check whether a given redirect response matches a specific reversed URL. - -    Arguments: -    * `response`: The HttpResponseRedirect returned by the test client -    * `reversed_url`: The URL returned by `reverse()` -    * `strip_params`: Whether to strip URL parameters (following a "?") from the URL given in the -                     `response` object -    """ -    host = get_host(None) -    hostname = reverse_host(host) - -    redirect_url = response.url - -    if strip_params and "?" in redirect_url: -        redirect_url = redirect_url.split("?", 1)[0] - -    result = reversed_url == f"//{hostname}{redirect_url}" -    return result - - -class TestAccountDeleteView(TestCase): -    def setUp(self) -> None: -        """Create an authorized Django user for testing purposes.""" -        self.user = User.objects.create( -            username="user#0000" -        ) - -    def test_redirect_when_logged_out(self): -        """Test that the user is redirected to the homepage when not logged in.""" -        url = reverse("account_delete") -        resp = self.client.get(url) -        self.assertEqual(resp.status_code, 302) -        self.assertTrue(check_redirect_url(resp, reverse("home"))) - -    def test_get_when_logged_in(self): -        """Test that the view returns a HTTP 200 when the user is logged in.""" -        url = reverse("account_delete") - -        self.client.force_login(self.user) -        resp = self.client.get(url) -        self.client.logout() - -        self.assertEqual(resp.status_code, 200) - -    def test_post_invalid(self): -        """Test that the user is redirected when the form is filled out incorrectly.""" -        url = reverse("account_delete") - -        self.client.force_login(self.user) - -        resp = self.client.post(url, {}) -        self.assertEqual(resp.status_code, 302) -        self.assertTrue(check_redirect_url(resp, url)) - -        resp = self.client.post(url, {"username": "user"}) -        self.assertEqual(resp.status_code, 302) -        self.assertTrue(check_redirect_url(resp, url)) - -        self.client.logout() - -    def test_post_valid(self): -        """Test that the account is deleted when the form is filled out correctly..""" -        url = reverse("account_delete") - -        self.client.force_login(self.user) - -        resp = self.client.post(url, {"username": "user#0000"}) -        self.assertEqual(resp.status_code, 302) -        self.assertTrue(check_redirect_url(resp, reverse("home"))) - -        with self.assertRaises(User.DoesNotExist): -            User.objects.get(username=self.user.username) - -        self.client.logout() - - -class TestAccountSettingsView(TestCase): -    def setUp(self) -> None: -        """Create an authorized Django user for testing purposes.""" -        self.user = User.objects.create( -            username="user#0000" -        ) - -        self.user_unlinked = User.objects.create( -            username="user#9999" -        ) - -        self.user_unlinked_discord = User.objects.create( -            username="user#1234" -        ) - -        self.user_unlinked_github = User.objects.create( -            username="user#1111" -        ) - -        self.github_account = SocialAccount.objects.create( -            user=self.user, -            provider="github", -            uid="0" -        ) - -        self.discord_account = SocialAccount.objects.create( -            user=self.user, -            provider="discord", -            uid="0000" -        ) - -        self.github_account_secondary = SocialAccount.objects.create( -            user=self.user_unlinked_discord, -            provider="github", -            uid="1" -        ) - -        self.discord_account_secondary = SocialAccount.objects.create( -            user=self.user_unlinked_github, -            provider="discord", -            uid="1111" -        ) - -    def test_redirect_when_logged_out(self): -        """Check that the user is redirected to the homepage when not logged in.""" -        url = reverse("account_settings") -        resp = self.client.get(url) -        self.assertEqual(resp.status_code, 302) -        self.assertTrue(check_redirect_url(resp, reverse("home"))) - -    def test_get_when_logged_in(self): -        """Test that the view returns a HTTP 200 when the user is logged in.""" -        url = reverse("account_settings") - -        self.client.force_login(self.user) -        resp = self.client.get(url) -        self.client.logout() - -        self.assertEqual(resp.status_code, 200) - -        self.client.force_login(self.user_unlinked) -        resp = self.client.get(url) -        self.client.logout() - -        self.assertEqual(resp.status_code, 200) - -        self.client.force_login(self.user_unlinked_discord) -        resp = self.client.get(url) -        self.client.logout() - -        self.assertEqual(resp.status_code, 200) - -        self.client.force_login(self.user_unlinked_github) -        resp = self.client.get(url) -        self.client.logout() - -        self.assertEqual(resp.status_code, 200) - -    def test_post_invalid(self): -        """Test the behaviour of invalid POST submissions.""" -        url = reverse("account_settings") - -        self.client.force_login(self.user_unlinked) - -        resp = self.client.post(url, {"provider": "discord"}) -        self.assertEqual(resp.status_code, 302) -        self.assertTrue(check_redirect_url(resp, reverse("home"))) - -        resp = self.client.post(url, {"provider": "github"}) -        self.assertEqual(resp.status_code, 302) -        self.assertTrue(check_redirect_url(resp, reverse("home"))) - -        self.client.logout() - -    def test_post_valid(self): -        """Ensure that GitHub is unlinked with a valid POST submission.""" -        url = reverse("account_settings") - -        self.client.force_login(self.user) - -        resp = self.client.post(url, {"provider": "github"}) -        self.assertEqual(resp.status_code, 302) -        self.assertTrue(check_redirect_url(resp, reverse("home"))) - -        with self.assertRaises(SocialAccount.DoesNotExist): -            SocialAccount.objects.get(user=self.user, provider="github") - -        self.client.logout() +from django_hosts.resolvers import reverse  class TestIndexReturns200(TestCase): @@ -201,21 +8,3 @@ class TestIndexReturns200(TestCase):          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) -        self.assertTrue(check_redirect_url(resp, reverse("home"))) - - -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) -        self.assertTrue(check_redirect_url(resp, reverse("home"))) diff --git a/pydis_site/apps/home/urls.py b/pydis_site/apps/home/urls.py index 5a58e002..024437f7 100644 --- a/pydis_site/apps/home/urls.py +++ b/pydis_site/apps/home/urls.py @@ -1,36 +1,10 @@ -from allauth.account.views import LogoutView  from django.contrib import admin -from django.contrib.messages import ERROR -from django.urls import include, path +from django.urls import path -from pydis_site.utils.views import MessageRedirectView -from .views import AccountDeleteView, AccountSettingsView, HomeView +from .views import HomeView  app_name = 'home'  urlpatterns = [ -    # We do this twice because Allauth expects specific view names to exist      path('', HomeView.as_view(), name='home'), -    path('', HomeView.as_view(), name='socialaccount_connections'), - -    path('accounts/', include('allauth.socialaccount.providers.discord.urls')), -    path('accounts/', include('allauth.socialaccount.providers.github.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('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 801fd398..971d73a3 100644 --- a/pydis_site/apps/home/views/__init__.py +++ b/pydis_site/apps/home/views/__init__.py @@ -1,4 +1,3 @@ -from .account import DeleteView as AccountDeleteView, SettingsView as AccountSettingsView  from .home import HomeView -__all__ = ["AccountDeleteView", "AccountSettingsView", "HomeView"] +__all__ = ["HomeView"] diff --git a/pydis_site/apps/home/views/account/__init__.py b/pydis_site/apps/home/views/account/__init__.py deleted file mode 100644 index 3b3250ea..00000000 --- a/pydis_site/apps/home/views/account/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index 798b8a33..00000000 --- a/pydis_site/apps/home/views/account/delete.py +++ /dev/null @@ -1,37 +0,0 @@ -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.messages import ERROR, INFO, add_message -from django.http import HttpRequest, HttpResponse -from django.shortcuts import redirect, render -from django.urls import reverse -from django.views import View - -from pydis_site.apps.home.forms.account_deletion import AccountDeletionForm - - -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", -            context={"form": AccountDeletionForm()} -        ) - -    def post(self, request: HttpRequest) -> HttpResponse: -        """HTTP POST: Process the deletion, as requested by the user.""" -        form = AccountDeletionForm(request.POST) - -        if not form.is_valid() or request.user.username != form.cleaned_data["username"]: -            add_message(request, ERROR, "Please enter your username exactly as shown.") - -            return redirect(reverse("account_delete")) - -        request.user.delete() -        add_message(request, INFO, "Your account has been deleted.") - -        return redirect(reverse("home")) diff --git a/pydis_site/apps/home/views/account/settings.py b/pydis_site/apps/home/views/account/settings.py deleted file mode 100644 index 3a817dbc..00000000 --- a/pydis_site/apps/home/views/account/settings.py +++ /dev/null @@ -1,59 +0,0 @@ -from allauth.socialaccount.models import SocialAccount -from allauth.socialaccount.providers import registry -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.messages import ERROR, INFO, add_message -from django.http import HttpRequest, HttpResponse -from django.shortcuts import redirect, 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. - -    This view actually renders a template with a bare modal, and is intended to be -    inserted into another template using JavaScript. -    """ - -    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.""" -        context = { -            "groups": request.user.groups.all(), - -            "discord": None, -            "github": None, - -            "discord_provider": registry.provider_map.get("discord"), -            "github_provider": registry.provider_map.get("github"), -        } - -        for account in SocialAccount.objects.filter(user=request.user).all(): -            if account.provider == "discord": -                context["discord"] = account - -            if account.provider == "github": -                context["github"] = account - -        return render(request, "home/account/settings.html", context=context) - -    def post(self, request: HttpRequest) -> HttpResponse: -        """HTTP POST: Process account disconnections.""" -        provider = request.POST["provider"] - -        if provider == "github": -            try: -                account = SocialAccount.objects.get(user=request.user, provider=provider) -            except SocialAccount.DoesNotExist: -                add_message(request, ERROR, "You do not have a GitHub account linked.") -            else: -                account.delete() -                add_message(request, INFO, "The social account has been disconnected.") -        else: -            add_message(request, ERROR, f"Unknown provider: {provider}") - -        return redirect(reverse("home")) diff --git a/pydis_site/apps/staff/admin.py b/pydis_site/apps/staff/admin.py deleted file mode 100644 index 94cd83c5..00000000 --- a/pydis_site/apps/staff/admin.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.contrib import admin - -from .models import RoleMapping - - -admin.site.register(RoleMapping) diff --git a/pydis_site/apps/staff/migrations/0003_delete_rolemapping.py b/pydis_site/apps/staff/migrations/0003_delete_rolemapping.py new file mode 100644 index 00000000..e9b6114e --- /dev/null +++ b/pydis_site/apps/staff/migrations/0003_delete_rolemapping.py @@ -0,0 +1,16 @@ +# Generated by Django 3.0.9 on 2020-10-04 17:49 + +from django.db import migrations + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('staff', '0002_add_is_staff_to_role_mappings'), +    ] + +    operations = [ +        migrations.DeleteModel( +            name='RoleMapping', +        ), +    ] diff --git a/pydis_site/apps/staff/models/__init__.py b/pydis_site/apps/staff/models/__init__.py deleted file mode 100644 index b49b6fd0..00000000 --- a/pydis_site/apps/staff/models/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 8a1fac2e..00000000 --- a/pydis_site/apps/staff/models/role_mapping.py +++ /dev/null @@ -1,31 +0,0 @@ -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 -    ) - -    is_staff = models.BooleanField( -        help_text="Whether this role mapping relates to a Django staff group", -        default=False -    ) - -    def __str__(self): -        """Returns the mapping, for display purposes.""" -        return f"@{self.role.name} -> {self.group.name}"  |