diff options
Diffstat (limited to 'pydis_site')
22 files changed, 1052 insertions, 34 deletions
| diff --git a/pydis_site/__init__.py b/pydis_site/__init__.py index c6146450..df67cf71 100644 --- a/pydis_site/__init__.py +++ b/pydis_site/__init__.py @@ -2,3 +2,8 @@ from wiki.plugins.macros.mdx import toc  # Remove the toc header prefix. There's no option for this, so we gotta monkey patch it.  toc.HEADER_ID_PREFIX = '' + +# 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/forms/__init__.py b/pydis_site/apps/home/forms/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/pydis_site/apps/home/forms/__init__.py diff --git a/pydis_site/apps/home/forms/account_deletion.py b/pydis_site/apps/home/forms/account_deletion.py new file mode 100644 index 00000000..17ffe5c1 --- /dev/null +++ b/pydis_site/apps/home/forms/account_deletion.py @@ -0,0 +1,24 @@ +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Layout +from django.forms import CharField, Form +from django_crispy_bulma.layout import IconField, Submit + + +class AccountDeletionForm(Form): +    """Account deletion form, to collect username for confirmation of removal.""" + +    def __init__(self, *args, **kwargs): +        super().__init__(*args, **kwargs) +        self.helper = FormHelper() + +        self.helper.form_method = "post" +        self.helper.add_input(Submit("submit", "I understand, delete my account")) + +        self.helper.layout = Layout( +            IconField("username", icon_prepend="user") +        ) + +    username = CharField( +        label="Username", +        required=True +    ) diff --git a/pydis_site/apps/home/signals.py b/pydis_site/apps/home/signals.py index 9f286882..4cb4564b 100644 --- a/pydis_site/apps/home/signals.py +++ b/pydis_site/apps/home/signals.py @@ -1,3 +1,4 @@ +from contextlib import suppress  from typing import List, Optional, Type  from allauth.account.signals import user_logged_in @@ -8,7 +9,7 @@ from allauth.socialaccount.signals import (      pre_social_login, social_account_added, social_account_removed,      social_account_updated)  from django.contrib.auth.models import Group, User as DjangoUser -from django.db.models.signals import post_save, pre_delete, pre_save +from django.db.models.signals import post_delete, post_save, pre_save  from pydis_site.apps.api.models import User as DiscordUser  from pydis_site.apps.staff.models import RoleMapping @@ -37,7 +38,7 @@ class AllauthSignalListener:      def __init__(self):          post_save.connect(self.user_model_updated, sender=DiscordUser) -        pre_delete.connect(self.mapping_model_deleted, sender=RoleMapping) +        post_delete.connect(self.mapping_model_deleted, sender=RoleMapping)          pre_save.connect(self.mapping_model_updated, sender=RoleMapping)          pre_social_login.connect(self.social_account_updated) @@ -133,13 +134,29 @@ class AllauthSignalListener:          Processes deletion signals from the RoleMapping model, removing perms from users.          We need to do this to ensure that users aren't left with permissions groups that -        they shouldn't have assigned to them when a RoleMapping is deleted from the database. +        they shouldn't have assigned to them when a RoleMapping is deleted from the database, +        and to remove their staff status if they should no longer have it.          """          instance: RoleMapping = kwargs["instance"]          for user in instance.group.user_set.all(): +            # Firstly, remove their related user group              user.groups.remove(instance.group) +            with suppress(SocialAccount.DoesNotExist, DiscordUser.DoesNotExist): +                # If we get either exception, then the user could not have been assigned staff +                # with our system in the first place. + +                social_account = SocialAccount.objects.get(user=user, provider=DiscordProvider.id) +                discord_user = DiscordUser.objects.get(id=int(social_account.uid)) + +                mappings = RoleMapping.objects.filter(role__in=discord_user.roles.all()).all() +                is_staff = any(m.is_staff for m in mappings) + +                if user.is_staff != is_staff: +                    user.is_staff = is_staff +                    user.save(update_fields=("is_staff", )) +      def mapping_model_updated(self, sender: Type[RoleMapping], **kwargs) -> None:          """          Processes update signals from the RoleMapping model. @@ -174,6 +191,21 @@ class AllauthSignalListener:          for account in accounts:              account.user.groups.add(instance.group) +            if instance.is_staff and not account.user.is_staff: +                account.user.is_staff = instance.is_staff +                account.user.save(update_fields=("is_staff", )) +            else: +                discord_user = DiscordUser.objects.get(id=int(account.uid)) + +                mappings = RoleMapping.objects.filter( +                    role__in=discord_user.roles.all() +                ).exclude(id=instance.id).all() +                is_staff = any(m.is_staff for m in mappings) + +                if account.user.is_staff != is_staff: +                    account.user.is_staff = is_staff +                    account.user.save(update_fields=("is_staff",)) +      def user_model_updated(self, sender: Type[DiscordUser], **kwargs) -> None:          """          Processes update signals from the Discord User model, assigning perms as required. @@ -230,31 +262,53 @@ class AllauthSignalListener:          except SocialAccount.user.RelatedObjectDoesNotExist:              return  # There's no user account yet, this will be handled by another receiver +        # Ensure that the username on this account is correct +        new_username = f"{user.name}#{user.discriminator}" + +        if account.user.username != new_username: +            account.user.username = new_username +            account.user.first_name = new_username +          if not user.in_guild:              deletion = True          if deletion:              # They've unlinked Discord or left the server, so we have to remove their groups +            # and their staff status -            if not current_groups: -                return  # They have no groups anyway, no point in processing +            if current_groups: +                # They do have groups, so let's remove them +                account.user.groups.remove( +                    *(mapping.group for mapping in mappings) +                ) -            account.user.groups.remove( -                *(mapping.group for mapping in mappings) -            ) +            if account.user.is_staff: +                # They're marked as a staff user and they shouldn't be, so let's fix that +                account.user.is_staff = False          else:              new_groups = [] +            is_staff = False              for role in user.roles.all():                  try: -                    new_groups.append(mappings.get(role=role).group) +                    mapping = mappings.get(role=role)                  except RoleMapping.DoesNotExist:                      continue  # No mapping exists -                account.user.groups.add( -                    *[group for group in new_groups if group not in current_groups] -                ) +                new_groups.append(mapping.group) -                account.user.groups.remove( -                    *[mapping.group for mapping in mappings if mapping.group not in new_groups] -                ) +                if mapping.is_staff: +                    is_staff = True + +            account.user.groups.add( +                *[group for group in new_groups if group not in current_groups] +            ) + +            account.user.groups.remove( +                *[mapping.group for mapping in mappings if mapping.group not in new_groups] +            ) + +            if account.user.is_staff != is_staff: +                account.user.is_staff = is_staff + +        account.user.save() diff --git a/pydis_site/apps/home/tests/test_signal_listener.py b/pydis_site/apps/home/tests/test_signal_listener.py index 27fc7710..66a67252 100644 --- a/pydis_site/apps/home/tests/test_signal_listener.py +++ b/pydis_site/apps/home/tests/test_signal_listener.py @@ -67,12 +67,14 @@ class SignalListenerTests(TestCase):          cls.admin_mapping = RoleMapping.objects.create(              role=cls.admin_role, -            group=cls.admin_group +            group=cls.admin_group, +            is_staff=True          )          cls.moderator_mapping = RoleMapping.objects.create(              role=cls.moderator_role, -            group=cls.moderator_group +            group=cls.moderator_group, +            is_staff=False          )          cls.discord_user = DiscordUser.objects.create( @@ -166,7 +168,7 @@ class SignalListenerTests(TestCase):          cls.django_moderator = DjangoUser.objects.create(              username="moderator", -            is_staff=True, +            is_staff=False,              is_superuser=False          ) @@ -339,6 +341,33 @@ class SignalListenerTests(TestCase):          self.discord_admin.roles.add(self.admin_role)          self.discord_admin.save() +    def test_apply_groups_moderator(self): +        """Test application of groups by role, relating to a non-`is_staff` moderator user.""" +        handler = AllauthSignalListener() + +        self.assertEqual(self.django_user_discordless.groups.all().count(), 0) + +        # Apply groups based on moderator role being present on Discord +        handler._apply_groups(self.discord_moderator, self.social_moderator) +        self.assertTrue(self.moderator_group in self.django_moderator.groups.all()) + +        # Remove groups based on the user apparently leaving the server +        handler._apply_groups(self.discord_moderator, self.social_moderator, True) +        self.assertEqual(self.django_user_discordless.groups.all().count(), 0) + +        # Apply the moderator role again +        handler._apply_groups(self.discord_moderator, self.social_moderator) + +        # Remove all of the roles from the user +        self.discord_moderator.roles.clear() + +        # Remove groups based on the user no longer having the moderator role on Discord +        handler._apply_groups(self.discord_moderator, self.social_moderator) +        self.assertEqual(self.django_user_discordless.groups.all().count(), 0) + +        self.discord_moderator.roles.add(self.moderator_role) +        self.discord_moderator.save() +      def test_apply_groups_other(self):          """Test application of groups by role, relating to non-standard cases."""          handler = AllauthSignalListener() @@ -373,10 +402,25 @@ class SignalListenerTests(TestCase):          self.assertEqual(self.django_moderator.groups.all().count(), 1)          self.assertEqual(self.django_admin.groups.all().count(), 1) +        # Test is_staff changes +        self.admin_mapping.is_staff = False +        self.admin_mapping.save() + +        self.assertFalse(self.django_moderator.is_staff) +        self.assertFalse(self.django_admin.is_staff) + +        self.admin_mapping.is_staff = True +        self.admin_mapping.save() + +        self.django_admin.refresh_from_db(fields=("is_staff", )) +        self.assertTrue(self.django_admin.is_staff) +          # Test mapping deletion          self.admin_mapping.delete() +        self.django_admin.refresh_from_db(fields=("is_staff",))          self.assertEqual(self.django_admin.groups.all().count(), 0) +        self.assertFalse(self.django_admin.is_staff)          # Test mapping update          self.moderator_mapping.group = self.admin_group @@ -388,12 +432,30 @@ class SignalListenerTests(TestCase):          # Test mapping creation          new_mapping = RoleMapping.objects.create(              role=self.admin_role, -            group=self.moderator_group +            group=self.moderator_group, +            is_staff=True +        ) + +        self.assertEqual(self.django_admin.groups.all().count(), 1) +        self.assertTrue(self.moderator_group in self.django_admin.groups.all()) + +        self.django_admin.refresh_from_db(fields=("is_staff",)) +        self.assertTrue(self.django_admin.is_staff) + +        new_mapping.delete() + +        # Test mapping creation (without is_staff) +        new_mapping = RoleMapping.objects.create( +            role=self.admin_role, +            group=self.moderator_group,          )          self.assertEqual(self.django_admin.groups.all().count(), 1)          self.assertTrue(self.moderator_group in self.django_admin.groups.all()) +        self.django_admin.refresh_from_db(fields=("is_staff",)) +        self.assertFalse(self.django_admin.is_staff) +          # Test that nothing happens when fixtures are loaded          pre_save.send(RoleMapping, instance=new_mapping, raw=True) diff --git a/pydis_site/apps/home/tests/test_views.py b/pydis_site/apps/home/tests/test_views.py index 7aeaddd2..572317a7 100644 --- a/pydis_site/apps/home/tests/test_views.py +++ b/pydis_site/apps/home/tests/test_views.py @@ -1,5 +1,198 @@ +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 reverse +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()  class TestIndexReturns200(TestCase): @@ -16,6 +209,7 @@ class TestLoginCancelledReturns302(TestCase):          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): @@ -24,3 +218,4 @@ class TestLoginErrorReturns302(TestCase):          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 211a7ad1..61e87a39 100644 --- a/pydis_site/apps/home/urls.py +++ b/pydis_site/apps/home/urls.py @@ -1,5 +1,4 @@  from allauth.account.views import LogoutView -from allauth.socialaccount.views import ConnectionsView  from django.conf import settings  from django.conf.urls.static import static  from django.contrib import admin @@ -7,14 +6,18 @@ from django.contrib.messages import ERROR  from django.urls import include, path  from pydis_site.utils.views import MessageRedirectView -from .views import HomeView +from .views import AccountDeleteView, AccountSettingsView, HomeView  app_name = 'home'  urlpatterns = [ +    # 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('pages/', include('wiki.urls')),      path('accounts/', include('allauth.socialaccount.providers.discord.urls')), +    path('accounts/', include('allauth.socialaccount.providers.github.urls')),      path(          'accounts/login/cancelled', MessageRedirectView.as_view( @@ -28,7 +31,9 @@ urlpatterns = [          ), name='socialaccount_login_error'      ), -    path('connections', ConnectionsView.as_view()), +    path('accounts/settings', AccountSettingsView.as_view(), name="account_settings"), +    path('accounts/delete', AccountDeleteView.as_view(), name="account_delete"), +      path('logout', LogoutView.as_view(), name="logout"),      path('admin/', admin.site.urls), diff --git a/pydis_site/apps/home/views/__init__.py b/pydis_site/apps/home/views/__init__.py index 971d73a3..801fd398 100644 --- a/pydis_site/apps/home/views/__init__.py +++ b/pydis_site/apps/home/views/__init__.py @@ -1,3 +1,4 @@ +from .account import DeleteView as AccountDeleteView, SettingsView as AccountSettingsView  from .home import HomeView -__all__ = ["HomeView"] +__all__ = ["AccountDeleteView", "AccountSettingsView", "HomeView"] diff --git a/pydis_site/apps/home/views/account/__init__.py b/pydis_site/apps/home/views/account/__init__.py new file mode 100644 index 00000000..3b3250ea --- /dev/null +++ b/pydis_site/apps/home/views/account/__init__.py @@ -0,0 +1,4 @@ +from .delete import DeleteView +from .settings import SettingsView + +__all__ = ["DeleteView", "SettingsView"] diff --git a/pydis_site/apps/home/views/account/delete.py b/pydis_site/apps/home/views/account/delete.py new file mode 100644 index 00000000..798b8a33 --- /dev/null +++ b/pydis_site/apps/home/views/account/delete.py @@ -0,0 +1,37 @@ +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 new file mode 100644 index 00000000..3a817dbc --- /dev/null +++ b/pydis_site/apps/home/views/account/settings.py @@ -0,0 +1,59 @@ +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/migrations/0002_add_is_staff_to_role_mappings.py b/pydis_site/apps/staff/migrations/0002_add_is_staff_to_role_mappings.py new file mode 100644 index 00000000..0404d270 --- /dev/null +++ b/pydis_site/apps/staff/migrations/0002_add_is_staff_to_role_mappings.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.6 on 2019-10-20 14:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('staff', '0001_initial'), +    ] + +    operations = [ +        migrations.AddField( +            model_name='rolemapping', +            name='is_staff', +            field=models.BooleanField(default=False, help_text='Whether this role mapping relates to a Django staff group'), +        ), +    ] diff --git a/pydis_site/apps/staff/models/role_mapping.py b/pydis_site/apps/staff/models/role_mapping.py index 10c09cf1..8a1fac2e 100644 --- a/pydis_site/apps/staff/models/role_mapping.py +++ b/pydis_site/apps/staff/models/role_mapping.py @@ -21,6 +21,11 @@ class RoleMapping(models.Model):          unique=True,  # Unique in order to simplify group assignment logic      ) +    is_staff = models.BooleanField( +        help_text="Whether this role mapping 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 94718ec7..66376c4e 100644 --- a/pydis_site/settings.py +++ b/pydis_site/settings.py @@ -92,6 +92,7 @@ INSTALLED_APPS = [      'allauth.socialaccount',      'allauth.socialaccount.providers.discord', +    'allauth.socialaccount.providers.github',      'crispy_forms',      'django_crispy_bulma', @@ -302,14 +303,14 @@ BULMA_SETTINGS = {      "variables": {  # If you update these colours, please update the notification.css file          "primary": "#7289DA",    # Discord blurple -        "orange": "#ffb39b",     # Bulma default, but at a saturation of 100 -        "yellow": "#ffea9b",     # Bulma default, but at a saturation of 100 -        "green": "#7fd19c",      # Bulma default, but at a saturation of 100 -        "turquoise": "#7289DA",  # Blurple, because Bulma uses this as the default primary -        "cyan": "#91cbee",       # Bulma default, but at a saturation of 100 -        "blue": "#86a7dc",       # Bulma default, but at a saturation of 100 -        "purple": "#b86bff",     # Bulma default, but at a saturation of 100 -        "red": "#ffafc2",        # Bulma default, but at a saturation of 80 +        # "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 +        "cyan": "#2482c1",       # Colour picked after Discord discussion (matches the blue) +        "purple": "#aa55e4",     # Apparently unused, but changed for consistency +        "red": "#d63852",        # Colour picked after Discord discussion          "link": "$primary", @@ -407,5 +408,13 @@ AUTHENTICATION_BACKENDS = (      '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/base.css b/pydis_site/static/css/base/base.css index 3ca6b2a7..75702d47 100644 --- a/pydis_site/static/css/base/base.css +++ b/pydis_site/static/css/base/base.css @@ -84,7 +84,25 @@ div.card.has-equal-height {  /* Fix for logout form submit button in navbar */ -button.is-size-navbar-menu { +button.is-size-navbar-menu, a.is-size-navbar-menu {      font-size: 14px; +    padding-left: 1.5rem; +    padding-right: 1.5rem;  } +@media screen and (min-width: 1088px) { +    button.is-size-navbar-menu, a.is-size-navbar-menu { +        padding-left: 1rem; +        padding-right: 1rem; +    } +} + +/* Fix for modals being behind the navbar */ + +.modal * { +    z-index: 1020; +} + +.modal-background { +    z-index: 1010; +} diff --git a/pydis_site/static/js/base/modal.js b/pydis_site/static/js/base/modal.js new file mode 100644 index 00000000..eccc8845 --- /dev/null +++ b/pydis_site/static/js/base/modal.js @@ -0,0 +1,100 @@ +/* + 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 a9b31c0f..4c70d778 100644 --- a/pydis_site/templates/base/base.html +++ b/pydis_site/templates/base/base.html @@ -28,6 +28,7 @@    {# Font-awesome here is defined explicitly so that we can have Pro #}    <script src="https://kit.fontawesome.com/ae6a3152d8.js"></script> +  <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" %}"> diff --git a/pydis_site/templates/base/navbar.html b/pydis_site/templates/base/navbar.html index 8cdac0de..2ba5bdd4 100644 --- a/pydis_site/templates/base/navbar.html +++ b/pydis_site/templates/base/navbar.html @@ -102,7 +102,15 @@            {% else %}              <form method="post" action="{% url 'logout' %}">                {% csrf_token %} -              <button type="submit" class="navbar-item button is-white is-inline is-fullwidth has-text-left is-size-navbar-menu has-text-grey-dark">Logout</button> + +              <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 %} @@ -116,3 +124,24 @@      </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 new file mode 100644 index 00000000..1020a82b --- /dev/null +++ b/pydis_site/templates/home/account/delete.html @@ -0,0 +1,44 @@ +{% extends 'base/base.html' %} + +{% load crispy_forms_tags %} +{% 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"> +          {% crispy form %} +        </div> +      </div> +    </div> +  </section> +{% endblock %} diff --git a/pydis_site/templates/home/account/settings.html b/pydis_site/templates/home/account/settings.html new file mode 100644 index 00000000..9f48d736 --- /dev/null +++ b/pydis_site/templates/home/account/settings.html @@ -0,0 +1,137 @@ +{% 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/test_utils_account.py b/pydis_site/tests/test_utils_account.py new file mode 100644 index 00000000..d2946368 --- /dev/null +++ b/pydis_site/tests/test_utils_account.py @@ -0,0 +1,132 @@ +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 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): +        self.django_user = User.objects.create(username="user") + +        self.discord_account = SocialAccount.objects.create( +            user=self.django_user, provider="discord", uid=0 +        ) + +        self.discord_account_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 +        ) + +        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 +        ) + +        self.discord_user_two_roles = DiscordUser.objects.create( +            id=2, +            name="user with both roles", +            discriminator=0 +        ) + +        everyone_role = Role.objects.create( +            id=0, +            name="@everyone", +            colour=0, +            permissions=0, +            position=0 +        ) + +        self.discord_user_role.roles.add(everyone_role) +        self.discord_user_two_roles.roles.add(everyone_role) + +        self.discord_user_two_roles.roles.add(Role.objects.create( +            id=1, +            name="Developers", +            colour=0, +            permissions=0, +            position=1 +        )) + +    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_role) +        discord_login_two_roles = SocialLogin(account=self.discord_account_two_roles) +        discord_login_not_present = SocialLogin(account=self.discord_account_not_present) + +        github_login = SocialLogin(account=self.github_account) + +        messages_request = HttpRequest() +        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.assertEqual(len(messages_request._messages._queued_messages), 4) +                self.assertEqual(mock_redirect.call_count, 4) +            self.assertEqual(mock_reverse.call_count, 4) + +        self.assertTrue(adapter.is_open_for_signup(HttpRequest(), discord_login_two_roles)) + +    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" + +        user = adapter.populate_user( +            HttpRequest(), discord_login, +            {"username": "user"} +        ) + +        self.assertEqual(user.username, "user#0000") +        self.assertEqual(user.first_name, "user#0000") diff --git a/pydis_site/utils/account.py b/pydis_site/utils/account.py new file mode 100644 index 00000000..e64919de --- /dev/null +++ b/pydis_site/utils/account.py @@ -0,0 +1,79 @@ +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 user.roles.count() <= 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}" +            data["name"] = data["username"] + +        return super().populate_user(request, social_login, data) | 
