diff options
31 files changed, 21 insertions, 1975 deletions
diff --git a/.mdlrc b/.mdlrc deleted file mode 100644 index 0c02cde4..00000000 --- a/.mdlrc +++ /dev/null @@ -1 +0,0 @@ -rules '~MD024' @@ -16,7 +16,6 @@ whitenoise = "~=5.0" requests = "~=2.21" pyyaml = "~=5.1" pyuwsgi = {version = "~=2.0", sys_platform = "!='win32'"} -django-allauth = "~=0.41" sentry-sdk = "~=0.14" gitpython = "~=3.1.7" diff --git a/Pipfile.lock b/Pipfile.lock index 51ca9a65..8c91e0db 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "6a5a945e018186c05a5e544d2a09db8b480a2d5dcce2bba8ccbc7b91a3e5fc69" + "sha256": "07f534656de1dd12baafc39bcdb68f143bbe195f8220211724427e15244dc187" }, "pipfile-spec": 6, "requires": { @@ -38,14 +38,6 @@ ], "version": "==3.0.4" }, - "defusedxml": { - "hashes": [ - "sha256:6687150770438374ab581bb7a1b327a847dd9c5749e396102de3fad4e8a3ef93", - "sha256:f684034d135af4c6cbb949b8a4d2ed61634515257a67299e5f940fbaa34377f5" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==0.6.0" - }, "django": { "hashes": [ "sha256:2d14be521c3ae24960e5e83d4575e156a8c479a75c935224b671b1c6e66eddaf", @@ -54,13 +46,6 @@ "index": "pypi", "version": "==3.0.10" }, - "django-allauth": { - "hashes": [ - "sha256:f17209410b7f87da0a84639fd79d3771b596a6d3fc1a8e48ce50dabc7f441d30" - ], - "index": "pypi", - "version": "==0.42.0" - }, "django-environ": { "hashes": [ "sha256:6c9d87660142608f63ec7d5ce5564c49b603ea8ff25da595fd6098f6dc82afde", @@ -150,14 +135,6 @@ ], "version": "==0.20.1" }, - "oauthlib": { - "hashes": [ - "sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889", - "sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==3.1.0" - }, "psycopg2-binary": { "hashes": [ "sha256:0deac2af1a587ae12836aa07970f5cb91964f05a7c6cdb69d8425ff4c15d4e2c", @@ -196,13 +173,6 @@ "index": "pypi", "version": "==2.8.6" }, - "python3-openid": { - "hashes": [ - "sha256:33fbf6928f401e0b790151ed2b5290b02545e8775f982485205a066f874aaeaf", - "sha256:6626f771e0417486701e0b4daff762e7212e820ca5b29fcc0d05f6f8736dfa6b" - ], - "version": "==3.2.0" - }, "pytz": { "hashes": [ "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed", @@ -254,14 +224,6 @@ "index": "pypi", "version": "==2.24.0" }, - "requests-oauthlib": { - "hashes": [ - "sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d", - "sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a", - "sha256:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc" - ], - "version": "==1.3.0" - }, "sentry-sdk": { "hashes": [ "sha256:1d91a0059d2d8bb980bec169578035c2f2d4b93cd8a4fb5b85c81904d33e221a", diff --git a/pydis_site/__init__.py b/pydis_site/__init__.py index c15c59c8..e69de29b 100644 --- a/pydis_site/__init__.py +++ b/pydis_site/__init__.py @@ -1,4 +0,0 @@ -# Empty list of validators for Allauth to ponder over. This is referred to in settings.py -# by a string because Allauth won't let us just give it a list _there_, we have to point -# at a list _somewhere else_ instead. -VALIDATORS = [] 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}" diff --git a/pydis_site/settings.py b/pydis_site/settings.py index cbfa2fe3..c7c17bb0 100644 --- a/pydis_site/settings.py +++ b/pydis_site/settings.py @@ -16,7 +16,6 @@ import sys import environ import sentry_sdk -from django.contrib.messages import constants as messages from sentry_sdk.integrations.django import DjangoIntegration from pydis_site.constants import GIT_SHA @@ -93,13 +92,6 @@ INSTALLED_APPS = [ 'django.contrib.sites', 'django.contrib.staticfiles', - 'allauth', - 'allauth.account', - 'allauth.socialaccount', - - 'allauth.socialaccount.providers.discord', - 'allauth.socialaccount.providers.github', - 'django_hosts', 'django_filters', 'django_simple_bulma', @@ -264,22 +256,10 @@ LOGGING = { } } -# Django Messages framework config -MESSAGE_TAGS = { - messages.DEBUG: 'primary', - messages.INFO: 'info', - messages.SUCCESS: 'success', - messages.WARNING: 'warning', - messages.ERROR: 'danger', -} - # Custom settings for django-simple-bulma BULMA_SETTINGS = { "variables": { # If you update these colours, please update the notification.css file "primary": "#7289DA", # Discord blurple - - # "orange": "", # Apparently unused, but the default is fine - # "yellow": "", # The default yellow looks pretty good "green": "#32ac66", # Colour picked after Discord discussion "turquoise": "#7289DA", # Blurple, because Bulma uses this regardless of `primary` above "blue": "#2482c1", # Colour picked after Discord discussion @@ -294,23 +274,3 @@ BULMA_SETTINGS = { "footer-padding": "1rem 1.5rem 1rem", } } - -# Django Allauth stuff -AUTHENTICATION_BACKENDS = ( - # Needed to login by username in Django admin, regardless of `allauth` - 'django.contrib.auth.backends.ModelBackend', - - # `allauth` specific authentication methods, such as login by e-mail - 'allauth.account.auth_backends.AuthenticationBackend', -) - -ACCOUNT_ADAPTER = "pydis_site.utils.account.AccountAdapter" -ACCOUNT_EMAIL_REQUIRED = False # Undocumented allauth setting; don't require emails -ACCOUNT_EMAIL_VERIFICATION = "none" # No verification required; we don't use emails for anything - -# We use this validator because Allauth won't let us actually supply a list with no validators -# in it, and we can't just give it a lambda - that'd be too easy, I suppose. -ACCOUNT_USERNAME_VALIDATORS = "pydis_site.VALIDATORS" - -LOGIN_REDIRECT_URL = "home" -SOCIALACCOUNT_ADAPTER = "pydis_site.utils.account.SocialAccountAdapter" diff --git a/pydis_site/static/css/base/notification.css b/pydis_site/static/css/base/notification.css deleted file mode 100644 index b2824641..00000000 --- a/pydis_site/static/css/base/notification.css +++ /dev/null @@ -1,99 +0,0 @@ -/* On-page message styling */ - -@keyframes message-slide-in { - 0% { - transform: translateX(100%); - } - - 100% { - transform: translateX(0); - } -} - -div.messages { - animation: 0.5s ease-out 0s 1 message-slide-in; - padding: 0.5rem; - position: fixed; - right: 0; - top: 76px; - - z-index: 1000; /* On top of everything else */ -} - -/* Discord light theme inspired notifications */ - -.messages .notification { - background-color: #fdfdfd; /* Discord embed background */ - border: #eeeeee 1px solid; /* Discord embed border */ - border-left: #4f545c 4px solid; /* Discord default embed colour */ - color: #4a4a4a; /* Bulma default colour */ -} - -.messages .notification.is-primary { - background-color: #fdfdfd; - border-left-color: #7289DA; - color: #4a4a4a; /* Bulma default colour */ -} - -.messages .notification.is-info { - background-color: #fdfdfd; - border-left-color: #1c8ad3; /* Bulma default colour */ - color: #4a4a4a; /* Bulma default colour */ -} - -.messages .notification.is-success { - background-color: #fdfdfd; - border-left-color: #21c65c; - color: #4a4a4a; /* Bulma default colour */ -} - -.messages .notification.is-warning { - background-color: #fdfdfd; - border-left-color: #ffdd57; /* Bulma default colour */ - color: #4a4a4a; /* Bulma default colour */ -} - -.messages .notification.is-danger { - background-color: #fdfdfd; - border-left-color: #ff3860; /* Bulma default colour */ - color: #4a4a4a; /* Bulma default colour */ -} - -/* Discord dark theme inspired notifications */ - -.messages .notification.is-dark { - background-color: #33353C; /* Discord embed background */ - border: #36393f 1px solid; /* Discord embed border */ - border-left: #4f545c 4px solid; /* Discord default embed colour */ - color: #fff; /* Bulma default colour */ -} - -.messages .notification.is-dark.is-primary { - background-color: #33353C; - border-left-color: #7289DA; - color: #fff; /* Bulma default colour */ -} - -.messages .notification.is-dark.is-info { - background-color: #33353C; - border-left-color: #1c8ad3; /* Bulma default colour */ - color: #fff; /* Bulma default colour */ -} - -.messages .notification.is-dark.is-success { - background-color: #33353C; - border-left-color: #21c65c; - color: #fff; /* Bulma default colour */ -} - -.messages .notification.is-dark.is-warning { - background-color: #33353C; - border-left-color: #ffdd57; /* Bulma default colour */ - color: #fff; /* Bulma default colour */ -} - -.messages .notification.is-dark.is-danger { - background-color: #33353C; - border-left-color: #ff3860; /* Bulma default colour */ - color: #fff; /* Bulma default colour */ -} diff --git a/pydis_site/static/js/base/modal.js b/pydis_site/static/js/base/modal.js deleted file mode 100644 index eccc8845..00000000 --- a/pydis_site/static/js/base/modal.js +++ /dev/null @@ -1,100 +0,0 @@ -/* - modal.js: A simple way to wire up Bulma modals. - - This library is intended to be used with Bulma's modals, as described in the - official Bulma documentation. It's based on the JavaScript that Bulma - themselves use for this purpose on the modals documentation page. - - Note that, just like that piece of JavaScript, this library assumes that - you will only ever want to have one modal open at once. - */ - -"use strict"; - -// Event handler for the "esc" key, for closing modals. - -document.addEventListener("keydown", (event) => { - const e = event || window.event; - - if (e.code === "Escape" || e.keyCode === 27) { - closeModals(); - } -}); - -// An array of all the modal buttons we've already set up - -const modal_buttons = []; - -// Public API functions - -function setupModal(target) { - // Set up a modal's events, given a DOM element. This can be - // used later in order to set up a modal that was added after - // this library has been run. - - // We need to collect a bunch of elements to work with - const modal_background = Array.from(target.getElementsByClassName("modal-background")); - const modal_close = Array.from(target.getElementsByClassName("modal-close")); - - const modal_head = Array.from(target.getElementsByClassName("modal-card-head")); - const modal_foot = Array.from(target.getElementsByClassName("modal-card-foot")); - - const modal_delete = []; - const modal_button = []; - - modal_head.forEach((element) => modal_delete.concat(Array.from(element.getElementsByClassName("delete")))); - modal_foot.forEach((element) => modal_button.concat(Array.from(element.getElementsByClassName("button")))); - - // Collect all the elements that can be used to close modals - const modal_closers = modal_background.concat(modal_close).concat(modal_delete).concat(modal_button); - - // Assign click events for closing modals - modal_closers.forEach((element) => { - element.addEventListener("click", () => { - closeModals(); - }); - }); - - setupOpeningButtons(); -} - -function setupOpeningButtons() { - // Wire up all the opening buttons, avoiding buttons we've already wired up. - const modal_opening_buttons = Array.from(document.getElementsByClassName("modal-button")); - - modal_opening_buttons.forEach((element) => { - if (!modal_buttons.includes(element)) { - element.addEventListener("click", () => { - openModal(element.dataset.target); - }); - - modal_buttons.push(element); - } - }); -} - -function openModal(target) { - // Open a modal, given a string ID - const element = document.getElementById(target); - - document.documentElement.classList.add("is-clipped"); - element.classList.add("is-active"); -} - -function closeModals() { - // Close all open modals - const modals = Array.from(document.getElementsByClassName("modal")); - document.documentElement.classList.remove("is-clipped"); - - modals.forEach((element) => { - element.classList.remove("is-active"); - }); -} - -(function () { - // Set up all the modals currently on the page - const modals = Array.from(document.getElementsByClassName("modal")); - - modals.forEach((modal) => setupModal(modal)); - setupOpeningButtons(); -}()); diff --git a/pydis_site/templates/base/base.html b/pydis_site/templates/base/base.html index 905d408c..6fc0c6bb 100644 --- a/pydis_site/templates/base/base.html +++ b/pydis_site/templates/base/base.html @@ -30,7 +30,6 @@ <script src="{% static "js/base/modal.js" %}"></script> <link rel="stylesheet" href="{% static "css/base/base.css" %}"> - <link rel="stylesheet" href="{% static "css/base/notification.css" %}"> {% block head %}{% endblock %} </head> @@ -38,18 +37,6 @@ <!-- Git hash for this release: {{ git_sha }} --> <main class="site-content"> - {% if messages %} - <div class="messages"> - {% for message in messages %} - <div class="notification {% if message.tags %}is-{{ message.tags }}{% endif %}"> - <button class="delete"></button> - - {{ message }} - </div> - {% endfor %} - </div> - {% endif %} - {% block content %} {{ block.super }} {% endblock %} diff --git a/pydis_site/templates/base/navbar.html b/pydis_site/templates/base/navbar.html index dd68949b..6c8d52a1 100644 --- a/pydis_site/templates/base/navbar.html +++ b/pydis_site/templates/base/navbar.html @@ -1,4 +1,3 @@ -{% load socialaccount %} {% load static %} <nav class="navbar is-primary" role="navigation" aria-label="main navigation"> @@ -93,33 +92,6 @@ <a class="navbar-item" href="#"> All events </a> - <hr class="navbar-divider"> - - {% if not user.is_authenticated %} - {% get_providers as socialaccount_providers %} - - {% for provider in socialaccount_providers %} - {% if provider.id == "discord" %} - <a class="navbar-item" - href="{% provider_login_url provider.id process="login" scope=scope auth_params=auth_params %}" - >Login with {{ provider.name }}</a> - {% endif %} - {% endfor %} - {% else %} - <form method="post" action="{% url 'logout' %}"> - {% csrf_token %} - - <div class="field navbar-item is-paddingless is-fullwidth is-grouped"> - <button type="submit" class="button is-white is-inline is-fullwidth has-text-left is-size-navbar-menu has-text-grey-dark">Logout</button> - <a title="Settings" class="button is-white is-inline has-text-right is-size-navbar-menu has-text-grey-dark modal-button" data-target="account-modal"> - <span class="is-icon"> - <i class="fas fa-cog"></i> - </span> - </a> - </div> - </form> - {% endif %} - </div> </div> </div> @@ -130,24 +102,3 @@ </a> </div> </nav> - -{% if user.is_authenticated %} - <script defer type="text/javascript"> - // Script which loads and sets up the account settings modal. - // This script must be placed in a template, or rewritten to take the fetch - // URL as a function argument, in order to be used. - - "use strict"; - - // Create and prepend a new div for this modal - let element = document.createElement("div"); - document.body.prepend(element); - - fetch("{% url "account_settings" %}") // Fetch the URL - .then((response) => response.text()) // Read in the data stream as text - .then((text) => { - element.outerHTML = text; // Replace the div's HTML with the loaded modal HTML - setupModal(document.getElementById("account-modal")); // Set up the modal - }); - </script> -{% endif %} diff --git a/pydis_site/templates/home/account/delete.html b/pydis_site/templates/home/account/delete.html deleted file mode 100644 index 0d44e32a..00000000 --- a/pydis_site/templates/home/account/delete.html +++ /dev/null @@ -1,47 +0,0 @@ -{% extends 'base/base.html' %} -{% load static %} - -{% block title %}Delete Account{% endblock %} - -{% block content %} - {% include "base/navbar.html" %} - - <section class="section content"> - <div class="container"> - <h2 class="is-size-2 has-text-centered">Account Deletion</h2> - - <div class="columns is-centered"> - <div class="column is-half-desktop is-full-tablet is-full-mobile"> - - <article class="message is-danger"> - <div class="message-body"> - <p> - You have requested to delete the account with username - <strong><span class="has-text-dark is-family-monospace">{{ user.username }}</span></strong>. - </p> - - <p> - Please note that this <strong>cannot be undone</strong>. - </p> - - <p> - To verify that you'd like to remove your account, please type your username into the box below. - </p> - </div> - </article> - </div> - </div> - - <div class="columns is-centered"> - <div class="column is-half-desktop is-full-tablet is-full-mobile"> - <form method="post"> - {% csrf_token %} - <label for="id_username" class="label requiredField">Username</label> - <input id="id_username" class="input" type="text" required name="username"> - <input style="margin-top: 1em;" type="submit" value="I understand, delete my account" class="button is-primary"> - </form> - </div> - </div> - </div> - </section> -{% endblock %} diff --git a/pydis_site/templates/home/account/settings.html b/pydis_site/templates/home/account/settings.html deleted file mode 100644 index ed59b052..00000000 --- a/pydis_site/templates/home/account/settings.html +++ /dev/null @@ -1,136 +0,0 @@ -{% load socialaccount %} - -{# This template is just for a modal, which is actually inserted into the navbar #} -{# template. Take a look at `navbar.html` to see how it's inserted. #} - -<div class="modal" id="account-modal"> - <div class="modal-background"></div> - <div class="modal-card"> - <div class="modal-card-head"> - <div class="modal-card-title">Settings for {{ user.username }}</div> - - {% if groups %} - <div> - {% for group in groups %} - <span class="tag is-primary">{{ group.name }}</span> - {% endfor %} - </div> - {% else %} - <span class="tag is-dark">No groups</span> - {% endif %} - </div> - <div class="modal-card-body"> - <h3 class="title">Connections</h3> - <div class="columns"> - {% if discord_provider is not None %} - <div class="column"> - <div class="box"> - {% if not discord %} - <div class="media"> - <div class="media-left"> - <div class="image"> - <i class="fab fa-discord fa-3x has-text-primary"></i> - </div> - </div> - <div class="media-content"> - <div class="title is-5">Discord</div> - <div class="subtitle is-6">Not connected</div> - </div> - </div> - <div> - <br /> - <a class="button is-primary" href="{% provider_login_url "discord" process="connect" %}"> - <span class="icon"> - <i class="fad fa-link"></i> - </span> - <span>Connect</span> - </a> - </div> - {% else %} - <div class="media"> - <div class="media-left"> - <div class="image"> - <i class="fab fa-discord fa-3x has-text-primary"></i> - </div> - </div> - <div class="media-content"> - <div class="title is-5">Discord</div> - <div class="subtitle is-6">{{ user.username }}</div> - </div> - </div> - <div> - <br /> - <button class="button" disabled> - <span class="icon"> - <i class="fas fa-check"></i> - </span> - <span>Connected</span> - </button> - </div> - {% endif %} - </div> - </div> - {% endif %} - - {% if github_provider is not None %} - <div class="column"> - <div class="box"> - {% if not github %} - <div class="media"> - <div class="media-left"> - <div class="image"> - <i class="fab fa-github fa-3x"></i> - </div> - </div> - <div class="media-content"> - <div class="title is-5">GitHub</div> - <div class="subtitle is-6">Not connected</div> - </div> - </div> - <div> - <br /> - <a class="button is-primary" href="{% provider_login_url "github" process="connect" %}"> - <span class="icon"> - <i class="fad fa-link"></i> - </span> - <span>Connect</span> - </a> - </div> - {% else %} - <div class="media"> - <div class="media-left"> - <div class="image"> - <i class="fab fa-github fa-3x"></i> - </div> - </div> - <div class="media-content"> - <div class="title is-5">GitHub</div> - <div class="subtitle is-6">{{ github.extra_data.name }}</div> - </div> - </div> - <div> - <form method="post" action="{% url "account_settings" %}" type="submit"> - {% csrf_token %} - - <input type="hidden" name="provider" value="github" /> - - <br /> - <button type="submit" class="button is-danger"> - <span class="icon"> - <i class="fas fa-times"></i> - </span> - <span>Disconnect</span> - </button> - </form> - </div> - {% endif %} - </div> - </div> - {% endif %} - </div> - </div> - <div class="modal-card-foot"> - <a class="button is-danger" href="{% url "account_delete" %}">Delete Account</a> - </div> - </div> -</div> diff --git a/pydis_site/tests/__init__.py b/pydis_site/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 --- a/pydis_site/tests/__init__.py +++ /dev/null diff --git a/pydis_site/tests/test_utils_account.py b/pydis_site/tests/test_utils_account.py deleted file mode 100644 index 6f8338b4..00000000 --- a/pydis_site/tests/test_utils_account.py +++ /dev/null @@ -1,139 +0,0 @@ -from unittest.mock import patch - -from allauth.exceptions import ImmediateHttpResponse -from allauth.socialaccount.models import SocialAccount, SocialLogin -from django.contrib.auth.models import User -from django.contrib.messages.storage.base import BaseStorage -from django.http import HttpRequest -from django.test import RequestFactory, TestCase - -from pydis_site.apps.api.models import Role, User as DiscordUser -from pydis_site.utils.account import AccountAdapter, SocialAccountAdapter - - -class AccountUtilsTests(TestCase): - def setUp(self): - # Create the user - self.django_user = User.objects.create(username="user") - - # Create the roles - developers_role = Role.objects.create( - id=1, - name="Developers", - colour=0, - permissions=0, - position=1 - ) - everyone_role = Role.objects.create( - id=0, - name="@everyone", - colour=0, - permissions=0, - position=0 - ) - - # Create the social accounts - self.discord_account = SocialAccount.objects.create( - user=self.django_user, provider="discord", uid=0 - ) - self.discord_account_one_role = SocialAccount.objects.create( - user=self.django_user, provider="discord", uid=1 - ) - self.discord_account_two_roles = SocialAccount.objects.create( - user=self.django_user, provider="discord", uid=2 - ) - self.discord_account_not_present = SocialAccount.objects.create( - user=self.django_user, provider="discord", uid=3 - ) - self.github_account = SocialAccount.objects.create( - user=self.django_user, provider="github", uid=0 - ) - - # Create DiscordUsers - self.discord_user = DiscordUser.objects.create( - id=0, - name="user", - discriminator=0 - ) - - self.discord_user_role = DiscordUser.objects.create( - id=1, - name="user present", - discriminator=0, - roles=[everyone_role.id] - ) - - self.discord_user_two_roles = DiscordUser.objects.create( - id=2, - name="user with both roles", - discriminator=0, - roles=[everyone_role.id, developers_role.id] - ) - - self.request_factory = RequestFactory() - - def test_account_adapter(self): - """Test that our Allauth account adapter functions correctly.""" - adapter = AccountAdapter() - - self.assertFalse(adapter.is_open_for_signup(HttpRequest())) - - def test_social_account_adapter_signup(self): - """Test that our Allauth social account adapter correctly handles signups.""" - adapter = SocialAccountAdapter() - - discord_login = SocialLogin(account=self.discord_account) - discord_login_role = SocialLogin(account=self.discord_account_one_role) - discord_login_not_present = SocialLogin(account=self.discord_account_not_present) - discord_login_two_roles = SocialLogin(account=self.discord_account_two_roles) - - github_login = SocialLogin(account=self.github_account) - - messages_request = self.request_factory.get("/") - messages_request._messages = BaseStorage(messages_request) - - with patch("pydis_site.utils.account.reverse") as mock_reverse: - with patch("pydis_site.utils.account.redirect") as mock_redirect: - with self.assertRaises(ImmediateHttpResponse): - adapter.is_open_for_signup(messages_request, github_login) - - with self.assertRaises(ImmediateHttpResponse): - adapter.is_open_for_signup(messages_request, discord_login) - - with self.assertRaises(ImmediateHttpResponse): - adapter.is_open_for_signup(messages_request, discord_login_role) - - with self.assertRaises(ImmediateHttpResponse): - adapter.is_open_for_signup(messages_request, discord_login_not_present) - - self.assertTrue( - adapter.is_open_for_signup(messages_request, discord_login_two_roles) - ) - - self.assertEqual(len(messages_request._messages._queued_messages), 4) - self.assertEqual(mock_redirect.call_count, 4) - self.assertEqual(mock_reverse.call_count, 4) - - def test_social_account_adapter_populate(self): - """Test that our Allauth social account adapter correctly handles data population.""" - adapter = SocialAccountAdapter() - - discord_login = SocialLogin( - account=self.discord_account, - user=self.django_user - ) - discord_login.account.extra_data["discriminator"] = "0000" - - discord_user = adapter.populate_user( - self.request_factory.get("/"), discord_login, - {"username": "user"} - ) - self.assertEqual(discord_user.username, "user#0000") - self.assertEqual(discord_user.first_name, "user#0000") - - discord_login.account.provider = "not_discord" - not_discord_user = adapter.populate_user( - self.request_factory.get("/"), discord_login, - {"username": "user"} - ) - self.assertEqual(not_discord_user.username, "user") diff --git a/pydis_site/utils/account.py b/pydis_site/utils/account.py deleted file mode 100644 index b4e41198..00000000 --- a/pydis_site/utils/account.py +++ /dev/null @@ -1,79 +0,0 @@ -from typing import Any, Dict - -from allauth.account.adapter import DefaultAccountAdapter -from allauth.exceptions import ImmediateHttpResponse -from allauth.socialaccount.adapter import DefaultSocialAccountAdapter -from allauth.socialaccount.models import SocialLogin -from django.contrib.auth.models import User as DjangoUser -from django.contrib.messages import ERROR, add_message -from django.http import HttpRequest -from django.shortcuts import redirect -from django.urls import reverse - -from pydis_site.apps.api.models import User as DiscordUser - -ERROR_CONNECT_DISCORD = ("You must login with Discord before connecting another account. " - "Your account details have not been saved.") -ERROR_JOIN_DISCORD = ("Please join the Discord server and verify that you accept the rules and " - "privacy policy.") - - -class AccountAdapter(DefaultAccountAdapter): - """An Allauth account adapter that prevents signups via form submission.""" - - def is_open_for_signup(self, request: HttpRequest) -> bool: - """ - Checks whether or not the site is open for signups. - - We override this to always return False so that users may never sign up using - Allauth's signup form endpoints, to be on the safe side - since we only want users - to sign up using their Discord account. - """ - return False - - -class SocialAccountAdapter(DefaultSocialAccountAdapter): - """An Allauth SocialAccount adapter that prevents signups via non-Discord connections.""" - - def is_open_for_signup(self, request: HttpRequest, social_login: SocialLogin) -> bool: - """ - Checks whether or not the site is open for signups. - - We override this method in order to prevent users from creating a new account using - a non-Discord connection, as we require this connection for our users. - """ - if social_login.account.provider != "discord": - add_message(request, ERROR, ERROR_CONNECT_DISCORD) - - raise ImmediateHttpResponse(redirect(reverse("home"))) - - try: - user = DiscordUser.objects.get(id=int(social_login.account.uid)) - except DiscordUser.DoesNotExist: - add_message(request, ERROR, ERROR_JOIN_DISCORD) - - raise ImmediateHttpResponse(redirect(reverse("home"))) - - if len(user.roles) <= 1: - add_message(request, ERROR, ERROR_JOIN_DISCORD) - - raise ImmediateHttpResponse(redirect(reverse("home"))) - - return True - - def populate_user(self, request: HttpRequest, - social_login: SocialLogin, - data: Dict[str, Any]) -> DjangoUser: - """ - Method used to populate a Django User with data. - - We override this so that the Django user is created with the username#discriminator, - instead of just the username, as Django users must have unique usernames. For display - purposes, we also set the `name` key, which is used for `first_name` in the database. - """ - if social_login.account.provider == "discord": - discriminator = social_login.account.extra_data["discriminator"] - data["username"] = f"{data['username']}#{discriminator:0>4}" - data["name"] = data["username"] - - return super().populate_user(request, social_login, data) diff --git a/pydis_site/utils/views.py b/pydis_site/utils/views.py deleted file mode 100644 index c9803bd6..00000000 --- a/pydis_site/utils/views.py +++ /dev/null @@ -1,25 +0,0 @@ -from django.contrib import messages -from django.http import HttpRequest -from django.views.generic import RedirectView - - -class MessageRedirectView(RedirectView): - """ - Redirects to another URL, also setting a message using the Django Messages framework. - - This is based on Django's own `RedirectView` and works the same way, but takes two additional - parameters. - - * `message`: Set to the message content you wish to display. - * `message_level`: Set to one of the message levels from the Django messages framework. This - parameter defaults to `messages.INFO`. - """ - - message: str = "" - message_level: int = messages.INFO - - def get(self, request: HttpRequest, *args, **kwargs) -> None: - """Called upon a GET request.""" - messages.add_message(request, self.message_level, self.message) - - return super().get(request, *args, **kwargs) |