diff --git a/allianceauth/authentication/decorators.py b/allianceauth/authentication/decorators.py
new file mode 100644
index 00000000..60f938f0
--- /dev/null
+++ b/allianceauth/authentication/decorators.py
@@ -0,0 +1,37 @@
+from django.conf.urls import include
+from functools import wraps
+from django.shortcuts import redirect
+from django.contrib import messages
+from django.utils.translation import gettext_lazy as _
+from django.contrib.auth.decorators import login_required
+
+
+def user_has_main_character(user):
+ return bool(user.profile.main_character)
+
+
+def decorate_url_patterns(urls, decorator):
+ url_list, app_name, namespace = include(urls)
+
+ def process_patterns(url_patterns):
+ for pattern in url_patterns:
+ if hasattr(pattern, 'url_patterns'):
+ # this is an include - apply to all nested patterns
+ process_patterns(pattern.url_patterns)
+ else:
+ # this is a pattern
+ pattern.callback = decorator(pattern.callback)
+
+ process_patterns(url_list)
+ return url_list, app_name, namespace
+
+
+def main_character_required(view_func):
+ @wraps(view_func)
+ def _wrapped_view(request, *args, **kwargs):
+ if user_has_main_character(request.user):
+ return view_func(request, *args, **kwargs)
+
+ messages.error(request, _('A main character is required to perform that action. Add one below.'))
+ return redirect('authentication:dashboard')
+ return login_required(_wrapped_view)
diff --git a/allianceauth/authentication/templates/authentication/dashboard.html b/allianceauth/authentication/templates/authentication/dashboard.html
index 6fe28db3..a4b916a0 100644
--- a/allianceauth/authentication/templates/authentication/dashboard.html
+++ b/allianceauth/authentication/templates/authentication/dashboard.html
@@ -57,7 +57,7 @@
{% endwith %}
{% else %}
-
{% trans "Missing main character model." %}
+ {% trans "No main character set." %}
{% endif %}
diff --git a/allianceauth/authentication/tests.py b/allianceauth/authentication/tests.py
index 73e6f060..ae1d119c 100644
--- a/allianceauth/authentication/tests.py
+++ b/allianceauth/authentication/tests.py
@@ -8,6 +8,61 @@ from .backends import StateBackend
from .tasks import check_character_ownership
from allianceauth.eveonline.models import EveCharacter, EveCorporationInfo, EveAllianceInfo
from esi.models import Token
+from allianceauth.authentication.decorators import main_character_required
+from django.test.client import RequestFactory
+from django.http.response import HttpResponse
+from django.contrib.auth.models import AnonymousUser
+from django.conf import settings
+from django.shortcuts import reverse
+from urllib import parse
+
+MODULE_PATH = 'allianceauth.authentication'
+
+
+class DecoratorTestCase(TestCase):
+ @staticmethod
+ @main_character_required
+ def dummy_view(*args, **kwargs):
+ return HttpResponse(status=200)
+
+ @classmethod
+ def setUpTestData(cls):
+ cls.main_user = AuthUtils.create_user('main_user', disconnect_signals=True)
+ cls.no_main_user = AuthUtils.create_user('no_main_user', disconnect_signals=True)
+ main_character = EveCharacter.objects.create(
+ character_id=1,
+ character_name='Main Character',
+ corporation_id=1,
+ corporation_name='Corp',
+ corporation_ticker='CORP',
+ )
+ CharacterOwnership.objects.create(user=cls.main_user, character=main_character, owner_hash='1')
+ cls.main_user.profile.main_character = main_character
+
+ def setUp(self):
+ self.request = RequestFactory().get('/test/')
+
+ @mock.patch(MODULE_PATH + '.decorators.messages')
+ def test_login_redirect(self, m):
+ setattr(self.request, 'user', AnonymousUser())
+ response = self.dummy_view(self.request)
+ self.assertEqual(response.status_code, 302)
+ url = getattr(response, 'url', None)
+ self.assertEqual(parse.urlparse(url).path, reverse(settings.LOGIN_URL))
+
+ @mock.patch(MODULE_PATH + '.decorators.messages')
+ def test_main_character_redirect(self, m):
+ setattr(self.request, 'user', self.no_main_user)
+ response = self.dummy_view(self.request)
+ self.assertEqual(response.status_code, 302)
+ url = getattr(response, 'url', None)
+ self.assertEqual(url, reverse('authentication:dashboard'))
+
+ @mock.patch(MODULE_PATH + '.decorators.messages')
+ def test_successful_request(self, m):
+ setattr(self.request, 'user', self.main_user)
+ response = self.dummy_view(self.request)
+ self.assertEqual(response.status_code, 200)
class BackendTestCase(TestCase):
diff --git a/allianceauth/permissions_tool/tests.py b/allianceauth/permissions_tool/tests.py
index e8f7764d..455ad98f 100644
--- a/allianceauth/permissions_tool/tests.py
+++ b/allianceauth/permissions_tool/tests.py
@@ -8,6 +8,7 @@ from allianceauth.tests.auth_utils import AuthUtils
class PermissionsToolViewsTestCase(WebTest):
def setUp(self):
self.member = AuthUtils.create_member('auth_member')
+ AuthUtils.add_main_character(self.member, 'test character', '1234', '2345', 'test corp', 'testc')
self.member.email = 'auth_member@example.com'
self.member.save()
self.none_user = AuthUtils.create_user('none_user', disconnect_signals=True)
diff --git a/allianceauth/services/modules/discord/tests.py b/allianceauth/services/modules/discord/tests.py
index 58126fbc..bb352204 100644
--- a/allianceauth/services/modules/discord/tests.py
+++ b/allianceauth/services/modules/discord/tests.py
@@ -145,6 +145,7 @@ class DiscordHooksTestCase(TestCase):
class DiscordViewsTestCase(WebTest):
def setUp(self):
self.member = AuthUtils.create_member('auth_member')
+ AuthUtils.add_main_character(self.member, 'test character', '1234', '2345', 'test corp', 'testc')
add_permissions()
def login(self):
diff --git a/allianceauth/urls.py b/allianceauth/urls.py
index 8ca4b0cc..9eac2116 100755
--- a/allianceauth/urls.py
+++ b/allianceauth/urls.py
@@ -9,9 +9,9 @@ import allianceauth.authentication.urls
import allianceauth.notifications.urls
import allianceauth.groupmanagement.urls
import allianceauth.services.urls
+from allianceauth.authentication.decorators import main_character_required, decorate_url_patterns
from allianceauth import NAME
from allianceauth import views
-
from allianceauth.authentication import hmac_urls
from allianceauth.hooks import get_hooks
@@ -42,13 +42,14 @@ urlpatterns = [
url(r'', include(allianceauth.groupmanagement.urls)),
# Services
- url(r'', include(allianceauth.services.urls)),
+ url(r'', decorate_url_patterns(allianceauth.services.urls.urlpatterns, main_character_required)),
# Night mode
url(r'^night/', views.NightModeRedirectView.as_view(), name='nightmode')
]
+
# Append app urls
app_urls = get_hooks('url_hook')
for app in app_urls:
- urlpatterns += [app().include_pattern]
+ urlpatterns += [url(r'', decorate_url_patterns([app().include_pattern], main_character_required))]
diff --git a/docs/development/url-hooks.md b/docs/development/url-hooks.md
index 930da0df..2ea5e372 100644
--- a/docs/development/url-hooks.md
+++ b/docs/development/url-hooks.md
@@ -2,7 +2,7 @@
```eval_rst
.. note::
- Currently most URL patterns are statically defined in the project's core urls.py file. Ideally this behaviour will change over time with each module of Alliance Auth providing all of its menu items via the hook. New modules should aim to use the hook over statically adding URL patterns to the project's patterns.
+ URLs added through URL Hooks are protected by a decorator which ensures the requesting user is logged in and has a main character set.
```
The URL hooks allow you to dynamically specify URL patterns from your plugin app or service. To achieve this you should subclass or instantiate the `services.hooks.UrlHook` class and then register the URL patterns with the hook.