Compare commits

..

40 Commits

Author SHA1 Message Date
Ariel Rin
be2fbe862b Version Bump 3.0.0 2022-07-30 19:10:07 +10:00
Ariel Rin
f95bee0921 roll back readthedocs python version, pending #1331 2022-07-30 18:54:53 +10:00
Ariel Rin
2f9ae8b054 Django bugfix bump before release 2022-07-30 18:40:27 +10:00
Ariel Rin
9cdcd8365c Merge branch 'docs' into 'v3.x'
Refactor Docs with OS Versions and non-root

See merge request allianceauth/allianceauth!1390
2022-07-30 07:26:43 +00:00
Ariel Rin
f5d70a2c48 Refactor Docs with OS Versions and non-root 2022-07-30 07:26:42 +00:00
Ariel Rin
f40ebbfba4 Merge branch 'v3.x' of https://gitlab.com/allianceauth/allianceauth into v3.x 2022-07-30 16:35:31 +10:00
Ariel Rin
2551a9dd64 Merge branch 'the-big-template-cleanup' into 'v3.x'
Big Template Cleanup

See merge request allianceauth/allianceauth!1443
2022-07-29 12:49:58 +00:00
Ariel Rin
e3b01ccbc9 Merge branch 'apache-ssl-settings-in-local-py' into 'v3.x'
Added fix for "apache vs django" proxy headers to docs

See merge request allianceauth/allianceauth!1440
2022-07-29 12:48:35 +00:00
Ariel Rin
267a392945 Merge branch 'timerboard-mandatory-fields' into 'v3.x'
[FIX] Set `planet_moon` field as not required

See merge request allianceauth/allianceauth!1441
2022-07-23 05:13:03 +00:00
Ariel Rin
634d021bf2 Merge branch 'fix-hr-search-result-duplication' into 'v3.x'
[FIX] Search result duplication in HR module

See merge request allianceauth/allianceauth!1444
2022-07-23 05:12:15 +00:00
Ariel Rin
4e8bfba738 Merge branch 'master' of https://gitlab.com/allianceauth/allianceauth into v3.x 2022-07-19 20:46:24 +10:00
Ariel Rin
297f98f046 Version Bump 2.15.1 2022-07-19 19:07:47 +10:00
Ariel Rin
27dad05927 Merge branch 'fix-discord-update-username' into 'master'
Fix discord update username

See merge request allianceauth/allianceauth!1442
2022-07-19 08:48:26 +00:00
Erik Kalkoken
697e9dd772 Fix discord update username 2022-07-19 08:48:25 +00:00
Peter Pfeufer
312951ea3f One search result per user is enough
No need to show the same result for each alt ....
2022-07-18 23:02:28 +02:00
Peter Pfeufer
e4bf96cfb6 Deprecated attributes removed 2022-07-18 21:51:07 +02:00
Peter Pfeufer
3bd6baa8f9 Templates cleaned up / fixed
- Deprecated CSS atrributes removed
- HTML fixes
    - Mandatory attributes added
    - Missing semicolons added
    - Missing closing tags added
    - Missing label association in forms added/fixed
    - Missing quotes added
    - Closing tags that have no opening tag removed
- Bootstrap fixes
- Unused template tags removed
2022-07-18 21:39:20 +02:00
Peter Pfeufer
06e38dcd93 [FIX] Set planet_moon field as not required
It is explicitly set to be not required in the form, so it shouldn't be required in the Django backend
2022-07-18 19:37:25 +02:00
Peter Pfeufer
f47b9eee5b Added fix for "apache vs django" proxy headers 2022-07-18 18:10:54 +02:00
Ariel Rin
0d4cab66b2 Merge branch 'master' of https://gitlab.com/allianceauth/allianceauth into v3.x 2022-07-18 21:25:23 +10:00
Ariel Rin
dc23ee8ad2 Delay automated docker builds, to allow publishing pipeline to catch up 2022-07-18 10:14:05 +00:00
Ariel Rin
65f2efc890 Version Bump 2.15.0 2022-07-18 19:22:50 +10:00
Ariel Rin
def30900b4 Merge branch 'discord_bugfixes_and_refactor' into 'master'
Fix managed roles and reserved groups bugs in Discord Service and more

Closes #1345 and #1334

See merge request allianceauth/allianceauth!1429
2022-07-18 09:12:32 +00:00
Erik Kalkoken
d7fabccddd Fix managed roles and reserved groups bugs in Discord Service and more 2022-07-18 09:12:32 +00:00
Ariel Rin
45289e1d17 Merge branch 'fix-filterdropdown-bug' into 'master'
Fix filterdropdown bug

See merge request allianceauth/allianceauth!1439
2022-07-18 09:04:35 +00:00
Ariel Rin
e7bafaa4d8 Merge branch 'datatables-filterdropdown-js-update' into 'v3.x'
Update `filterDropDown.js` to latest available version

See merge request allianceauth/allianceauth!1438
2022-07-18 09:01:30 +00:00
Peter Pfeufer
ba3f1507be LICENSE file added 2022-07-18 10:51:40 +02:00
ErikKalkoken
7b9bf08aa3 Fix bug in filterDropDown bundle 2022-07-15 13:39:48 +02:00
Peter Pfeufer
360458f574 Update with latest version 2022-07-12 18:27:28 +02:00
Ariel Rin
def6431052 Version Bump 2.14.0 2022-07-11 14:27:49 +10:00
Ariel Rin
a47bd8d7c7 Version Bump 3.0.0b3 2022-07-11 14:20:53 +10:00
Ariel Rin
22a270aedb Merge branch 'filterdropdown-backwards-compatibility' into 'master'
Add filterdropdown bundle to AA2 to ensure backwards compatibility

See merge request allianceauth/allianceauth!1437
2022-07-11 04:15:25 +00:00
Ariel Rin
54bce4315b Merge branch 'add-filterdropdown-js-to-bundles' into 'v3.x'
Add filterdropdown js to bundles

See merge request allianceauth/allianceauth!1436
2022-07-11 04:14:46 +00:00
Peter Pfeufer
c930f7bbeb Also adds timers.js, eve-time.js and refresh_notifications.js
As these seem to be used in some apps as well
2022-07-09 15:57:43 +02:00
Peter Pfeufer
8c1f06d7b8 Added refresh_notifications.js to bundles
Probably used in template overrides
2022-07-09 15:51:55 +02:00
Peter Pfeufer
815b6fa030 Added eve-time.js to bundles
Probably used in template overrides
2022-07-09 15:50:09 +02:00
Peter Pfeufer
7c05217900 Add timers.js to bundle
It's used in `mumbletemps`
2022-07-09 15:45:25 +02:00
Peter Pfeufer
64ee273953 Add filterdropdown bundle to AA2 to ensure backwards compatibility 2022-07-09 13:43:05 +02:00
Peter Pfeufer
d8c2944966 [FIX] table HTML syntax 2022-07-07 11:55:10 +02:00
Peter Pfeufer
7669c9e55d Add filterDropDown.js to bundles 2022-07-07 11:54:31 +02:00
103 changed files with 3851 additions and 2003 deletions

View File

@@ -213,6 +213,8 @@ build-image:
docker image push --all-tags $CI_REGISTRY_IMAGE/auth docker image push --all-tags $CI_REGISTRY_IMAGE/auth
rules: rules:
- if: $CI_COMMIT_TAG - if: $CI_COMMIT_TAG
when: delayed
start_in: 10 minutes
build-image-dev: build-image-dev:
before_script: [] before_script: []

View File

@@ -11,7 +11,7 @@ build:
apt_packages: apt_packages:
- redis - redis
tools: tools:
python: "3.10" python: "3.8"
# Build documentation in the docs/ directory with Sphinx # Build documentation in the docs/ directory with Sphinx
sphinx: sphinx:

View File

@@ -1,7 +1,7 @@
# This will make sure the app is always imported when # This will make sure the app is always imported when
# Django starts so that shared_task will use this app. # Django starts so that shared_task will use this app.
__version__ = '3.0.0b2' __version__ = '3.0.0'
__title__ = 'Alliance Auth' __title__ = 'Alliance Auth'
__url__ = 'https://gitlab.com/allianceauth/allianceauth' __url__ = 'https://gitlab.com/allianceauth/allianceauth'
NAME = f'{__title__} v{__version__}' NAME = f'{__title__} v{__version__}'

View File

@@ -3,16 +3,17 @@ from urllib.parse import parse_qs
import requests_mock import requests_mock
from django.test import TestCase, override_settings from django.test import override_settings
from allianceauth.analytics.tasks import ANALYTICS_URL from allianceauth.analytics.tasks import ANALYTICS_URL
from allianceauth.eveonline.tasks import update_character from allianceauth.eveonline.tasks import update_character
from allianceauth.tests.auth_utils import AuthUtils from allianceauth.tests.auth_utils import AuthUtils
from allianceauth.utils.testing import NoSocketsTestCase
@override_settings(CELERY_ALWAYS_EAGER=True) @override_settings(CELERY_ALWAYS_EAGER=True)
@requests_mock.mock() @requests_mock.mock()
class TestAnalyticsForViews(TestCase): class TestAnalyticsForViews(NoSocketsTestCase):
@override_settings(ANALYTICS_DISABLED=False) @override_settings(ANALYTICS_DISABLED=False)
def test_should_run_analytics(self, requests_mocker): def test_should_run_analytics(self, requests_mocker):
# given # given
@@ -40,7 +41,7 @@ class TestAnalyticsForViews(TestCase):
@override_settings(CELERY_ALWAYS_EAGER=True) @override_settings(CELERY_ALWAYS_EAGER=True)
@requests_mock.mock() @requests_mock.mock()
class TestAnalyticsForTasks(TestCase): class TestAnalyticsForTasks(NoSocketsTestCase):
@override_settings(ANALYTICS_DISABLED=False) @override_settings(ANALYTICS_DISABLED=False)
@patch("allianceauth.eveonline.models.EveCharacter.objects.update_character") @patch("allianceauth.eveonline.models.EveCharacter.objects.update_character")
def test_should_run_analytics_for_successful_task( def test_should_run_analytics_for_successful_task(

View File

@@ -1,12 +1,22 @@
import requests_mock
from django.test.utils import override_settings
from allianceauth.analytics.tasks import ( from allianceauth.analytics.tasks import (
analytics_event, analytics_event,
send_ga_tracking_celery_event, send_ga_tracking_celery_event,
send_ga_tracking_web_view) send_ga_tracking_web_view)
from django.test.testcases import TestCase from allianceauth.utils.testing import NoSocketsTestCase
class TestAnalyticsTasks(TestCase): GOOGLE_ANALYTICS_DEBUG_URL = 'https://www.google-analytics.com/debug/collect'
def test_analytics_event(self):
@override_settings(CELERY_ALWAYS_EAGER=True, CELERY_EAGER_PROPAGATES_EXCEPTIONS=True)
@requests_mock.Mocker()
class TestAnalyticsTasks(NoSocketsTestCase):
def test_analytics_event(self, requests_mocker):
requests_mocker.register_uri('POST', GOOGLE_ANALYTICS_DEBUG_URL)
analytics_event( analytics_event(
category='allianceauth.analytics', category='allianceauth.analytics',
action='send_tests', action='send_tests',
@@ -14,15 +24,19 @@ class TestAnalyticsTasks(TestCase):
value=1, value=1,
event_type='Stats') event_type='Stats')
def test_send_ga_tracking_web_view_sent(self): def test_send_ga_tracking_web_view_sent(self, requests_mocker):
# This test sends if the event SENDS to google """This test sends if the event SENDS to google.
# Not if it was successful Not if it was successful.
"""
# given
requests_mocker.register_uri('POST', GOOGLE_ANALYTICS_DEBUG_URL)
tracking_id = 'UA-186249766-2' tracking_id = 'UA-186249766-2'
client_id = 'ab33e241fbf042b6aa77c7655a768af7' client_id = 'ab33e241fbf042b6aa77c7655a768af7'
page = '/index/' page = '/index/'
title = 'Hello World' title = 'Hello World'
locale = 'en' locale = 'en'
useragent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36" useragent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36"
# when
response = send_ga_tracking_web_view( response = send_ga_tracking_web_view(
tracking_id, tracking_id,
client_id, client_id,
@@ -30,15 +44,23 @@ class TestAnalyticsTasks(TestCase):
title, title,
locale, locale,
useragent) useragent)
# then
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_send_ga_tracking_web_view_success(self): def test_send_ga_tracking_web_view_success(self, requests_mocker):
# given
requests_mocker.register_uri(
'POST',
GOOGLE_ANALYTICS_DEBUG_URL,
json={"hitParsingResult":[{'valid': True}]}
)
tracking_id = 'UA-186249766-2' tracking_id = 'UA-186249766-2'
client_id = 'ab33e241fbf042b6aa77c7655a768af7' client_id = 'ab33e241fbf042b6aa77c7655a768af7'
page = '/index/' page = '/index/'
title = 'Hello World' title = 'Hello World'
locale = 'en' locale = 'en'
useragent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36" useragent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36"
# when
json_response = send_ga_tracking_web_view( json_response = send_ga_tracking_web_view(
tracking_id, tracking_id,
client_id, client_id,
@@ -46,15 +68,42 @@ class TestAnalyticsTasks(TestCase):
title, title,
locale, locale,
useragent).json() useragent).json()
# then
self.assertTrue(json_response["hitParsingResult"][0]["valid"]) self.assertTrue(json_response["hitParsingResult"][0]["valid"])
def test_send_ga_tracking_web_view_invalid_token(self): def test_send_ga_tracking_web_view_invalid_token(self, requests_mocker):
# given
requests_mocker.register_uri(
'POST',
GOOGLE_ANALYTICS_DEBUG_URL,
json={
"hitParsingResult":[
{
'valid': False,
'parserMessage': [
{
'messageType': 'INFO',
'description': 'IP Address from this hit was anonymized to 1.132.110.0.',
'messageCode': 'VALUE_MODIFIED'
},
{
'messageType': 'ERROR',
'description': "The value provided for parameter 'tid' is invalid. Please see http://goo.gl/a8d4RP#tid for details.",
'messageCode': 'VALUE_INVALID', 'parameter': 'tid'
}
],
'hit': '/debug/collect?v=1&tid=UA-IntentionallyBadTrackingID-2&cid=ab33e241fbf042b6aa77c7655a768af7&t=pageview&dp=/index/&dt=Hello World&ul=en&ua=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36&aip=1&an=allianceauth&av=2.9.0a2'
}
]
}
)
tracking_id = 'UA-IntentionallyBadTrackingID-2' tracking_id = 'UA-IntentionallyBadTrackingID-2'
client_id = 'ab33e241fbf042b6aa77c7655a768af7' client_id = 'ab33e241fbf042b6aa77c7655a768af7'
page = '/index/' page = '/index/'
title = 'Hello World' title = 'Hello World'
locale = 'en' locale = 'en'
useragent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36" useragent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36"
# when
json_response = send_ga_tracking_web_view( json_response = send_ga_tracking_web_view(
tracking_id, tracking_id,
client_id, client_id,
@@ -62,18 +111,25 @@ class TestAnalyticsTasks(TestCase):
title, title,
locale, locale,
useragent).json() useragent).json()
# then
self.assertFalse(json_response["hitParsingResult"][0]["valid"]) self.assertFalse(json_response["hitParsingResult"][0]["valid"])
self.assertEqual(json_response["hitParsingResult"][0]["parserMessage"][1]["description"], "The value provided for parameter 'tid' is invalid. Please see http://goo.gl/a8d4RP#tid for details.") self.assertEqual(
json_response["hitParsingResult"][0]["parserMessage"][1]["description"],
"The value provided for parameter 'tid' is invalid. Please see http://goo.gl/a8d4RP#tid for details."
)
# [{'valid': False, 'parserMessage': [{'messageType': 'INFO', 'description': 'IP Address from this hit was anonymized to 1.132.110.0.', 'messageCode': 'VALUE_MODIFIED'}, {'messageType': 'ERROR', 'description': "The value provided for parameter 'tid' is invalid. Please see http://goo.gl/a8d4RP#tid for details.", 'messageCode': 'VALUE_INVALID', 'parameter': 'tid'}], 'hit': '/debug/collect?v=1&tid=UA-IntentionallyBadTrackingID-2&cid=ab33e241fbf042b6aa77c7655a768af7&t=pageview&dp=/index/&dt=Hello World&ul=en&ua=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36&aip=1&an=allianceauth&av=2.9.0a2'}] # [{'valid': False, 'parserMessage': [{'messageType': 'INFO', 'description': 'IP Address from this hit was anonymized to 1.132.110.0.', 'messageCode': 'VALUE_MODIFIED'}, {'messageType': 'ERROR', 'description': "The value provided for parameter 'tid' is invalid. Please see http://goo.gl/a8d4RP#tid for details.", 'messageCode': 'VALUE_INVALID', 'parameter': 'tid'}], 'hit': '/debug/collect?v=1&tid=UA-IntentionallyBadTrackingID-2&cid=ab33e241fbf042b6aa77c7655a768af7&t=pageview&dp=/index/&dt=Hello World&ul=en&ua=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36&aip=1&an=allianceauth&av=2.9.0a2'}]
def test_send_ga_tracking_celery_event_sent(self): def test_send_ga_tracking_celery_event_sent(self, requests_mocker):
# given
requests_mocker.register_uri('POST', GOOGLE_ANALYTICS_DEBUG_URL)
tracking_id = 'UA-186249766-2' tracking_id = 'UA-186249766-2'
client_id = 'ab33e241fbf042b6aa77c7655a768af7' client_id = 'ab33e241fbf042b6aa77c7655a768af7'
category = 'test' category = 'test'
action = 'test' action = 'test'
label = 'test' label = 'test'
value = '1' value = '1'
# when
response = send_ga_tracking_celery_event( response = send_ga_tracking_celery_event(
tracking_id, tracking_id,
client_id, client_id,
@@ -81,15 +137,23 @@ class TestAnalyticsTasks(TestCase):
action, action,
label, label,
value) value)
# then
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_send_ga_tracking_celery_event_success(self): def test_send_ga_tracking_celery_event_success(self, requests_mocker):
# given
requests_mocker.register_uri(
'POST',
GOOGLE_ANALYTICS_DEBUG_URL,
json={"hitParsingResult":[{'valid': True}]}
)
tracking_id = 'UA-186249766-2' tracking_id = 'UA-186249766-2'
client_id = 'ab33e241fbf042b6aa77c7655a768af7' client_id = 'ab33e241fbf042b6aa77c7655a768af7'
category = 'test' category = 'test'
action = 'test' action = 'test'
label = 'test' label = 'test'
value = '1' value = '1'
# when
json_response = send_ga_tracking_celery_event( json_response = send_ga_tracking_celery_event(
tracking_id, tracking_id,
client_id, client_id,
@@ -97,15 +161,42 @@ class TestAnalyticsTasks(TestCase):
action, action,
label, label,
value).json() value).json()
# then
self.assertTrue(json_response["hitParsingResult"][0]["valid"]) self.assertTrue(json_response["hitParsingResult"][0]["valid"])
def test_send_ga_tracking_celery_event_invalid_token(self): def test_send_ga_tracking_celery_event_invalid_token(self, requests_mocker):
# given
requests_mocker.register_uri(
'POST',
GOOGLE_ANALYTICS_DEBUG_URL,
json={
"hitParsingResult":[
{
'valid': False,
'parserMessage': [
{
'messageType': 'INFO',
'description': 'IP Address from this hit was anonymized to 1.132.110.0.',
'messageCode': 'VALUE_MODIFIED'
},
{
'messageType': 'ERROR',
'description': "The value provided for parameter 'tid' is invalid. Please see http://goo.gl/a8d4RP#tid for details.",
'messageCode': 'VALUE_INVALID', 'parameter': 'tid'
}
],
'hit': '/debug/collect?v=1&tid=UA-IntentionallyBadTrackingID-2&cid=ab33e241fbf042b6aa77c7655a768af7&t=pageview&dp=/index/&dt=Hello World&ul=en&ua=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36&aip=1&an=allianceauth&av=2.9.0a2'
}
]
}
)
tracking_id = 'UA-IntentionallyBadTrackingID-2' tracking_id = 'UA-IntentionallyBadTrackingID-2'
client_id = 'ab33e241fbf042b6aa77c7655a768af7' client_id = 'ab33e241fbf042b6aa77c7655a768af7'
category = 'test' category = 'test'
action = 'test' action = 'test'
label = 'test' label = 'test'
value = '1' value = '1'
# when
json_response = send_ga_tracking_celery_event( json_response = send_ga_tracking_celery_event(
tracking_id, tracking_id,
client_id, client_id,
@@ -113,7 +204,9 @@ class TestAnalyticsTasks(TestCase):
action, action,
label, label,
value).json() value).json()
# then
self.assertFalse(json_response["hitParsingResult"][0]["valid"]) self.assertFalse(json_response["hitParsingResult"][0]["valid"])
self.assertEqual(json_response["hitParsingResult"][0]["parserMessage"][1]["description"], "The value provided for parameter 'tid' is invalid. Please see http://goo.gl/a8d4RP#tid for details.") self.assertEqual(
json_response["hitParsingResult"][0]["parserMessage"][1]["description"],
# [{'valid': False, 'parserMessage': [{'messageType': 'INFO', 'description': 'IP Address from this hit was anonymized to 1.132.110.0.', 'messageCode': 'VALUE_MODIFIED'}, {'messageType': 'ERROR', 'description': "The value provided for parameter 'tid' is invalid. Please see http://goo.gl/a8d4RP#tid for details.", 'messageCode': 'VALUE_INVALID', 'parameter': 'tid'}], 'hit': '/debug/collect?v=1&tid=UA-IntentionallyBadTrackingID-2&cid=ab33e241fbf042b6aa77c7655a768af7&t=pageview&dp=/index/&dt=Hello World&ul=en&ua=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36&aip=1&an=allianceauth&av=2.9.0a2'}] "The value provided for parameter 'tid' is invalid. Please see http://goo.gl/a8d4RP#tid for details."
)

View File

@@ -27,7 +27,7 @@
<table class="table"> <table class="table">
<tr> <tr>
<td class="text-center"> <td class="text-center">
<img class="ra-avatar" src="{{ main.portrait_url_128 }}"> <img class="ra-avatar" src="{{ main.portrait_url_128 }}" alt="{{ main.character_name }}">
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -39,7 +39,7 @@
<table class="table"> <table class="table">
<tr> <tr>
<td class="text-center"> <td class="text-center">
<img class="ra-avatar" src="{{ main.corporation_logo_url_128 }}"> <img class="ra-avatar" src="{{ main.corporation_logo_url_128 }}" alt="{{ main.corporation_name }}">
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -52,7 +52,7 @@
<table class="table"> <table class="table">
<tr> <tr>
<td class="text-center"> <td class="text-center">
<img class="ra-avatar" src="{{ main.alliance_logo_url_128 }}"> <img class="ra-avatar" src="{{ main.alliance_logo_url_128 }}" alt="{{ main.alliance_name }}">
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -63,7 +63,7 @@
<table class="table"> <table class="table">
<tr> <tr>
<td class="text-center"> <td class="text-center">
<img class="ra-avatar" src="{{ main.faction_logo_url_128 }}"> <img class="ra-avatar" src="{{ main.faction_logo_url_128 }}" alt="{{ main.faction_name }}">
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -75,13 +75,13 @@
</div> </div>
<div class="table visible-xs-block"> <div class="table visible-xs-block">
<p> <p>
<img class="ra-avatar" src="{{ main.portrait_url_64 }}"> <img class="ra-avatar" src="{{ main.portrait_url_64 }}" alt="{{ main.corporation_name }}">
<img class="ra-avatar" src="{{ main.corporation_logo_url_64 }}"> <img class="ra-avatar" src="{{ main.corporation_logo_url_64 }}" alt="{{ main.corporation_name }}">
{% if main.alliance_id %} {% if main.alliance_id %}
<img class="ra-avatar" src="{{ main.alliance_logo_url_64 }}"> <img class="ra-avatar" src="{{ main.alliance_logo_url_64 }}" alt="{{ main.alliance_name }}">
{% endif %} {% endif %}
{% if main.faction_id %} {% if main.faction_id %}
<img class="ra-avatar" src="{{ main.faction_logo_url_64 }}"> <img class="ra-avatar" src="{{ main.faction_logo_url_64 }}" alt="{{ main.faction_name }}">
{% endif %} {% endif %}
</p> </p>
<p> <p>
@@ -121,7 +121,7 @@
<h3 class="panel-title">{% translate "Group Memberships" %}</h3> <h3 class="panel-title">{% translate "Group Memberships" %}</h3>
</div> </div>
<div class="panel-body"> <div class="panel-body">
<div style="height: 240px;overflow:-moz-scrollbars-vertical;overflow-y:auto;"> <div style="height: 240px;overflow-y:auto;">
<table class="table table-aa"> <table class="table table-aa">
{% for group in groups %} {% for group in groups %}
<tr> <tr>
@@ -154,7 +154,8 @@
<tbody> <tbody>
{% for char in characters %} {% for char in characters %}
<tr> <tr>
<td class="text-center"><img class="ra-avatar img-circle" src="{{ char.portrait_url_32 }}"> <td class="text-center">
<img class="ra-avatar img-circle" src="{{ char.portrait_url_32 }}" alt="{{ char.character_name }}">
</td> </td>
<td class="text-center">{{ char.character_name }}</td> <td class="text-center">{{ char.character_name }}</td>
<td class="text-center">{{ char.corporation_name }}</td> <td class="text-center">{{ char.corporation_name }}</td>
@@ -168,7 +169,7 @@
{% for char in characters %} {% for char in characters %}
<tr> <tr>
<td class="text-center" style="vertical-align: middle"> <td class="text-center" style="vertical-align: middle">
<img class="ra-avatar img-circle" src="{{ char.portrait_url_32 }}"> <img class="ra-avatar img-circle" src="{{ char.portrait_url_32 }}" alt="{{ char.character_name }}">
</td> </td>
<td class="text-center" style="vertical-align: middle; width: 100%"> <td class="text-center" style="vertical-align: middle; width: 100%">
<strong>{{ char.character_name }}</strong><br> <strong>{{ char.character_name }}</strong><br>

View File

@@ -47,7 +47,7 @@
</style> </style>
</head> </head>
<body> <body>
<div class="container" style="margin-top:150px"> <div class="container" style="margin-top:150px;">
{% block content %} {% block content %}
{% endblock %} {% endblock %}
</div> </div>

View File

@@ -7,6 +7,6 @@
{% block middle_box_content %} {% block middle_box_content %}
<a href="{% url 'auth_sso_login' %}{% if request.GET.next %}?next={{request.GET.next}}{%endif%}"> <a href="{% url 'auth_sso_login' %}{% if request.GET.next %}?next={{request.GET.next}}{%endif%}">
<img class="img-responsive center-block" src="{% static 'allianceauth/authentication/img/sso/EVE_SSO_Login_Buttons_Large_Black.png' %}"> <img class="img-responsive center-block" src="{% static 'allianceauth/authentication/img/sso/EVE_SSO_Login_Buttons_Large_Black.png' %}" alt="{% translate 'Login with Eve SSO' %}">
</a> </a>
{% endblock %} {% endblock %}

View File

@@ -8,11 +8,11 @@
<table class="table"> <table class="table">
<tr> <tr>
<td class="text-center col-lg-6{% if corpstats.corp.alliance %}{% else %}col-lg-offset-3{% endif %}"> <td class="text-center col-lg-6{% if corpstats.corp.alliance %}{% else %}col-lg-offset-3{% endif %}">
<img class="ra-avatar" src="{{ corpstats.corp.logo_url_64 }}"> <img class="ra-avatar" src="{{ corpstats.corp.logo_url_64 }}" alt="{{ corpstats.corp.corporation_name }}">
</td> </td>
{% if corpstats.corp.alliance %} {% if corpstats.corp.alliance %}
<td class="text-center col-lg-6"> <td class="text-center col-lg-6">
<img class="ra-avatar" src="{{ corpstats.corp.alliance.logo_url_64 }}"> <img class="ra-avatar" src="{{ corpstats.corp.alliance.logo_url_64 }}" alt="{{ corpstats.corp.alliance.alliance_name }}">
</td> </td>
{% endif %} {% endif %}
</tr> </tr>
@@ -59,7 +59,7 @@
<tr> <tr>
<td class="text-center" style="vertical-align:middle"> <td class="text-center" style="vertical-align:middle">
<div class="thumbnail" style="border: 0 none; box-shadow: none; background: transparent;"> <div class="thumbnail" style="border: 0 none; box-shadow: none; background: transparent;">
<img src="{{ main.main.portrait_url_64 }}" class="img-circle"> <img src="{{ main.main.portrait_url_64 }}" class="img-circle" alt="{{ main.main }}">
<div class="caption text-center"> <div class="caption text-center">
{{ main.main }} {{ main.main }}
</div> </div>
@@ -80,7 +80,7 @@
<tr> <tr>
<td class="text-center" style="width:5%"> <td class="text-center" style="width:5%">
<div class="thumbnail" style="border: 0 none; box-shadow: none; background: transparent;"> <div class="thumbnail" style="border: 0 none; box-shadow: none; background: transparent;">
<img src="{{ alt.portrait_url_32 }}" class="img-circle"> <img src="{{ alt.portrait_url_32 }}" class="img-circle" alt="{{ alt.character_name }}">
</div> </div>
</td> </td>
<td class="text-center" style="width:30%">{{ alt.character_name }}</td> <td class="text-center" style="width:30%">{{ alt.character_name }}</td>
@@ -119,7 +119,7 @@
<tbody> <tbody>
{% for member in members %} {% for member in members %}
<tr> <tr>
<td><img src="{{ member.portrait_url }}" class="img-circle"></td> <td><img src="{{ member.portrait_url }}" class="img-circle" alt="{{ member }}"></td>
<td class="text-center">{{ member }}</td> <td class="text-center">{{ member }}</td>
<td class="text-center"> <td class="text-center">
<a href="https://zkillboard.com/character/{{ member.character_id }}/" class="label label-danger" target="_blank">{% translate "Killboard" %}</a> <a href="https://zkillboard.com/character/{{ member.character_id }}/" class="label label-danger" target="_blank">{% translate "Killboard" %}</a>
@@ -131,7 +131,7 @@
{% endfor %} {% endfor %}
{% for member in unregistered %} {% for member in unregistered %}
<tr class="danger"> <tr class="danger">
<td><img src="{{ member.portrait_url }}" class="img-circle"></td> <td><img src="{{ member.portrait_url }}" class="img-circle" alt="{{ member.character_name }}"></td>
<td class="text-center">{{ member.character_name }}</td> <td class="text-center">{{ member.character_name }}</td>
<td class="text-center"> <td class="text-center">
<a href="https://zkillboard.com/character/{{ member.character_id }}/" class="label label-danger" target="_blank">{% translate "Killboard" %}</a> <a href="https://zkillboard.com/character/{{ member.character_id }}/" class="label label-danger" target="_blank">{% translate "Killboard" %}</a>
@@ -160,7 +160,7 @@
<tbody> <tbody>
{% for member in unregistered %} {% for member in unregistered %}
<tr class="danger"> <tr class="danger">
<td><img src="{{ member.portrait_url }}" class="img-circle"></td> <td><img src="{{ member.portrait_url }}" class="img-circle" alt="{{ member.character_name }}"></td>
<td class="text-center">{{ member.character_name }}</td> <td class="text-center">{{ member.character_name }}</td>
<td class="text-center"> <td class="text-center">
<a href="https://zkillboard.com/character/{{ member.character_id }}/" class="label label-danger" target="_blank"> <a href="https://zkillboard.com/character/{{ member.character_id }}/" class="label label-danger" target="_blank">

View File

@@ -21,7 +21,7 @@
<tbody> <tbody>
{% for result in results %} {% for result in results %}
<tr {% if not result.1.registered %}class="danger"{% endif %}> <tr {% if not result.1.registered %}class="danger"{% endif %}>
<td class="text-center"><img src="{{ result.1.portrait_url }}" class="img-circle"></td> <td class="text-center"><img src="{{ result.1.portrait_url }}" class="img-circle" alt="{{ result.1.character_name }}"></td>
<td class="text-center">{{ result.1.character_name }}</td> <td class="text-center">{{ result.1.character_name }}</td>
<td class="text-center">{{ result.0.corp.corporation_name }}</td> <td class="text-center">{{ result.0.corp.corporation_name }}</td>
<td class="text-center"><a href="https://zkillboard.com/character/{{ result.1.character_id }}/" class="label label-danger" target="_blank">{% translate "Killboard" %}</a></td> <td class="text-center"><a href="https://zkillboard.com/character/{{ result.1.character_id }}/" class="label label-danger" target="_blank">{% translate "Killboard" %}</a></td>

View File

@@ -12,7 +12,7 @@
<div class="panel-heading">{{ character_name }}</div> <div class="panel-heading">{{ character_name }}</div>
<div class="panel-body"> <div class="panel-body">
<div class="col-lg-2 col-sm-2"> <div class="col-lg-2 col-sm-2">
<img class="ra-avatar img-responsive" src="{{ character_portrait_url }}"> <img class="ra-avatar img-responsive" src="{{ character_portrait_url }}" alt="{{ character_name }}">
</div> </div>
<div class="col-lg-10 col-sm-2"> <div class="col-lg-10 col-sm-2">
<div class="alert alert-danger" role="alert">{% translate "Character not registered!" %}</div> <div class="alert alert-danger" role="alert">{% translate "Character not registered!" %}</div>

View File

@@ -1,5 +1,4 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load bootstrap %}
{% load i18n %} {% load i18n %}
{% block page_title %}{% translate "Fatlink view" %}{% endblock page_title %} {% block page_title %}{% translate "Fatlink view" %}{% endblock page_title %}

View File

@@ -1,5 +1,4 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load bootstrap %}
{% load i18n %} {% load i18n %}
{% block page_title %}{% translate "Personal fatlink statistics" %}{% endblock page_title %} {% block page_title %}{% translate "Personal fatlink statistics" %}{% endblock page_title %}

View File

@@ -1,5 +1,4 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load bootstrap %}
{% load i18n %} {% load i18n %}
{% block page_title %}{% translate "Personal fatlink statistics" %}{% endblock page_title %} {% block page_title %}{% translate "Personal fatlink statistics" %}{% endblock page_title %}

View File

@@ -1,5 +1,4 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load bootstrap %}
{% load i18n %} {% load i18n %}
{% block page_title %}{% translate "Fatlink Corp Statistics" %}{% endblock page_title %} {% block page_title %}{% translate "Fatlink Corp Statistics" %}{% endblock page_title %}
@@ -28,7 +27,7 @@
{% for memberStat in fatStats %} {% for memberStat in fatStats %}
<tr> <tr>
<td> <td>
<img src="{{ memberStat.mainchar.portrait_url_32 }}" class="ra-avatar img-responsive"> <img src="{{ memberStat.mainchar.portrait_url_32 }}" class="ra-avatar img-responsive" alt="{{ memberStat.mainchar.character_name }}">
</td> </td>
<td class="text-center">{{ memberStat.mainchar.character_name }}</td> <td class="text-center">{{ memberStat.mainchar.character_name }}</td>
<td class="text-center">{{ memberStat.n_chars }}</td> <td class="text-center">{{ memberStat.n_chars }}</td>

View File

@@ -1,5 +1,4 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load bootstrap %}
{% load i18n %} {% load i18n %}
{% block page_title %}{% translate "Fatlink statistics" %}{% endblock page_title %} {% block page_title %}{% translate "Fatlink statistics" %}{% endblock page_title %}
@@ -29,9 +28,9 @@
{% for corpStat in fatStats %} {% for corpStat in fatStats %}
<tr> <tr>
<td> <td>
<img src="{{ corpStat.corp.logo_url_32 }}" class="ra-avatar img-responsive"> <img src="{{ corpStat.corp.logo_url_32 }}" class="ra-avatar img-responsive" alt="{{ corpStat.corp.corporation_name }}">
</td> </td>
<td class="text-center"><a href="{% url 'fatlink:statistics_corp' corpStat.corp.corporation_id %}">[{{ corpStat.corp.corporation_ticker }}]</td> <td class="text-center"><a href="{% url 'fatlink:statistics_corp' corpStat.corp.corporation_id %}">[{{ corpStat.corp.corporation_ticker }}]</a></td>
<td class="text-center">{{ corpStat.corp.corporation_name }}</td> <td class="text-center">{{ corpStat.corp.corporation_name }}</td>
<td class="text-center">{{ corpStat.corp.member_count }}</td> <td class="text-center">{{ corpStat.corp.member_count }}</td>
<td class="text-center">{{ corpStat.n_fats }}</td> <td class="text-center">{{ corpStat.n_fats }}</td>

View File

@@ -1,5 +1,4 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load bootstrap %}
{% load i18n %} {% load i18n %}
{% block page_title %}{% translate "Fatlink view" %}{% endblock page_title %} {% block page_title %}{% translate "Fatlink view" %}{% endblock page_title %}

View File

@@ -25,13 +25,15 @@
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped" id="log-entries"> <table class="table table-striped" id="log-entries">
<thead> <thead>
<th scope="col">{% translate "Date/Time" %}</th> <tr>
<th scope="col">{% translate "Requestor" %}</th> <th scope="col">{% translate "Date/Time" %}</th>
<th scope="col">{% translate "Character" %}</th> <th scope="col">{% translate "Requestor" %}</th>
<th scope="col">{% translate "Corporation" %}</th> <th scope="col">{% translate "Character" %}</th>
<th scope="col">{% translate "Type" %}</th> <th scope="col">{% translate "Corporation" %}</th>
<th scope="col">{% translate "Action" %}</th> <th scope="col">{% translate "Type" %}</th>
<th scope="col">{% translate "Actor" %}</th> <th scope="col">{% translate "Action" %}</th>
<th scope="col">{% translate "Actor" %}</th>
</tr>
</thead> </thead>
<tbody> <tbody>
@@ -74,7 +76,7 @@
{% block extra_javascript %} {% block extra_javascript %}
{% include 'bundles/datatables-js.html' %} {% include 'bundles/datatables-js.html' %}
{% include 'bundles/moment-js.html' with locale=True %} {% include 'bundles/moment-js.html' with locale=True %}
<script type="application/javascript" src="{% static 'allianceauth/js/filterDropDown/filterDropDown.min.js' %}"></script> {% include 'bundles/filterdropdown-js.html' %}
{% endblock %} {% endblock %}
{% block extra_css %} {% block extra_css %}

View File

@@ -36,7 +36,7 @@
{% for member in members %} {% for member in members %}
<tr> <tr>
<td> <td>
<img src="{{ member.main_char|character_portrait_url:32 }}" class="img-circle" style="margin-right: 1rem;"> <img src="{{ member.main_char|character_portrait_url:32 }}" class="img-circle" style="margin-right: 1rem;" alt="{{ member.main_char.character_name }}">
{% if member.main_char %} {% if member.main_char %}
<a href="{{ member.main_char|evewho_character_url }}" target="_blank"> <a href="{{ member.main_char|evewho_character_url }}" target="_blank">
{{ member.main_char.character_name }} {{ member.main_char.character_name }}

View File

@@ -63,7 +63,7 @@
{% for acceptrequest in acceptrequests %} {% for acceptrequest in acceptrequests %}
<tr> <tr>
<td> <td>
<img src="{{ acceptrequest.main_char|character_portrait_url:32 }}" class="img-circle" style="margin-right: 1rem;"> <img src="{{ acceptrequest.main_char|character_portrait_url:32 }}" class="img-circle" style="margin-right: 1rem;" alt="{{ acceptrequest.main_char.character_name }}">
{% if acceptrequest.main_char %} {% if acceptrequest.main_char %}
<a href="{{ acceptrequest.main_char|evewho_character_url }}" target="_blank"> <a href="{{ acceptrequest.main_char|evewho_character_url }}" target="_blank">
{{ acceptrequest.main_char.character_name }} {{ acceptrequest.main_char.character_name }}
@@ -120,7 +120,7 @@
{% for leaverequest in leaverequests %} {% for leaverequest in leaverequests %}
<tr> <tr>
<td> <td>
<img src="{{ leaverequest.main_char|character_portrait_url:32 }}" class="img-circle" style="margin-right: 1rem;"> <img src="{{ leaverequest.main_char|character_portrait_url:32 }}" class="img-circle" style="margin-right: 1rem;" alt="{{ leaverequest.main_char.character_name }}">
{% if leaverequest.main_char %} {% if leaverequest.main_char %}
<a href="{{ leaverequest.main_char|evewho_character_url }}" target="_blank"> <a href="{{ leaverequest.main_char|evewho_character_url }}" target="_blank">
{{ leaverequest.main_char.character_name }} {{ leaverequest.main_char.character_name }}

View File

@@ -15,11 +15,11 @@
<label class="control-label" for="id_{{ question.pk }}">{{ question.title }}</label> <label class="control-label" for="id_{{ question.pk }}">{{ question.title }}</label>
<div class=" "> <div class=" ">
{% if question.help_text %} {% if question.help_text %}
<div cass="text-center">{{ question.help_text }}</div> <div class="text-center">{{ question.help_text }}</div>
{% endif %} {% endif %}
{% for choice in question.choices.all %} {% for choice in question.choices.all %}
<input type={% if question.multi_select == False %}"radio"{% else %}"checkbox"{% endif %} name="{{ question.pk }}" id="id_{{ question.pk }}" value="{{ choice.choice_text }}"> <input type={% if question.multi_select == False %}"radio"{% else %}"checkbox"{% endif %} name="{{ question.pk }}" id="id_{{ question.pk }}_choice_{{ forloop.counter }}" value="{{ choice.choice_text }}">
<label for="choice{{ forloop.counter }}">{{ choice.choice_text }}</label><br> <label for="id_{{ question.pk }}_choice_{{ forloop.counter }}">{{ choice.choice_text }}</label><br>
{% empty %} {% empty %}
<textarea class="form-control" cols="30" id="id_{{ question.pk }}" name="{{ question.pk }}" rows="4"></textarea> <textarea class="form-control" cols="30" id="id_{{ question.pk }}" name="{{ question.pk }}" rows="4"></textarea>
{% endfor %} {% endfor %}

View File

@@ -177,7 +177,7 @@
<h4 class="modal-title" id="myModalLabel">{% translate "Application Search" %}</h4> <h4 class="modal-title" id="myModalLabel">{% translate "Application Search" %}</h4>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form class="form-signin" role="form" action={% url 'hrapplications:search' %} method="POST"> <form class="form-signin" role="form" action="{% url 'hrapplications:search' %}" method="POST">
{% csrf_token %} {% csrf_token %}
{{ search_form|bootstrap }} {{ search_form|bootstrap }}
<br> <br>

View File

@@ -63,7 +63,7 @@
<h4 class="modal-title" id="myModalLabel">{% translate "Application Search" %}</h4> <h4 class="modal-title" id="myModalLabel">{% translate "Application Search" %}</h4>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form class="form-signin" role="form" action={% url 'hrapplications:search' %} method="POST"> <form class="form-signin" role="form" action="{% url 'hrapplications:search' %}" method="POST">
{% csrf_token %} {% csrf_token %}
{{ search_form|bootstrap }} {{ search_form|bootstrap }}
<br> <br>

View File

@@ -48,7 +48,7 @@
{% for char in app.characters %} {% for char in app.characters %}
<tr> <tr>
<td class="text-center"> <td class="text-center">
<img class="ra-avatar img-responsive img-circle" src="{{ char.portrait_url_32 }}"> <img class="ra-avatar img-responsive img-circle" src="{{ char.portrait_url_32 }}" alt="{{ char.character_name }}">
</td> </td>
<td class="text-center">{{ char.character_name }}</td> <td class="text-center">{{ char.character_name }}</td>
<td class="text-center">{{ char.corporation_name }}</td> <td class="text-center">{{ char.corporation_name }}</td>

View File

@@ -219,7 +219,7 @@ def hr_application_search(request):
Q(user__character_ownerships__character__corporation_name__icontains=searchstring) | Q(user__character_ownerships__character__corporation_name__icontains=searchstring) |
Q(user__character_ownerships__character__alliance_name__icontains=searchstring) | Q(user__character_ownerships__character__alliance_name__icontains=searchstring) |
Q(user__username__icontains=searchstring) Q(user__username__icontains=searchstring)
) ).distinct()
context = {'applications': applications, 'search_form': HRApplicationSearchForm()} context = {'applications': applications, 'search_form': HRApplicationSearchForm()}

View File

@@ -8,15 +8,15 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading clearfix">
<ul class="nav nav-pills"> <ul class="nav nav-pills navbar-left">
<li class="active"><a data-toggle="tab" href="#unread">{% translate "Unread" %}<b>({{ unread|length }})</b></a></li> <li class="active"><a data-toggle="tab" href="#unread">{% translate "Unread" %}<b>({{ unread|length }})</b></a></li>
<li><a data-toggle="tab" href="#read">{% translate "Read" %} <b>({{ read|length }})</b></a></li> <li><a data-toggle="tab" href="#read">{% translate "Read" %} <b>({{ read|length }})</b></a></li>
<div class="pull-right">
<a href="{% url 'notifications:mark_all_read' %}" class="btn btn-warning">{% translate "Mark All Read" %}</a>
<a href="{% url 'notifications:delete_all_read' %}" class="btn btn-danger">{% translate "Delete All Read" %}</a>
</div>
</ul> </ul>
<div class="nav navbar-nav navbar-right" style="margin-right: 0;">
<a href="{% url 'notifications:mark_all_read' %}" class="btn btn-warning">{% translate "Mark All Read" %}</a>
<a href="{% url 'notifications:delete_all_read' %}" class="btn btn-danger">{% translate "Delete All Read" %}</a>
</div>
</div> </div>
<div class="panel-body"> <div class="panel-body">

View File

@@ -1,5 +1,4 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load static %}
{% load i18n %} {% load i18n %}
{% get_current_language as LANGUAGE_CODE %} {% get_current_language as LANGUAGE_CODE %}
@@ -40,9 +39,9 @@
</div> </div>
{% include 'bundles/moment-js.html' with locale=True %} {% include 'bundles/moment-js.html' with locale=True %}
<script src="{% static 'allianceauth/js/timers.js' %}"></script> {% include 'bundles/timers-js.html' %}
<script type="application/javascript"> <script>
// Data // Data
let timers = [ let timers = [
{% for op in optimer %} {% for op in optimer %}

View File

@@ -1,6 +1,4 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load bootstrap %}
{% load static %}
{% load i18n %} {% load i18n %}
{% block page_title %}{{ permission.permission.codename }} - {% translate "Permissions Audit" %}{% endblock page_title %} {% block page_title %}{{ permission.permission.codename }} - {% translate "Permissions Audit" %}{% endblock page_title %}
@@ -47,7 +45,7 @@
{% block extra_javascript %} {% block extra_javascript %}
{% include 'bundles/datatables-js.html' %} {% include 'bundles/datatables-js.html' %}
<script type="application/javascript" src="{% static 'allianceauth/js/filterDropDown/filterDropDown.min.js' %}"></script> {% include 'bundles/filterdropdown-js.html' %}
{% endblock %} {% endblock %}
{% block extra_css %} {% block extra_css %}

View File

@@ -5,7 +5,7 @@
{{ type }}: {{ name }} {{ type }}: {{ name }}
</td> </td>
<td class="text-right"> <td class="text-right">
<img src="{{ user.profile.main_character|character_portrait_url:32 }}" class="img-circle"> <img src="{{ user.profile.main_character|character_portrait_url:32 }}" class="img-circle" alt="{{ user.profile.main_character.character_name }}">
</td> </td>
<td> <td>
<strong>{{ user }}<br></strong> <strong>{{ user }}<br></strong>

View File

@@ -1,6 +1,4 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load bootstrap %}
{% load static %}
{% load i18n %} {% load i18n %}
{% block page_title %}{% translate "Permissions Overview" %}{% endblock page_title %} {% block page_title %}{% translate "Permissions Overview" %}{% endblock page_title %}
@@ -80,7 +78,7 @@
{% block extra_javascript %} {% block extra_javascript %}
{% include 'bundles/datatables-js.html' %} {% include 'bundles/datatables-js.html' %}
<script type="application/javascript" src="{% static 'allianceauth/js/filterDropDown/filterDropDown.min.js' %}"></script> {% include 'bundles/filterdropdown-js.html' %}
{% endblock %} {% endblock %}
{% block extra_css %} {% block extra_css %}

View File

@@ -3,11 +3,11 @@ from django.contrib import admin
from allianceauth import hooks from allianceauth import hooks
from allianceauth.authentication.admin import ( from allianceauth.authentication.admin import (
MainAllianceFilter,
MainCorporationsFilter,
user_main_organization,
user_profile_pic, user_profile_pic,
user_username, user_username,
user_main_organization,
MainCorporationsFilter,
MainAllianceFilter
) )
from .models import NameFormatConfig from .models import NameFormatConfig
@@ -36,19 +36,18 @@ class ServicesUserAdmin(admin.ModelAdmin):
MainAllianceFilter, MainAllianceFilter,
'user__date_joined', 'user__date_joined',
) )
list_select_related = (
'user', 'user__profile__main_character', 'user__profile__state'
)
@admin.display(ordering='user__profile__state__name')
def _state(self, obj): def _state(self, obj):
return obj.user.profile.state.name return obj.user.profile.state.name
_state.short_description = 'state' @admin.display(ordering='user__date_joined')
_state.admin_order_field = 'user__profile__state__name'
def _date_joined(self, obj): def _date_joined(self, obj):
return obj.user.date_joined return obj.user.date_joined
_date_joined.short_description = 'date joined'
_date_joined.admin_order_field = 'user__date_joined'
class NameFormatConfigForm(forms.ModelForm): class NameFormatConfigForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -62,6 +61,7 @@ class NameFormatConfigForm(forms.ModelForm):
self.fields['service_name'] = forms.ChoiceField(choices=SERVICE_CHOICES) self.fields['service_name'] = forms.ChoiceField(choices=SERVICE_CHOICES)
@admin.register(NameFormatConfig)
class NameFormatConfigAdmin(admin.ModelAdmin): class NameFormatConfigAdmin(admin.ModelAdmin):
form = NameFormatConfigForm form = NameFormatConfigForm
list_display = ('service_name', 'get_state_display_string') list_display = ('service_name', 'get_state_display_string')
@@ -69,6 +69,3 @@ class NameFormatConfigAdmin(admin.ModelAdmin):
def get_state_display_string(self, obj): def get_state_display_string(self, obj):
return ', '.join([state.name for state in obj.states.all()]) return ', '.join([state.name for state in obj.states.all()])
get_state_display_string.short_description = 'States' get_state_display_string.short_description = 'States'
admin.site.register(NameFormatConfig, NameFormatConfigAdmin)

View File

@@ -2,12 +2,11 @@ import logging
from django.contrib import admin from django.contrib import admin
from . import __title__
from ...admin import ServicesUserAdmin from ...admin import ServicesUserAdmin
from . import __title__
from .models import DiscordUser from .models import DiscordUser
from .utils import LoggerAddTag from .utils import LoggerAddTag
logger = LoggerAddTag(logging.getLogger(__name__), __title__) logger = LoggerAddTag(logging.getLogger(__name__), __title__)
@@ -18,21 +17,16 @@ class DiscordUserAdmin(ServicesUserAdmin):
list_filter = ServicesUserAdmin.list_filter + ('activated',) list_filter = ServicesUserAdmin.list_filter + ('activated',)
ordering = ('-activated',) ordering = ('-activated',)
def _uid(self, obj):
return obj.uid
_uid.short_description = 'Discord ID (UID)'
_uid.admin_order_field = 'uid'
def _username(self, obj):
if obj.username and obj.discriminator:
return f'{obj.username}#{obj.discriminator}'
else:
return ''
def delete_queryset(self, request, queryset): def delete_queryset(self, request, queryset):
for user in queryset: for user in queryset:
user.delete_user() user.delete_user()
_username.short_description = 'Discord Username' @admin.display(description='Discord ID (UID)', ordering='uid')
_username.admin_order_field = 'username' def _uid(self, obj):
return obj.uid
@admin.display(description='Discord Username', ordering='username')
def _username(self, obj):
if obj.username and obj.discriminator:
return f'{obj.username}#{obj.discriminator}'
return ''

View File

@@ -0,0 +1,37 @@
"""Public interface for community apps who want to interact with the Discord server
of the current Alliance Auth instance.
Example
=======
Here is an example for using the api to fetch the current roles from the configured Discord server.
.. code-block:: python
from allianceauth.services.modules.discord.api import create_bot_client, discord_guild_id
client = create_bot_client() # create a new Discord client
guild_id = discord_guild_id() # get the ID of the configured Discord server
roles = client.guild_roles(guild_id) # fetch the roles from our Discord server
.. seealso::
The docs for the client class can be found here: :py:class:`~allianceauth.services.modules.discord.discord_client.client.DiscordClient`
"""
from typing import Optional
from .app_settings import DISCORD_GUILD_ID
from .core import create_bot_client, group_to_role, server_name # noqa
from .discord_client.models import Role # noqa
from .models import DiscordUser # noqa
__all__ = ["create_bot_client", "group_to_role", "server_name", "DiscordUser", "Role"]
def discord_guild_id() -> Optional[int]:
"""Guild ID of configured Discord server.
Returns:
Guild ID or ``None`` if not configured
"""
return int(DISCORD_GUILD_ID) if DISCORD_GUILD_ID else None

View File

@@ -2,16 +2,25 @@ from .utils import clean_setting
DISCORD_APP_ID = clean_setting('DISCORD_APP_ID', '') DISCORD_APP_ID = clean_setting('DISCORD_APP_ID', '')
"""App ID for the AA bot on Discord. Needs to be set."""
DISCORD_APP_SECRET = clean_setting('DISCORD_APP_SECRET', '') DISCORD_APP_SECRET = clean_setting('DISCORD_APP_SECRET', '')
"""App secret for the AA bot on Discord. Needs to be set."""
DISCORD_BOT_TOKEN = clean_setting('DISCORD_BOT_TOKEN', '') DISCORD_BOT_TOKEN = clean_setting('DISCORD_BOT_TOKEN', '')
"""Token used by the AA bot on Discord. Needs to be set."""
DISCORD_CALLBACK_URL = clean_setting('DISCORD_CALLBACK_URL', '') DISCORD_CALLBACK_URL = clean_setting('DISCORD_CALLBACK_URL', '')
"""Callback URL for OAuth with Discord. Needs to be set."""
DISCORD_GUILD_ID = clean_setting('DISCORD_GUILD_ID', '') DISCORD_GUILD_ID = clean_setting('DISCORD_GUILD_ID', '')
"""ID of the Discord Server. Needs to be set."""
# max retries of tasks after an error occurred
DISCORD_TASKS_MAX_RETRIES = clean_setting('DISCORD_TASKS_MAX_RETRIES', 3) DISCORD_TASKS_MAX_RETRIES = clean_setting('DISCORD_TASKS_MAX_RETRIES', 3)
"""Max retries of tasks after an error occurred."""
# Pause in seconds until next retry for tasks after the API returned an error
DISCORD_TASKS_RETRY_PAUSE = clean_setting('DISCORD_TASKS_RETRY_PAUSE', 60) DISCORD_TASKS_RETRY_PAUSE = clean_setting('DISCORD_TASKS_RETRY_PAUSE', 60)
"""Pause in seconds until next retry for tasks after the API returned an error."""
# automatically sync Discord users names to user's main character name when created
DISCORD_SYNC_NAMES = clean_setting('DISCORD_SYNC_NAMES', False) DISCORD_SYNC_NAMES = clean_setting('DISCORD_SYNC_NAMES', False)
"""Automatically sync Discord users names to user's main character name when created."""

View File

@@ -6,6 +6,7 @@ from django.template.loader import render_to_string
from allianceauth import hooks from allianceauth import hooks
from allianceauth.services.hooks import ServicesHook from allianceauth.services.hooks import ServicesHook
from .core import server_name, user_formatted_nick
from .models import DiscordUser from .models import DiscordUser
from .urls import urlpatterns from .urls import urlpatterns
from .utils import LoggerAddTag from .utils import LoggerAddTag
@@ -53,7 +54,7 @@ class DiscordService(ServicesHook):
return render_to_string( return render_to_string(
self.service_ctrl_template, self.service_ctrl_template,
{ {
'server_name': DiscordUser.objects.server_name(), 'server_name': server_name(),
'user_has_account': user_has_account, 'user_has_account': user_has_account,
'discord_username': discord_username 'discord_username': discord_username
}, },
@@ -73,7 +74,7 @@ class DiscordService(ServicesHook):
'user_pk': user.pk, 'user_pk': user.pk,
# since the new nickname is not yet in the DB we need to # since the new nickname is not yet in the DB we need to
# provide it manually to the task # provide it manually to the task
'nickname': DiscordUser.objects.user_formatted_nick(user) 'nickname': user_formatted_nick(user)
}, },
priority=SINGLE_TASK_PRIORITY priority=SINGLE_TASK_PRIORITY
) )

View File

@@ -0,0 +1,129 @@
"""Core functionality of the Discord service not directly related to models."""
import logging
from typing import List, Optional, Tuple
from requests.exceptions import HTTPError
from django.contrib.auth.models import Group, User
from allianceauth.groupmanagement.models import ReservedGroupName
from allianceauth.services.hooks import NameFormatter
from . import __title__
from .app_settings import DISCORD_BOT_TOKEN, DISCORD_GUILD_ID
from .discord_client import DiscordClient, RolesSet, Role
from .discord_client.exceptions import DiscordClientException
from .utils import LoggerAddTag
logger = LoggerAddTag(logging.getLogger(__name__), __title__)
def create_bot_client(is_rate_limited: bool = True) -> DiscordClient:
"""Create new bot client for accessing the configured Discord server.
Args:
is_rate_limited: Set to False to turn off rate limiting (use with care).
Return:
Discord client instance
"""
return DiscordClient(DISCORD_BOT_TOKEN, is_rate_limited=is_rate_limited)
def calculate_roles_for_user(
user: User,
client: DiscordClient,
discord_uid: int,
state_name: str = None,
) -> Tuple[RolesSet, Optional[bool]]:
"""Calculate current Discord roles for an Auth user.
Takes into account reserved groups and existing managed roles (e.g. nitro).
Returns:
- Discord roles, changed flag:
- True when roles have changed,
- False when they have not changed,
- None if user is not a member of the guild
"""
roles_calculated = client.match_or_create_roles_from_names_2(
guild_id=DISCORD_GUILD_ID,
role_names=_user_group_names(user=user, state_name=state_name),
)
logger.debug("Calculated roles for user %s: %s", user, roles_calculated.ids())
roles_current = client.guild_member_roles(
guild_id=DISCORD_GUILD_ID, user_id=discord_uid
)
if roles_current is None:
logger.debug("User %s is not a member of the guild.", user)
return roles_calculated, None
logger.debug("Current roles user %s: %s", user, roles_current.ids())
reserved_role_names = ReservedGroupName.objects.values_list("name", flat=True)
roles_reserved = roles_current.subset(role_names=reserved_role_names)
roles_managed = roles_current.subset(managed_only=True)
roles_persistent = roles_managed.union(roles_reserved)
if roles_calculated == roles_current.difference(roles_persistent):
return roles_calculated, False
return roles_calculated.union(roles_persistent), True
def _user_group_names(user: User, state_name: str = None) -> List[str]:
"""Names of groups and state the given user is a member of."""
if not state_name:
state_name = user.profile.state.name
group_names = [group.name for group in user.groups.all()] + [state_name]
logger.debug("Group names for roles updates of user %s are: %s", user, group_names)
return group_names
def user_formatted_nick(user: User) -> Optional[str]:
"""Name of the given user's main character with name formatting applied.
Returns:
Name or ``None`` if user has no main.
"""
from .auth_hooks import DiscordService
if user.profile.main_character:
return NameFormatter(DiscordService(), user).format_name()
return None
def group_to_role(group: Group) -> Optional[Role]:
"""Fetch the Discord role matching the given Django group by name.
Returns:
Discord role or None if no matching role exist
"""
return default_bot_client.match_role_from_name(
guild_id=DISCORD_GUILD_ID, role_name=group.name
)
def server_name(use_cache: bool = True) -> str:
"""Fetches the name of the current Discord server.
Args:
use_cache: When set False will force an API call to get the server name
Returns:
Server name or an empty string if the name could not be retrieved
"""
try:
server_name = default_bot_client.guild_name(
guild_id=DISCORD_GUILD_ID, use_cache=use_cache
)
except (HTTPError, DiscordClientException):
server_name = ""
except Exception:
logger.warning(
"Unexpected error when trying to retrieve the server name from Discord",
exc_info=True,
)
server_name = ""
return server_name
# Default bot client to be used by modules of this package
default_bot_client = create_bot_client()

View File

@@ -1,3 +1,10 @@
from .client import DiscordClient # noqa from .app_settings import DISCORD_OAUTH_BASE_URL, DISCORD_OAUTH_TOKEN_URL # noqa
from .exceptions import DiscordApiBackoff # noqa from .client import DiscordClient # noqa
from .helpers import DiscordRoles # noqa from .exceptions import ( # noqa
DiscordApiBackoff,
DiscordClientException,
DiscordRateLimitExhausted,
DiscordTooManyRequestsError,
)
from .helpers import RolesSet # noqa
from .models import Guild, GuildMember, Role, User # noqa

View File

@@ -1,45 +1,56 @@
"""Settings for the Discord client.
To overwrite a default set the variable in your local Django settings, e.g:
.. code:: python
DISCORD_GUILD_NAME_CACHE_MAX_AGE = 7200
"""
from ..utils import clean_setting from ..utils import clean_setting
# Base URL for all API calls. Must end with /.
DISCORD_API_BASE_URL = clean_setting( DISCORD_API_BASE_URL = clean_setting(
'DISCORD_API_BASE_URL', 'https://discord.com/api/' 'DISCORD_API_BASE_URL', 'https://discord.com/api/'
) )
"""Base URL for all API calls. Must end with /."""
# Low level connecttimeout for requests to the Discord API in seconds
DISCORD_API_TIMEOUT_CONNECT = clean_setting( DISCORD_API_TIMEOUT_CONNECT = clean_setting(
'DISCORD_API_TIMEOUT', 5 'DISCORD_API_TIMEOUT', 5
) )
"""Low level connect timeout for requests to the Discord API in seconds."""
# Low level read timeout for requests to the Discord API in seconds
DISCORD_API_TIMEOUT_READ = clean_setting( DISCORD_API_TIMEOUT_READ = clean_setting(
'DISCORD_API_TIMEOUT', 30 'DISCORD_API_TIMEOUT', 30
) )
"""Low level read timeout for requests to the Discord API in seconds."""
# Base authorization URL for Discord Oauth
DISCORD_OAUTH_BASE_URL = clean_setting( DISCORD_OAUTH_BASE_URL = clean_setting(
'DISCORD_OAUTH_BASE_URL', 'https://discord.com/api/oauth2/authorize' 'DISCORD_OAUTH_BASE_URL', 'https://discord.com/api/oauth2/authorize'
) )
"""Base authorization URL for Discord Oauth."""
# Base authorization URL for Discord Oauth
DISCORD_OAUTH_TOKEN_URL = clean_setting( DISCORD_OAUTH_TOKEN_URL = clean_setting(
'DISCORD_OAUTH_TOKEN_URL', 'https://discord.com/api/oauth2/token' 'DISCORD_OAUTH_TOKEN_URL', 'https://discord.com/api/oauth2/token'
) )
"""Base authorization URL for Discord Oauth."""
# How long the Discord guild names retrieved from the server are
# caches locally in seconds.
DISCORD_GUILD_NAME_CACHE_MAX_AGE = clean_setting( DISCORD_GUILD_NAME_CACHE_MAX_AGE = clean_setting(
'DISCORD_GUILD_NAME_CACHE_MAX_AGE', 3600 * 24 'DISCORD_GUILD_NAME_CACHE_MAX_AGE', 3600 * 24
) )
"""How long the Discord guild names retrieved from the server
are caches locally in seconds.
"""
# How long Discord roles retrieved from the server are caches locally in seconds.
DISCORD_ROLES_CACHE_MAX_AGE = clean_setting( DISCORD_ROLES_CACHE_MAX_AGE = clean_setting(
'DISCORD_ROLES_CACHE_MAX_AGE', 3600 * 1 'DISCORD_ROLES_CACHE_MAX_AGE', 3600 * 1
) )
"""How long Discord roles retrieved from the server are caches locally in seconds."""
# Turns off creation of new roles. In case the rate limit for creating roles is
# exhausted, this setting allows the Discord service to continue to function
# and wait out the reset. Rate limit is about 250 per 48 hrs.
DISCORD_DISABLE_ROLE_CREATION = clean_setting( DISCORD_DISABLE_ROLE_CREATION = clean_setting(
'DISCORD_DISABLE_ROLE_CREATION', False 'DISCORD_DISABLE_ROLE_CREATION', False
) )
"""Turns off creation of new roles. In case the rate limit for creating roles is
exhausted, this setting allows the Discord service to continue to function
and wait out the reset. Rate limit is about 250 per 48 hrs.
"""

View File

@@ -1,32 +1,37 @@
from hashlib import md5 """Client for interacting with the Discord API."""
import json import json
import logging import logging
from enum import IntEnum
from hashlib import md5
from http import HTTPStatus
from time import sleep from time import sleep
from typing import Iterable, List, Optional, Set, Tuple
from urllib.parse import urljoin from urllib.parse import urljoin
from uuid import uuid1 from uuid import uuid1
from redis import Redis
import requests import requests
from requests.exceptions import HTTPError
from redis import Redis
from allianceauth.utils.cache import get_redis_client from allianceauth.utils.cache import get_redis_client
from allianceauth import __title__ as AUTH_TITLE, __url__, __version__ from allianceauth import __title__ as AUTH_TITLE
from allianceauth import __url__, __version__
from .. import __title__ from .. import __title__
from ..utils import LoggerAddTag
from .app_settings import ( from .app_settings import (
DISCORD_API_BASE_URL, DISCORD_API_BASE_URL,
DISCORD_API_TIMEOUT_CONNECT, DISCORD_API_TIMEOUT_CONNECT,
DISCORD_API_TIMEOUT_READ, DISCORD_API_TIMEOUT_READ,
DISCORD_DISABLE_ROLE_CREATION, DISCORD_DISABLE_ROLE_CREATION,
DISCORD_GUILD_NAME_CACHE_MAX_AGE, DISCORD_GUILD_NAME_CACHE_MAX_AGE,
DISCORD_OAUTH_BASE_URL,
DISCORD_OAUTH_TOKEN_URL,
DISCORD_ROLES_CACHE_MAX_AGE, DISCORD_ROLES_CACHE_MAX_AGE,
) )
from .exceptions import DiscordRateLimitExhausted, DiscordTooManyRequestsError from .exceptions import DiscordRateLimitExhausted, DiscordTooManyRequestsError
from .helpers import DiscordRoles from .helpers import RolesSet
from ..utils import LoggerAddTag from .models import Guild, GuildMember, Role, User
logger = LoggerAddTag(logging.getLogger(__name__), __title__) logger = LoggerAddTag(logging.getLogger(__name__), __title__)
@@ -58,8 +63,13 @@ MINIMUM_BLOCKING_WAIT = 50
RATE_LIMIT_RETRIES = 1000 RATE_LIMIT_RETRIES = 1000
class DiscordApiStatusCode(IntEnum):
"""Status code returned from the Discord API."""
UNKNOWN_MEMBER = 10007 #:
class DiscordClient: class DiscordClient:
"""This class provides a web client for interacting with the Discord API """This class provides a web client for interacting with the Discord API.
The client has rate limiting that supports concurrency. The client has rate limiting that supports concurrency.
This means it is able to ensure the API rate limit is not violated, This means it is able to ensure the API rate limit is not violated,
@@ -67,24 +77,30 @@ class DiscordClient:
In addition the client support proper API backoff. In addition the client support proper API backoff.
Synchronization of rate limit infos accross multiple processes Synchronization of rate limit infos across multiple processes
is implemented with Redis and thus requires Redis as Django cache backend. is implemented with Redis and thus requires Redis as Django cache backend.
All durations are in milliseconds. The cache is shared across all clients and processes (also using Redis).
"""
OAUTH_BASE_URL = DISCORD_OAUTH_BASE_URL
OAUTH_TOKEN_URL = DISCORD_OAUTH_TOKEN_URL
All durations are in milliseconds.
Most errors from the API will raise a requests.HTTPError.
Args:
access_token: Discord access token used to authenticate all calls to the API
redis: Redis instance to be used.
is_rate_limited: Set to False to turn off rate limiting (use with care).
If not specified will try to use the Redis instance
from the default Django cache backend.
Raises:
ValueError: No access token provided
"""
_KEY_GLOBAL_BACKOFF_UNTIL = 'DISCORD_GLOBAL_BACKOFF_UNTIL' _KEY_GLOBAL_BACKOFF_UNTIL = 'DISCORD_GLOBAL_BACKOFF_UNTIL'
_KEY_GLOBAL_RATE_LIMIT_REMAINING = 'DISCORD_GLOBAL_RATE_LIMIT_REMAINING' _KEY_GLOBAL_RATE_LIMIT_REMAINING = 'DISCORD_GLOBAL_RATE_LIMIT_REMAINING'
_KEYPREFIX_GUILD_NAME = 'DISCORD_GUILD_NAME' _KEYPREFIX_GUILD_NAME = 'DISCORD_GUILD_NAME'
_KEYPREFIX_GUILD_ROLES = 'DISCORD_GUILD_ROLES' _KEYPREFIX_GUILD_ROLES = 'DISCORD_GUILD_ROLES'
_KEYPREFIX_ROLE_NAME = 'DISCORD_ROLE_NAME' _KEYPREFIX_ROLE_NAME = 'DISCORD_ROLE_NAME'
_NICK_MAX_CHARS = 32
_HTTP_STATUS_CODE_NOT_FOUND = 404
_HTTP_STATUS_CODE_RATE_LIMITED = 429
_DISCORD_STATUS_CODE_UNKNOWN_MEMBER = 10007
def __init__( def __init__(
self, self,
@@ -92,14 +108,8 @@ class DiscordClient:
redis: Redis = None, redis: Redis = None,
is_rate_limited: bool = True is_rate_limited: bool = True
) -> None: ) -> None:
""" if not access_token:
Params: raise ValueError('You must provide an access token.')
- access_token: Discord access token used to authenticate all calls to the API
- redis: Redis instance to be used.
- is_rate_limited: Set to False to run of rate limiting (use with care)
If not specified will try to use the Redis instance
from the default Django cache backend.
"""
self._access_token = str(access_token) self._access_token = str(access_token)
self._is_rate_limited = bool(is_rate_limited) self._is_rate_limited = bool(is_rate_limited)
if not redis: if not redis:
@@ -131,19 +141,20 @@ class DiscordClient:
self.__redis_script_set_longer = self._redis.register_script(lua_2) self.__redis_script_set_longer = self._redis.register_script(lua_2)
@property @property
def access_token(self): def access_token(self) -> str:
"""Discord access token."""
return self._access_token return self._access_token
@property @property
def is_rate_limited(self): def is_rate_limited(self) -> bool:
"""Wether this instance is rate limited."""
return self._is_rate_limited return self._is_rate_limited
def __repr__(self): def __repr__(self):
return f'{type(self).__name__}(access_token=...{self.access_token[-5:]})' return f'{type(self).__name__}(access_token=...{self.access_token[-5:]})'
def _redis_decr_or_set(self, name: str, value: str, px: int) -> bool: def _redis_decr_or_set(self, name: str, value: str, px: int) -> bool:
"""decreases the key value if it exists and returns the result """Decrease the key value if it exists and returns the result else set the key.
else sets the key
Implemented as Lua script to ensure atomicity. Implemented as Lua script to ensure atomicity.
""" """
@@ -152,7 +163,7 @@ class DiscordClient:
) )
def _redis_set_if_longer(self, name: str, value: str, px: int) -> bool: def _redis_set_if_longer(self, name: str, value: str, px: int) -> bool:
"""like set, but only goes through if either key doesn't exist """Like set, but only goes through if either key doesn't exist
or px would be extended. or px would be extended.
Implemented as Lua script to ensure atomicity. Implemented as Lua script to ensure atomicity.
@@ -163,111 +174,134 @@ class DiscordClient:
# users # users
def current_user(self) -> dict: def current_user(self) -> User:
"""returns the user belonging to the current access_token""" """Fetch user belonging to the current access_token."""
authorization = f'Bearer {self.access_token}' authorization = f'Bearer {self.access_token}'
r = self._api_request( r = self._api_request(
method='get', route='users/@me', authorization=authorization method='get', route='users/@me', authorization=authorization
) )
return r.json() return User.from_dict(r.json())
# guild # guild
def guild_infos(self, guild_id: int) -> dict: def guild_infos(self, guild_id: int) -> Guild:
"""Returns all basic infos about this guild""" """Fetch all basic infos about this guild.
Args:
guild_id: Discord ID of the guild
"""
route = f"guilds/{guild_id}" route = f"guilds/{guild_id}"
r = self._api_request(method='get', route=route) r = self._api_request(method='get', route=route)
return r.json() return Guild.from_dict(r.json())
def guild_name(self, guild_id: int, use_cache: bool = True) -> str: def guild_name(self, guild_id: int, use_cache: bool = True) -> str:
"""returns the name of this guild (cached) """Fetch the name of this guild (cached).
or an empty string if something went wrong
Params: Args:
- guild_id: ID of current guild guild_id: Discord ID of the guild
- use_cache: When set to False will force an API call to get the server name use_cache: When set to False will force an API call to get the server name
Returns:
Name of the server or an empty string if something went wrong.
""" """
key_name = self._guild_name_cache_key(guild_id) key_name = self._guild_name_cache_key(guild_id)
if use_cache: if use_cache:
guild_name = self._redis_decode(self._redis.get(key_name)) guild_name = self._redis_decode(self._redis.get(key_name))
else: else:
guild_name = None guild_name = ""
if not guild_name: if not guild_name:
guild_infos = self.guild_infos(guild_id) try:
if 'name' in guild_infos: guild = self.guild_infos(guild_id)
guild_name = guild_infos['name'] except HTTPError:
self._redis.set( guild_name = ""
name=key_name,
value=guild_name,
ex=DISCORD_GUILD_NAME_CACHE_MAX_AGE
)
else: else:
guild_name = '' guild_name = guild.name
self._redis.set(
name=key_name, value=guild_name, ex=DISCORD_GUILD_NAME_CACHE_MAX_AGE
)
return guild_name return guild_name
@classmethod @classmethod
def _guild_name_cache_key(cls, guild_id: int) -> str: def _guild_name_cache_key(cls, guild_id: int) -> str:
"""Returns key for accessing role given by name in the role cache""" """Construct key for accessing role given by name in the role cache.
Args:
guild_id: Discord ID of the guild
"""
gen_key = DiscordClient._generate_hash(f'{guild_id}') gen_key = DiscordClient._generate_hash(f'{guild_id}')
return f'{cls._KEYPREFIX_GUILD_NAME}__{gen_key}' return f'{cls._KEYPREFIX_GUILD_NAME}__{gen_key}'
# guild roles # guild roles
def guild_roles(self, guild_id: int, use_cache: bool = True) -> list: def guild_roles(self, guild_id: int, use_cache: bool = True) -> Set[Role]:
"""Returns the list of all roles for this guild """Fetch all roles for this guild.
If use_cache is set to False it will always hit the API to retrieve Args:
fresh data and update the cache guild_id: Discord ID of the guild
use_cache: If is set to False it will always hit the API to retrieve
fresh data and update the cache.
Returns:
""" """
cache_key = self._guild_roles_cache_key(guild_id) cache_key = self._guild_roles_cache_key(guild_id)
roles = None
if use_cache: if use_cache:
roles_raw = self._redis.get(name=cache_key) roles_raw = self._redis.get(name=cache_key)
if roles_raw: if roles_raw:
logger.debug('Returning roles for guild %s from cache', guild_id) logger.debug('Returning roles for guild %s from cache', guild_id)
return json.loads(self._redis_decode(roles_raw)) roles = json.loads(self._redis_decode(roles_raw))
else: logger.debug('No roles for guild %s in cache', guild_id)
logger.debug('No roles for guild %s in cache', guild_id) if roles is None:
route = f"guilds/{guild_id}/roles"
route = f"guilds/{guild_id}/roles" r = self._api_request(method='get', route=route)
r = self._api_request(method='get', route=route) roles = r.json()
roles = r.json() if not roles or not isinstance(roles, list):
if roles and isinstance(roles, list): raise RuntimeError(
f"Unexpected response when fetching roles from API: {roles}"
)
self._redis.set( self._redis.set(
name=cache_key, name=cache_key,
value=json.dumps(roles), value=json.dumps(roles),
ex=DISCORD_ROLES_CACHE_MAX_AGE ex=DISCORD_ROLES_CACHE_MAX_AGE
) )
return roles return {Role.from_dict(role) for role in roles}
def create_guild_role(self, guild_id: int, role_name: str, **kwargs) -> dict: def create_guild_role(
self, guild_id: int, role_name: str, **kwargs
) -> Optional[Role]:
"""Create a new guild role with the given name. """Create a new guild role with the given name.
See official documentation for additional optional parameters. See official documentation for additional optional parameters.
Note that Discord allows the creation of multiple roles with the same name, Note that Discord allows the creation of multiple roles with the same name,
so to avoid duplicates it's important to check existing roles so to avoid duplicates it's important to check existing roles
before creating new one before creating new one
returns a new role dict on success Args:
guild_id: Discord ID of the guild
role_name: Name of new role to create
Returns:
new role on success
""" """
route = f"guilds/{guild_id}/roles" route = f"guilds/{guild_id}/roles"
data = {'name': DiscordRoles.sanitize_role_name(role_name)} data = {'name': Role.sanitize_name(role_name)}
data.update(kwargs) data.update(kwargs)
r = self._api_request(method='post', route=route, data=data) r = self._api_request(method='post', route=route, data=data)
role = r.json() role = r.json()
if role: if role:
self._invalidate_guild_roles_cache(guild_id) self._invalidate_guild_roles_cache(guild_id)
return role return Role.from_dict(role)
return None
def delete_guild_role(self, guild_id: int, role_id: int) -> bool: def delete_guild_role(self, guild_id: int, role_id: int) -> bool:
"""Deletes a guild role""" """Delete a guild role."""
route = f"guilds/{guild_id}/roles/{role_id}" route = f"guilds/{guild_id}/roles/{role_id}"
r = self._api_request(method='delete', route=route) r = self._api_request(method='delete', route=route)
if r.status_code == 204: if r.status_code == 204:
self._invalidate_guild_roles_cache(guild_id) self._invalidate_guild_roles_cache(guild_id)
return True return True
else: return False
return False
def _invalidate_guild_roles_cache(self, guild_id: int) -> None: def _invalidate_guild_roles_cache(self, guild_id: int) -> None:
cache_key = self._guild_roles_cache_key(guild_id) cache_key = self._guild_roles_cache_key(guild_id)
@@ -276,67 +310,79 @@ class DiscordClient:
@classmethod @classmethod
def _guild_roles_cache_key(cls, guild_id: int) -> str: def _guild_roles_cache_key(cls, guild_id: int) -> str:
"""Returns key for accessing cached roles for a guild""" """Construct key for accessing cached roles for a guild.
Args:
guild_id: Discord ID of the guild
"""
gen_key = cls._generate_hash(f'{guild_id}') gen_key = cls._generate_hash(f'{guild_id}')
return f'{cls._KEYPREFIX_GUILD_ROLES}__{gen_key}' return f'{cls._KEYPREFIX_GUILD_ROLES}__{gen_key}'
def match_role_from_name(self, guild_id: int, role_name: str) -> dict: def match_role_from_name(self, guild_id: int, role_name: str) -> Optional[Role]:
"""returns Discord role matching the given name or an empty dict""" """Fetch Discord role matching the given name (cached).
guild_roles = DiscordRoles(self.guild_roles(guild_id))
Args:
guild_id: Discord ID of the guild
role_name: Name of role
Returns:
Matching role or None if no match is found
"""
guild_roles = RolesSet(self.guild_roles(guild_id))
return guild_roles.role_by_name(role_name) return guild_roles.role_by_name(role_name)
def match_or_create_roles_from_names(self, guild_id: int, role_names: list) -> list: def match_or_create_roles_from_names(
"""returns Discord roles matching the given names self, guild_id: int, role_names: Iterable[str]
) -> List[Tuple[Role, bool]]:
Returns as list of tuple of role and created flag """Fetch or create Discord roles matching the given names (cached).
Will try to match with existing roles names Will try to match with existing roles names
Non-existing roles will be created, then created flag will be True Non-existing roles will be created, then created flag will be True
Params: Args:
- guild_id: ID of guild guild_id: ID of guild
- role_names: list of name strings each defining a role role_names: list of name strings each defining a role
Returns:
List of tuple of Role and created flag
""" """
roles = list() roles = list()
guild_roles = DiscordRoles(self.guild_roles(guild_id)) guild_roles = RolesSet(self.guild_roles(guild_id))
role_names_cleaned = { role_names_cleaned = {Role.sanitize_name(name) for name in role_names}
DiscordRoles.sanitize_role_name(name) for name in role_names
}
for role_name in role_names_cleaned: for role_name in role_names_cleaned:
role, created = self.match_or_create_role_from_name( role, created = self.match_or_create_role_from_name(
guild_id=guild_id, guild_id=guild_id, role_name=role_name, guild_roles=guild_roles
role_name=DiscordRoles.sanitize_role_name(role_name),
guild_roles=guild_roles
) )
if role: if role:
roles.append((role, created)) roles.append((role, created))
if created: if created:
guild_roles = guild_roles.union(DiscordRoles([role])) guild_roles = guild_roles.union(RolesSet([role]))
return roles return roles
def match_or_create_role_from_name( def match_or_create_role_from_name(
self, guild_id: int, role_name: str, guild_roles: DiscordRoles = None self, guild_id: int, role_name: str, guild_roles: RolesSet = None
) -> tuple: ) -> Tuple[Role, bool]:
"""returns Discord role matching the given name """Fetch or create Discord role matching the given name.
Returns as tuple of role and created flag
Will try to match with existing roles names Will try to match with existing roles names
Non-existing roles will be created, then created flag will be True Non-existing roles will be created, then created flag will be True
Params: Args:
- guild_id: ID of guild guild_id: ID of guild
- role_name: strings defining name of a role role_name: strings defining name of a role
- guild_roles: All known guild roles as DiscordRoles object. guild_roles: All known guild roles as RolesSet object.
Helps to void redundant lookups of guild roles Helps to void redundant lookups of guild roles
when this method is used multiple times. when this method is used multiple times.
Returns:
Tuple of Role and created flag
""" """
if not isinstance(role_name, str): if not isinstance(role_name, str):
raise TypeError('role_name must be of type string') raise TypeError('role_name must be of type string')
created = False created = False
if guild_roles is None: if guild_roles is None:
guild_roles = DiscordRoles(self.guild_roles(guild_id)) guild_roles = RolesSet(self.guild_roles(guild_id))
role = guild_roles.role_by_name(role_name) role = guild_roles.role_by_name(role_name)
if not role: if not role:
if not DISCORD_DISABLE_ROLE_CREATION: if not DISCORD_DISABLE_ROLE_CREATION:
@@ -345,9 +391,24 @@ class DiscordClient:
created = True created = True
else: else:
role = None role = None
return role, created return role, created
def match_or_create_roles_from_names_2(
self, guild_id: int, role_names: Iterable[str]
) -> RolesSet:
"""Fetch or create Discord role matching the given name.
Wrapper for ``match_or_create_role_from_name()``
Returns:
Roles as RolesSet object.
"""
return RolesSet.create_from_matched_roles(
self.match_or_create_roles_from_names(
guild_id=guild_id, role_names=role_names
)
)
# guild members # guild members
def add_guild_member( def add_guild_member(
@@ -357,13 +418,13 @@ class DiscordClient:
access_token: str, access_token: str,
role_ids: list = None, role_ids: list = None,
nick: str = None nick: str = None
) -> bool: ) -> Optional[bool]:
"""Adds a user to the guilds. """Adds a user to the guild.
Returns: Returns:
- True when a new user was added - True when a new user was added
- None if the user already existed - None if the user already existed
- False when something went wrong or raises exception - False when something went wrong or raises exception
""" """
route = f"guilds/{guild_id}/members/{user_id}" route = f"guilds/{guild_id}/members/{user_id}"
data = { data = {
@@ -371,42 +432,49 @@ class DiscordClient:
} }
if role_ids: if role_ids:
data['roles'] = self._sanitize_role_ids(role_ids) data['roles'] = self._sanitize_role_ids(role_ids)
if nick: if nick:
data['nick'] = str(nick)[:self._NICK_MAX_CHARS] data['nick'] = GuildMember.sanitize_nick(nick)
r = self._api_request(method='put', route=route, data=data) r = self._api_request(method='put', route=route, data=data)
r.raise_for_status() r.raise_for_status()
if r.status_code == 201: if r.status_code == 201:
return True return True
elif r.status_code == 204: elif r.status_code == 204:
return None return None
else: return False
return False
def guild_member(self, guild_id: int, user_id: int) -> dict: def guild_member(self, guild_id: int, user_id: int) -> Optional[GuildMember]:
"""returns the user info for a guild member """Fetch info for a guild member.
or None if the user is not a member of the guild Args:
guild_id: Discord ID of the guild
user_id: Discord ID of the user
Returns:
guild member or ``None`` if the user is not a member of the guild
""" """
route = f'guilds/{guild_id}/members/{user_id}' route = f'guilds/{guild_id}/members/{user_id}'
r = self._api_request(method='get', route=route, raise_for_status=False) r = self._api_request(method='get', route=route, raise_for_status=False)
if self._is_member_unknown_error(r): if self._is_member_unknown_error(r):
logger.warning("Discord user ID %s could not be found on server.", user_id) logger.warning("Discord user ID %s could not be found on server.", user_id)
return None return None
else: r.raise_for_status()
r.raise_for_status() return GuildMember.from_dict(r.json())
return r.json()
def modify_guild_member( def modify_guild_member(
self, guild_id: int, user_id: int, role_ids: list = None, nick: str = None self, guild_id: int, user_id: int, role_ids: List[int] = None, nick: str = None
) -> bool: ) -> Optional[bool]:
"""Modify attributes of a guild member. """Set properties of a guild member.
Args:
guild_id: Discord ID of the guild
user_id: Discord ID of the user
roles_id: New list of role IDs (if provided)
nick: New nickname (if provided)
Returns Returns
- True when successful - True when successful
- None if user is not a member of this guild - None if user is not a member of this guild
- False otherwise - False otherwise
""" """
if not role_ids and not nick: if not role_ids and not nick:
raise ValueError('Must specify role_ids or nick') raise ValueError('Must specify role_ids or nick')
@@ -419,7 +487,7 @@ class DiscordClient:
data['roles'] = self._sanitize_role_ids(role_ids) data['roles'] = self._sanitize_role_ids(role_ids)
if nick: if nick:
data['nick'] = self._sanitize_nick(nick) data['nick'] = GuildMember.sanitize_nick(nick)
route = f"guilds/{guild_id}/members/{user_id}" route = f"guilds/{guild_id}/members/{user_id}"
r = self._api_request( r = self._api_request(
@@ -428,21 +496,22 @@ class DiscordClient:
if self._is_member_unknown_error(r): if self._is_member_unknown_error(r):
logger.warning('User ID %s is not a member of this guild', user_id) logger.warning('User ID %s is not a member of this guild', user_id)
return None return None
else: r.raise_for_status()
r.raise_for_status()
if r.status_code == 204: if r.status_code == 204:
return True return True
else: return False
return False
def remove_guild_member(self, guild_id: int, user_id: int) -> bool: def remove_guild_member(self, guild_id: int, user_id: int) -> Optional[bool]:
"""Remove a member from a guild """Remove a member from a guild.
Args:
guild_id: Discord ID of the guild
user_id: Discord ID of the user
Returns: Returns:
- True when successful - True when successful
- None if member does not exist - None if member does not exist
- False otherwise - False otherwise
""" """
route = f"guilds/{guild_id}/members/{user_id}" route = f"guilds/{guild_id}/members/{user_id}"
r = self._api_request( r = self._api_request(
@@ -451,19 +520,16 @@ class DiscordClient:
if self._is_member_unknown_error(r): if self._is_member_unknown_error(r):
logger.warning('User ID %s is not a member of this guild', user_id) logger.warning('User ID %s is not a member of this guild', user_id)
return None return None
else: r.raise_for_status()
r.raise_for_status()
if r.status_code == 204: if r.status_code == 204:
return True return True
else: return False
return False
# Guild member roles # Guild member roles
def add_guild_member_role( def add_guild_member_role(
self, guild_id: int, user_id: int, role_id: int self, guild_id: int, user_id: int, role_id: int
) -> bool: ) -> Optional[bool]:
"""Adds a role to a guild member """Adds a role to a guild member
Returns: Returns:
@@ -476,43 +542,69 @@ class DiscordClient:
if self._is_member_unknown_error(r): if self._is_member_unknown_error(r):
logger.warning('User ID %s is not a member of this guild', user_id) logger.warning('User ID %s is not a member of this guild', user_id)
return None return None
else: r.raise_for_status()
r.raise_for_status()
if r.status_code == 204: if r.status_code == 204:
return True return True
else: return False
return False
def remove_guild_member_role( def remove_guild_member_role(
self, guild_id: int, user_id: int, role_id: int self, guild_id: int, user_id: int, role_id: int
) -> bool: ) -> Optional[bool]:
"""Removes a role to a guild member """Remove a role to a guild member
Args:
guild_id: Discord ID of the guild
user_id: Discord ID of the user
role_id: Discord ID of role to be removed
Returns: Returns:
- True when successful - True when successful
- None if member does not exist - None if member does not exist
- False otherwise - False otherwise
""" """
route = f"guilds/{guild_id}/members/{user_id}/roles/{role_id}" route = f"guilds/{guild_id}/members/{user_id}/roles/{role_id}"
r = self._api_request(method='delete', route=route, raise_for_status=False) r = self._api_request(method='delete', route=route, raise_for_status=False)
if self._is_member_unknown_error(r): if self._is_member_unknown_error(r):
logger.warning('User ID %s is not a member of this guild', user_id) logger.warning('User ID %s is not a member of this guild', user_id)
return None return None
else: r.raise_for_status()
r.raise_for_status()
if r.status_code == 204: if r.status_code == 204:
return True return True
else: return False
return False
def guild_member_roles(self, guild_id: int, user_id: int) -> Optional[RolesSet]:
"""Fetch the current guild roles of a guild member.
Args:
- guild_id: Discord guild ID
- user_id: Discord user ID
Returns:
- Member roles
- None if user is not a member of the guild
"""
member_info = self.guild_member(guild_id=guild_id, user_id=user_id)
if member_info is None:
return None # User is no longer a member
guild_roles = RolesSet(self.guild_roles(guild_id=guild_id))
logger.debug('Current guild roles: %s', guild_roles.ids())
if not guild_roles.has_roles(member_info.roles):
guild_roles = RolesSet(
self.guild_roles(guild_id=guild_id, use_cache=False)
)
if not guild_roles.has_roles(member_info.roles):
role_ids = set(member_info.roles).difference(guild_roles.ids())
raise RuntimeError(
f'Discord user {user_id} has unknown roles: {role_ids}'
)
return guild_roles.subset(member_info.roles)
@classmethod @classmethod
def _is_member_unknown_error(cls, r: requests.Response) -> bool: def _is_member_unknown_error(cls, r: requests.Response) -> bool:
try: try:
result = ( result = (
r.status_code == cls._HTTP_STATUS_CODE_NOT_FOUND r.status_code == HTTPStatus.NOT_FOUND
and r.json()['code'] == cls._DISCORD_STATUS_CODE_UNKNOWN_MEMBER and r.json()['code'] == DiscordApiStatusCode.UNKNOWN_MEMBER
) )
except (ValueError, KeyError): except (ValueError, KeyError):
result = False result = False
@@ -529,7 +621,19 @@ class DiscordClient:
authorization: str = None, authorization: str = None,
raise_for_status: bool = True raise_for_status: bool = True
) -> requests.Response: ) -> requests.Response:
"""Core method for performing all API calls""" """Core method for performing all API calls.
Args:
method: HTTP method of the request, e.g. "get"
route: Route in the Discord API, e.g. "users/@me"
data: Data to be send with the request
authorization: The authorization string to be used.
Will use the default bot token if not set.
raise_for_status: Whether a requests exception is to be raised when not ok
Returns:
The raw response from the API
"""
uid = uuid1().hex uid = uuid1().hex
if not hasattr(requests, method): if not hasattr(requests, method):
@@ -577,7 +681,7 @@ class DiscordClient:
r.text r.text
) )
if r.status_code == self._HTTP_STATUS_CODE_RATE_LIMITED: if r.status_code == HTTPStatus.TOO_MANY_REQUESTS:
self._handle_new_api_backoff(r, uid) self._handle_new_api_backoff(r, uid)
self._report_rate_limit_from_api(r, uid) self._report_rate_limit_from_api(r, uid)
@@ -588,9 +692,10 @@ class DiscordClient:
return r return r
def _handle_ongoing_api_backoff(self, uid: str) -> None: def _handle_ongoing_api_backoff(self, uid: str) -> None:
"""checks if api is currently on backoff """Check if api is currently on backoff.
if on backoff: will do a blocking wait if it expires soon,
else raises exception If on backoff: will do a blocking wait if it expires soon,
else raises exception.
""" """
global_backoff_duration = self._redis.pttl(self._KEY_GLOBAL_BACKOFF_UNTIL) global_backoff_duration = self._redis.pttl(self._KEY_GLOBAL_BACKOFF_UNTIL)
if global_backoff_duration > 0: if global_backoff_duration > 0:
@@ -610,8 +715,9 @@ class DiscordClient:
raise DiscordTooManyRequestsError(retry_after=global_backoff_duration) raise DiscordTooManyRequestsError(retry_after=global_backoff_duration)
def _ensure_rate_limed_not_exhausted(self, uid: str) -> int: def _ensure_rate_limed_not_exhausted(self, uid: str) -> int:
"""ensures that the rate limit is not exhausted """Ensures that the rate limit is not exhausted.
if exhausted: will do a blocking wait if rate limit resets soon,
If exhausted: will do a blocking wait if rate limit resets soon,
else raises exception else raises exception
returns requests remaining on success returns requests remaining on success
@@ -654,10 +760,10 @@ class DiscordClient:
) )
raise DiscordRateLimitExhausted(resets_in) raise DiscordRateLimitExhausted(resets_in)
raise RuntimeError('Failed to handle rate limit after after too tries.') raise RuntimeError('Failed to handle rate limit after after too many tries.')
def _handle_new_api_backoff(self, r: requests.Response, uid: str) -> None: def _handle_new_api_backoff(self, r: requests.Response, uid: str) -> None:
"""raises exception for new API backoff error""" """Raise exception for new API backoff error."""
response = r.json() response = r.json()
if 'retry_after' in response: if 'retry_after' in response:
try: try:
@@ -679,8 +785,8 @@ class DiscordClient:
) )
raise DiscordTooManyRequestsError(retry_after=retry_after) raise DiscordTooManyRequestsError(retry_after=retry_after)
def _report_rate_limit_from_api(self, r, uid): def _report_rate_limit_from_api(self, r, uid) -> None:
"""Tries to log the current rate limit reported from API""" """Try to log the current rate limit reported from API."""
if ( if (
logger.getEffectiveLevel() <= logging.DEBUG logger.getEffectiveLevel() <= logging.DEBUG
and 'x-ratelimit-limit' in r.headers and 'x-ratelimit-limit' in r.headers
@@ -703,22 +809,17 @@ class DiscordClient:
@staticmethod @staticmethod
def _redis_decode(value: str) -> str: def _redis_decode(value: str) -> str:
"""Decodes a string from Redis and passes through None and Booleans""" """Decode a string from Redis and passes through None and Booleans."""
if value is not None and not isinstance(value, bool): if value is not None and not isinstance(value, bool):
return value.decode('utf-8') return value.decode('utf-8')
else: return value
return value
@staticmethod @staticmethod
def _generate_hash(key: str) -> str: def _generate_hash(key: str) -> str:
"""Generate hash key for given string."""
return md5(key.encode('utf-8')).hexdigest() return md5(key.encode('utf-8')).hexdigest()
@staticmethod @staticmethod
def _sanitize_role_ids(role_ids: list) -> list: def _sanitize_role_ids(role_ids: Iterable[int]) -> List[int]:
"""make sure its a list of integers""" """Sanitize a list of role IDs, i.e. make sure its a list of unique integers."""
return [int(role_id) for role_id in list(role_ids)] return [int(role_id) for role_id in set(role_ids)]
@classmethod
def _sanitize_nick(cls, nick: str) -> str:
"""shortens too long strings if necessary"""
return str(nick)[:cls._NICK_MAX_CHARS]

View File

@@ -1,23 +1,26 @@
"""Custom exceptions for the Discord Client package."""
import math import math
class DiscordClientException(Exception): class DiscordClientException(Exception):
"""Base Exception for the Discord client""" """Base Exception for the Discord client."""
class DiscordApiBackoff(DiscordClientException): class DiscordApiBackoff(DiscordClientException):
"""Exception signaling we need to backoff from sending requests to the API for now """Exception signaling we need to backoff from sending requests to the API for now.
Args:
retry_after: time to retry after in milliseconds
""" """
def __init__(self, retry_after: int): def __init__(self, retry_after: int):
"""
:param retry_after: int time to retry after in milliseconds
"""
super().__init__() super().__init__()
self.retry_after = int(retry_after) self.retry_after = int(retry_after)
@property @property
def retry_after_seconds(self): def retry_after_seconds(self):
"""Time to retry after in seconds."""
return math.ceil(self.retry_after / 1000) return math.ceil(self.retry_after / 1000)

View File

@@ -1,27 +1,37 @@
from copy import copy from copy import copy
from typing import Set, Iterable from typing import Iterable, List, Optional, Set, Tuple
from .models import Role
class DiscordRoles: class RolesSet:
"""Container class that helps dealing with Discord roles. """Container of Discord roles with added functionality.
Objects of this class are immutable and work in many ways like sets. Objects of this class are immutable and work in many ways like sets.
Ideally objects are initialized from raw API responses, Ideally objects are initialized from raw API responses,
e.g. from DiscordClient.guild.roles() e.g. from DiscordClient.guild.roles().
"""
_ROLE_NAME_MAX_CHARS = 100
def __init__(self, roles_lst: list) -> None: Args:
"""roles_lst must be a list of dict, each defining a role""" roles_lst: List of dicts, each defining a role
"""
def __init__(self, roles_lst: Iterable[Role]) -> None:
if not isinstance(roles_lst, (list, set, tuple)): if not isinstance(roles_lst, (list, set, tuple)):
raise TypeError('roles_lst must be of type list, set or tuple') raise TypeError('roles_lst must be of type list, set or tuple')
self._roles = dict() self._roles = dict()
self._roles_by_name = dict() self._roles_by_name = dict()
for role in list(roles_lst): for role in list(roles_lst):
self._assert_valid_role(role) if not isinstance(role, Role):
self._roles[int(role['id'])] = role raise TypeError('Roles must be of type Role: %s' % role)
self._roles_by_name[self.sanitize_role_name(role['name'])] = role self._roles[role.id] = role
self._roles_by_name[role.name] = role
def __repr__(self) -> str:
if self._roles_by_name:
roles = '"' + '", "'.join(sorted(list(self._roles_by_name.keys()))) + '"'
else:
roles = ""
return f'{self.__class__.__name__}([{roles}])'
def __eq__(self, other): def __eq__(self, other):
if isinstance(other, type(self)): if isinstance(other, type(self)):
@@ -41,15 +51,15 @@ class DiscordRoles:
return len(self._roles.keys()) return len(self._roles.keys())
def has_roles(self, role_ids: Set[int]) -> bool: def has_roles(self, role_ids: Set[int]) -> bool:
"""returns true if this objects contains all roles defined by given role_ids """True if this objects contains all roles defined by given role_ids
incl. managed roles incl. managed roles.
""" """
role_ids = {int(id) for id in role_ids} role_ids = {int(id) for id in role_ids}
all_role_ids = self._roles.keys() all_role_ids = self._roles.keys()
return role_ids.issubset(all_role_ids) return role_ids.issubset(all_role_ids)
def ids(self) -> Set[int]: def ids(self) -> Set[int]:
"""return a set of all role IDs""" """Set of all role IDs."""
return set(self._roles.keys()) return set(self._roles.keys())
def subset( def subset(
@@ -57,13 +67,13 @@ class DiscordRoles:
role_ids: Iterable[int] = None, role_ids: Iterable[int] = None,
managed_only: bool = False, managed_only: bool = False,
role_names: Iterable[str] = None role_names: Iterable[str] = None
) -> "DiscordRoles": ) -> "RolesSet":
"""returns a new object containing the subset of roles """Create instance containing the subset of roles
Args: Args:
- role_ids: role ids must be in the provided list role_ids: role ids must be in the provided list
- managed_only: roles must be managed managed_only: roles must be managed
- role_names: role names must match provided list (not case sensitive) role_names: role names must match provided list (not case sensitive)
""" """
if role_ids is not None: if role_ids is not None:
role_ids = {int(id) for id in role_ids} role_ids = {int(id) for id in role_ids}
@@ -75,72 +85,50 @@ class DiscordRoles:
elif role_ids is None and managed_only: elif role_ids is None and managed_only:
return type(self)([ return type(self)([
role for _, role in self._roles.items() if role['managed'] role for _, role in self._roles.items() if role.managed
]) ])
elif role_ids is not None and managed_only: elif role_ids is not None and managed_only:
return type(self)([ return type(self)([
role for role_id, role in self._roles.items() role for role_id, role in self._roles.items()
if role_id in role_ids and role['managed'] if role_id in role_ids and role.managed
]) ])
elif role_ids is None and managed_only is False and role_names is not None: elif role_ids is None and managed_only is False and role_names is not None:
role_names = {self.sanitize_role_name(name).lower() for name in role_names} role_names = {Role.sanitize_name(name).lower() for name in role_names}
return type(self)([ return type(self)([
role for role in self._roles.values() role for role in self._roles.values()
if role["name"].lower() in role_names if role.name.lower() in role_names
]) ])
return copy(self) return copy(self)
def union(self, other: object) -> "DiscordRoles": def union(self, other: object) -> "RolesSet":
"""returns a new roles object that is the union of this roles object """Create instance that is the union of this roles object with other."""
with other"""
return type(self)(list(self) + list(other)) return type(self)(list(self) + list(other))
def difference(self, other: object) -> "DiscordRoles": def difference(self, other: object) -> "RolesSet":
"""returns a new roles object that only contains the roles """Create instance that only contains the roles
that exist in the current objects, but not in other that exist in the current objects, but not in other.
""" """
new_ids = self.ids().difference(other.ids()) new_ids = self.ids().difference(other.ids())
return self.subset(role_ids=new_ids) return self.subset(role_ids=new_ids)
def role_by_name(self, role_name: str) -> dict: def role_by_name(self, role_name: str) -> Optional[Role]:
"""returns role if one with matching name is found else an empty dict""" """Role if one with matching name is found else None."""
role_name = self.sanitize_role_name(role_name) role_name = Role.sanitize_name(role_name)
if role_name in self._roles_by_name: if role_name in self._roles_by_name:
return self._roles_by_name[role_name] return self._roles_by_name[role_name]
return dict() return None
@classmethod @classmethod
def create_from_matched_roles(cls, matched_roles: list) -> "DiscordRoles": def create_from_matched_roles(
"""returns a new object created from the given list of matches roles cls, matched_roles: List[Tuple[Role, bool]]
) -> "RolesSet":
"""Create new instance from the given list of matches roles.
matches_roles must be a list of tuples in the form: (role, created) Args:
matches_roles: list of matches roles
""" """
raw_roles = [x[0] for x in matched_roles] raw_roles = [x[0] for x in matched_roles]
return cls(raw_roles) return cls(raw_roles)
@staticmethod
def _assert_valid_role(role: dict) -> None:
if not isinstance(role, dict):
raise TypeError('Roles must be of type dict: %s' % role)
if 'id' not in role or 'name' not in role or 'managed' not in role:
raise ValueError('This role is not valid: %s' % role)
@classmethod
def sanitize_role_name(cls, role_name: str) -> str:
"""shortens too long strings if necessary"""
return str(role_name)[:cls._ROLE_NAME_MAX_CHARS]
def match_or_create_roles_from_names(
client: object, guild_id: int, role_names: list
) -> DiscordRoles:
"""Shortcut for getting the result of matching role names as DiscordRoles object"""
return DiscordRoles.create_from_matched_roles(
client.match_or_create_roles_from_names(
guild_id=guild_id, role_names=role_names
)
)

View File

@@ -0,0 +1,125 @@
"""Implementation of Discord objects used by this client.
Note that only those objects and properties are implemented, which are needed by AA.
Names and types are mirrored from the API whenever possible.
Discord's snowflake type (used by Discord IDs) is implemented as int.
"""
from dataclasses import asdict, dataclass
from typing import FrozenSet
@dataclass(frozen=True)
class User:
"""A user on Discord."""
id: int
username: str
discriminator: str
def __post_init__(self):
object.__setattr__(self, "id", int(self.id))
object.__setattr__(self, "username", str(self.username))
object.__setattr__(self, "discriminator", str(self.discriminator))
@classmethod
def from_dict(cls, data: dict) -> "User":
"""Create object from dictionary as received from the API."""
return cls(
id=int(data["id"]),
username=data["username"],
discriminator=data["discriminator"],
)
@dataclass(frozen=True)
class Role:
"""A role on Discord."""
_ROLE_NAME_MAX_CHARS = 100
id: int
name: str
managed: bool = False
def __post_init__(self):
object.__setattr__(self, "id", int(self.id))
object.__setattr__(self, "name", self.sanitize_name(self.name))
object.__setattr__(self, "managed", bool(self.managed))
def asdict(self) -> dict:
"""Convert object into a dictionary representation."""
return asdict(self)
@classmethod
def from_dict(cls, data: dict) -> "Role":
"""Create object from dictionary as received from the API."""
return cls(id=int(data["id"]), name=data["name"], managed=data["managed"])
@classmethod
def sanitize_name(cls, role_name: str) -> str:
"""Shorten too long names if necessary."""
return str(role_name)[: cls._ROLE_NAME_MAX_CHARS]
@dataclass(frozen=True)
class Guild:
"""A guild on Discord."""
id: int
name: str
roles: FrozenSet[Role]
def __post_init__(self):
object.__setattr__(self, "id", int(self.id))
object.__setattr__(self, "name", str(self.name))
for role in self.roles:
if not isinstance(role, Role):
raise TypeError("roles can only contain Role objects.")
object.__setattr__(self, "roles", frozenset(self.roles))
@classmethod
def from_dict(cls, data: dict) -> "Guild":
"""Create object from dictionary as received from the API."""
return cls(
id=int(data["id"]),
name=data["name"],
roles=frozenset(Role.from_dict(obj) for obj in data["roles"]),
)
@dataclass(frozen=True)
class GuildMember:
"""A member of a guild on Discord."""
_NICK_MAX_CHARS = 32
roles: FrozenSet[int]
nick: str = None
user: User = None
def __post_init__(self):
if self.nick:
object.__setattr__(self, "nick", self.sanitize_nick(self.nick))
if self.user and not isinstance(self.user, User):
raise TypeError("user must be of type User")
for role in self.roles:
if not isinstance(role, int):
raise TypeError("roles can only contain ints")
object.__setattr__(self, "roles", frozenset(self.roles))
@classmethod
def from_dict(cls, data: dict) -> "GuildMember":
"""Create object from dictionary as received from the API."""
params = {"roles": {int(obj) for obj in data["roles"]}}
if data.get("user"):
params["user"] = User.from_dict(data["user"])
if data.get("nick"):
params["nick"] = data["nick"]
return cls(**params)
@classmethod
def sanitize_nick(cls, nick: str) -> str:
"""Sanitize a nick, i.e. shorten too long strings if necessary."""
return str(nick)[: cls._NICK_MAX_CHARS]

View File

@@ -1,40 +0,0 @@
TEST_GUILD_ID = 123456789012345678
TEST_USER_ID = 198765432012345678
TEST_USER_NAME = 'Peter Parker'
TEST_USER_DISCRIMINATOR = '1234'
TEST_BOT_TOKEN = 'abcdefhijlkmnopqastzvwxyz1234567890ABCDEFGHOJKLMNOPQRSTUVWXY'
TEST_ROLE_ID = 654321012345678912
def create_role(id: int, name: str, managed=False) -> dict:
return {
'id': int(id),
'name': str(name),
'managed': bool(managed)
}
def create_matched_role(role, created=False) -> tuple:
return role, created
ROLE_ALPHA = create_role(1, 'alpha')
ROLE_BRAVO = create_role(2, 'bravo')
ROLE_CHARLIE = create_role(3, 'charlie')
ROLE_CHARLIE_2 = create_role(4, 'Charlie') # Discord roles are case sensitive
ROLE_MIKE = create_role(13, 'mike', True)
ALL_ROLES = [ROLE_ALPHA, ROLE_BRAVO, ROLE_CHARLIE, ROLE_MIKE]
def create_user_info(
id: int = TEST_USER_ID,
username: str = TEST_USER_NAME,
discriminator: str = TEST_USER_DISCRIMINATOR
):
return {
'id': str(id),
'username': str(username[:32]),
'discriminator': str(discriminator[:4])
}

View File

@@ -0,0 +1,155 @@
{
"guilds": {
"2909267986263572999": {
"id": "2909267986263572999",
"name": "Mason's Test Server",
"icon": "389030ec9db118cb5b85a732333b7c98",
"description": null,
"splash": "75610b05a0dd09ec2c3c7df9f6975ea0",
"discovery_splash": null,
"approximate_member_count": 2,
"approximate_presence_count": 2,
"features": [
"INVITE_SPLASH",
"VANITY_URL",
"COMMERCE",
"BANNER",
"NEWS",
"VERIFIED",
"VIP_REGIONS"
],
"emojis": [
{
"name": "ultrafastparrot",
"roles": [],
"id": "393564762228785161",
"require_colons": true,
"managed": false,
"animated": true,
"available": true
}
],
"banner": "5c3cb8d1bc159937fffe7e641ec96ca7",
"owner_id": "53908232506183680",
"application_id": null,
"region": null,
"afk_channel_id": null,
"afk_timeout": 300,
"system_channel_id": null,
"widget_enabled": true,
"widget_channel_id": "639513352485470208",
"verification_level": 0,
"roles": [
{
"id": "2909267986263572999",
"name": "@everyone",
"permissions": "49794752",
"position": 0,
"color": 0,
"hoist": false,
"managed": false,
"mentionable": false
}
],
"default_message_notifications": 1,
"mfa_level": 0,
"explicit_content_filter": 0,
"max_presences": null,
"max_members": 250000,
"max_video_channel_users": 25,
"vanity_url_code": "no",
"premium_tier": 0,
"premium_subscription_count": 0,
"system_channel_flags": 0,
"preferred_locale": "en-US",
"rules_channel_id": null,
"public_updates_channel_id": null
}
},
"guildMembers": {
"1": {
"user": {},
"nick": null,
"avatar": null,
"roles": [],
"joined_at": "2015-04-26T06:26:56.936000+00:00",
"deaf": false,
"mute": false
},
"2": {
"user": {
"id": "80351110224678912",
"username": "Nelly",
"discriminator": "1337",
"avatar": "8342729096ea3675442027381ff50dfe",
"verified": true,
"email": "nelly@discord.com",
"flags": 64,
"banner": "06c16474723fe537c283b8efa61a30c8",
"accent_color": 16711680,
"premium_type": 1,
"public_flags": 64
},
"nick": "Nelly the great",
"avatar": null,
"roles": [
"197150972374548480",
"41771983423143936"
],
"joined_at": "2015-04-26T06:26:56.936000+00:00",
"deaf": false,
"mute": false
}
},
"roles": {
"197150972374548480": {
"id": "197150972374548480",
"name": "My Managed Role",
"color": 3447003,
"hoist": false,
"icon": "cf3ced8600b777c9486c6d8d84fb4327",
"unicode_emoji": null,
"position": 2,
"permissions": "66321471",
"managed": true,
"mentionable": false
},
"2909267986263572999": {
"id": "2909267986263572999",
"name": "@everyone",
"permissions": "49794752",
"position": 0,
"color": 0,
"hoist": false,
"managed": false,
"mentionable": false
},
"41771983423143936": {
"id": "41771983423143936",
"name": "WE DEM BOYZZ!!!!!!",
"color": 3447003,
"hoist": true,
"icon": "cf3ced8600b777c9486c6d8d84fb4327",
"unicode_emoji": null,
"position": 1,
"permissions": "66321471",
"managed": false,
"mentionable": false
}
},
"users": {
"80351110224678912": {
"id": "80351110224678912",
"username": "Nelly",
"discriminator": "1337",
"avatar": "8342729096ea3675442027381ff50dfe",
"verified": true,
"email": "nelly@discord.com",
"flags": 64,
"banner": "06c16474723fe537c283b8efa61a30c8",
"accent_color": 16711680,
"premium_type": 1,
"public_flags": 64
}
}
}

View File

@@ -0,0 +1,110 @@
from itertools import count
from django.utils.timezone import now
from ..client import DiscordApiStatusCode
from ..models import Guild, GuildMember, Role, User
TEST_GUILD_ID = 123456789012345678
TEST_GUILD_NAME = "Test Guild"
TEST_USER_ID = 198765432012345678
TEST_USER_NAME = "Peter Parker"
TEST_USER_DISCRIMINATOR = "1234"
TEST_BOT_TOKEN = "abcdefhijlkmnopqastzvwxyz1234567890ABCDEFGHOJKLMNOPQRSTUVWXY"
TEST_ROLE_ID = 654321012345678912
def create_discord_role_object(id: int, name: str, managed: bool = False) -> dict:
return {"id": str(int(id)), "name": str(name), "managed": bool(managed)}
def create_matched_role(role, created=False) -> tuple:
return role, created
def create_discord_user_object(**kwargs):
params = {
"id": TEST_USER_ID,
"username": TEST_USER_NAME,
"discriminator": TEST_USER_DISCRIMINATOR,
}
params.update(kwargs)
params["id"] = str(int(params["id"]))
return params
def create_discord_guild_member_object(user=None, **kwargs):
user_params = {}
if user:
user_params["user"] = user
params = {
"user": create_discord_user_object(**user_params),
"roles": [],
"joined_at": now().isoformat(),
"deaf": False,
"mute": False,
}
params.update(kwargs)
params["roles"] = [str(int(obj)) for obj in params["roles"]]
return params
def create_discord_error_response(code: int) -> dict:
return {"code": int(code)}
def create_discord_error_response_unknown_member() -> dict:
return create_discord_error_response(DiscordApiStatusCode.UNKNOWN_MEMBER.value)
def create_discord_guild_object(**kwargs):
params = {"id": TEST_GUILD_ID, "name": TEST_GUILD_NAME, "roles": []}
params.update(kwargs)
params["id"] = str(int(params["id"]))
return params
def create_user(**kwargs):
params = {
"id": TEST_USER_ID,
"username": TEST_USER_NAME,
"discriminator": TEST_USER_DISCRIMINATOR,
}
params.update(kwargs)
return User(**params)
def create_guild(**kwargs):
params = {"id": TEST_GUILD_ID, "name": TEST_GUILD_NAME, "roles": []}
params.update(kwargs)
return Guild(**params)
def create_guild_member(**kwargs):
params = {"user": create_user(), "roles": []}
params.update(kwargs)
return GuildMember(**params)
def create_role(**kwargs) -> dict:
params = {"managed": False}
params.update(kwargs)
if "id" not in params:
params["id"] = next_number("role")
if "name" not in params:
params["name"] = f"Test Role #{params['id']}"
return Role(**params)
def next_number(key: str = None) -> int:
"""Calculate the next number in a persistent sequence."""
if key is None:
key = "_general"
try:
return next_number._counter[key].__next__()
except AttributeError:
next_number._counter = dict()
except KeyError:
pass
next_number._counter[key] = count(start=1)
return next_number._counter[key].__next__()

View File

@@ -1,177 +1,201 @@
from unittest import TestCase from allianceauth.utils.testing import NoSocketsTestCase
from . import ( from ..helpers import RolesSet
ROLE_ALPHA, from .factories import create_matched_role, create_role
ROLE_BRAVO,
ROLE_CHARLIE,
ROLE_CHARLIE_2,
ROLE_MIKE,
ALL_ROLES,
create_role
)
from .. import DiscordRoles
MODULE_PATH = 'allianceauth.services.modules.discord.discord_client.client' class TestRolesSet(NoSocketsTestCase):
class TestDiscordRoles(TestCase):
def setUp(self):
self.all_roles = DiscordRoles(ALL_ROLES)
def test_can_create_simple(self): def test_can_create_simple(self):
roles_raw = [ROLE_ALPHA] # given
roles = DiscordRoles(roles_raw) roles_raw = [create_role()]
# when
roles = RolesSet(roles_raw)
# then
self.assertListEqual(list(roles), roles_raw) self.assertListEqual(list(roles), roles_raw)
def test_can_create_empty(self): def test_can_create_empty(self):
roles_raw = [] # when
roles = DiscordRoles(roles_raw) roles = RolesSet([])
# then
self.assertListEqual(list(roles), []) self.assertListEqual(list(roles), [])
def test_raises_exception_if_roles_raw_of_wrong_type(self): def test_raises_exception_if_roles_raw_of_wrong_type(self):
with self.assertRaises(TypeError): with self.assertRaises(TypeError):
DiscordRoles({'id': 1}) RolesSet({"id": 1})
def test_raises_exception_if_list_contains_non_dict(self): def test_raises_exception_if_list_contains_non_dict(self):
roles_raw = [ROLE_ALPHA, 'not_valid'] # given
roles_raw = [create_role(), "not_valid"]
# when/then
with self.assertRaises(TypeError): with self.assertRaises(TypeError):
DiscordRoles(roles_raw) RolesSet(roles_raw)
def test_raises_exception_if_invalid_role_1(self):
roles_raw = [{'name': 'alpha', 'managed': False}]
with self.assertRaises(ValueError):
DiscordRoles(roles_raw)
def test_raises_exception_if_invalid_role_2(self):
roles_raw = [{'id': 1, 'managed': False}]
with self.assertRaises(ValueError):
DiscordRoles(roles_raw)
def test_raises_exception_if_invalid_role_3(self):
roles_raw = [{'id': 1, 'name': 'alpha'}]
with self.assertRaises(ValueError):
DiscordRoles(roles_raw)
def test_roles_are_equal(self): def test_roles_are_equal(self):
roles_a = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO]) # given
roles_b = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO]) role_a = create_role()
role_b = create_role()
roles_a = RolesSet([role_a, role_b])
roles_b = RolesSet([role_a, role_b])
# when/then
self.assertEqual(roles_a, roles_b) self.assertEqual(roles_a, roles_b)
def test_roles_are_not_equal(self): def test_roles_are_not_equal(self):
roles_a = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO]) # given
roles_b = DiscordRoles([ROLE_ALPHA]) role_a = create_role()
role_b = create_role()
roles_a = RolesSet([role_a, role_b])
roles_b = RolesSet([role_a])
# when/then
self.assertNotEqual(roles_a, roles_b) self.assertNotEqual(roles_a, roles_b)
def test_different_objects_are_not_equal(self): def test_different_objects_are_not_equal(self):
roles_a = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO]) roles_a = RolesSet([])
self.assertFalse(roles_a == "invalid") self.assertFalse(roles_a == "invalid")
def test_len(self): def test_len(self):
self.assertEqual(len(self.all_roles), 4) # given
role_a = create_role()
role_b = create_role()
roles = RolesSet([role_a, role_b])
# when/then
self.assertEqual(len(roles), 2)
def test_contains(self): def test_contains(self):
self.assertTrue(1 in self.all_roles) # given
self.assertFalse(99 in self.all_roles) role_a = create_role(id=1)
roles = RolesSet([role_a])
def test_sanitize_role_name(self): # when/then
role_name_input = 'x' * 110 self.assertTrue(1 in roles)
role_name_expected = 'x' * 100 self.assertFalse(99 in roles)
result = DiscordRoles.sanitize_role_name(role_name_input)
self.assertEqual(result, role_name_expected)
def test_objects_are_hashable(self): def test_objects_are_hashable(self):
roles_a = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO]) # given
roles_b = DiscordRoles([ROLE_BRAVO, ROLE_ALPHA]) role_a = create_role()
roles_c = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO, ROLE_MIKE]) role_b = create_role()
role_c = create_role()
roles_a = RolesSet([role_a, role_b])
roles_b = RolesSet([role_b, role_a])
roles_c = RolesSet([role_a, role_b, role_c])
# when/then
self.assertIsNotNone(hash(roles_a)) self.assertIsNotNone(hash(roles_a))
self.assertEqual(hash(roles_a), hash(roles_b)) self.assertEqual(hash(roles_a), hash(roles_b))
self.assertNotEqual(hash(roles_a), hash(roles_c)) self.assertNotEqual(hash(roles_a), hash(roles_c))
def test_create_from_matched_roles(self): def test_create_from_matched_roles(self):
role_a = create_role()
role_b = create_role()
matched_roles = [ matched_roles = [
(ROLE_ALPHA, True), create_matched_role(role_a, True),
(ROLE_BRAVO, False) create_matched_role(role_b, False),
] ]
roles = DiscordRoles.create_from_matched_roles(matched_roles) # when
self.assertSetEqual(roles.ids(), {1, 2}) roles = RolesSet.create_from_matched_roles(matched_roles)
# then
self.assertEqual(roles, RolesSet([role_a, role_b]))
class TestIds(TestCase):
def setUp(self):
self.all_roles = DiscordRoles(ALL_ROLES)
def test_return_role_ids_default(self): def test_return_role_ids_default(self):
result = self.all_roles.ids() role_a = create_role(id=1)
expected = {1, 2, 3, 13} role_b = create_role(id=2)
self.assertSetEqual(result, expected) roles = RolesSet([role_a, role_b])
# when/then
self.assertSetEqual(roles.ids(), {1, 2})
def test_return_role_ids_empty(self): def test_return_role_ids_empty(self):
roles = DiscordRoles([]) # given
roles = RolesSet([])
# when/then
self.assertSetEqual(roles.ids(), set()) self.assertSetEqual(roles.ids(), set())
class TestSubset(TestCase): class TestRolesSetSubset(NoSocketsTestCase):
def setUp(self):
self.all_roles = DiscordRoles(ALL_ROLES)
def test_ids_only(self): def test_ids_only(self):
role_ids = {1, 3} # given
roles_subset = self.all_roles.subset(role_ids) role_a = create_role(id=1)
expected = {1, 3} role_b = create_role(id=2)
self.assertSetEqual(roles_subset.ids(), expected) role_c = create_role(id=3)
roles_all = RolesSet([role_a, role_b, role_c])
# when
roles_subset = roles_all.subset({1, 3})
# then
self.assertEqual(roles_subset, RolesSet([role_a, role_c]))
def test_ids_as_string_work_too(self): def test_ids_as_string_work_too(self):
role_ids = {'1', '3'} # given
roles_subset = self.all_roles.subset(role_ids) role_a = create_role(id=1)
expected = {1, 3} role_b = create_role(id=2)
self.assertSetEqual(roles_subset.ids(), expected) role_c = create_role(id=3)
roles_all = RolesSet([role_a, role_b, role_c])
# when
roles_subset = roles_all.subset({"1", "3"})
# then
self.assertEqual(roles_subset, RolesSet([role_a, role_c]))
def test_managed_only(self): def test_managed_only(self):
roles = self.all_roles.subset(managed_only=True) # given
expected = {13} role_a = create_role(id=1)
self.assertSetEqual(roles.ids(), expected) role_m = create_role(id=13, managed=True)
roles_all = RolesSet([role_a, role_m])
# when
roles_subset = roles_all.subset(managed_only=True)
# then
self.assertEqual(roles_subset, RolesSet([role_m]))
def test_ids_and_managed_only(self): def test_ids_and_managed_only(self):
role_ids = {1, 3, 13} # given
roles_subset = self.all_roles.subset(role_ids, managed_only=True) role_a = create_role(id=1)
expected = {13} role_b = create_role(id=2)
self.assertSetEqual(roles_subset.ids(), expected) role_m = create_role(id=13, managed=True)
roles_all = RolesSet([role_a, role_b, role_m])
# when
roles_subset = roles_all.subset({1, 13}, managed_only=True)
# then
self.assertEqual(roles_subset, RolesSet([role_m]))
def test_ids_are_empty(self): def test_ids_are_empty(self):
roles = self.all_roles.subset([]) # given
expected = set() role_a = create_role(id=1)
self.assertSetEqual(roles.ids(), expected) role_b = create_role(id=2)
roles_all = RolesSet([role_a, role_b])
roles_subset = roles_all.subset([])
# then
self.assertEqual(roles_subset, RolesSet([]))
def test_no_parameters(self): def test_no_parameters(self):
roles = self.all_roles.subset() # given
expected = {1, 2, 3, 13} role_a = create_role(id=1)
self.assertSetEqual(roles.ids(), expected) role_b = create_role(id=2)
roles_all = RolesSet([role_a, role_b])
roles_subset = roles_all.subset()
# then
self.assertEqual(roles_subset, roles_all)
def test_should_return_role_names_only(self): def test_should_return_role_names_only(self):
# given # given
all_roles = DiscordRoles([ role_a = create_role(name="alpha")
ROLE_ALPHA, ROLE_BRAVO, ROLE_CHARLIE, ROLE_MIKE, ROLE_CHARLIE_2 role_b = create_role(name="bravo")
]) role_c1 = create_role(name="charlie")
role_c2 = create_role(name="Charlie")
roles_all = RolesSet([role_a, role_b, role_c1, role_c2])
# when # when
roles = all_roles.subset(role_names={"bravo", "charlie"}) roles_subset = roles_all.subset(role_names={"bravo", "charlie"})
# then # then
self.assertSetEqual(roles.ids(), {2, 3, 4}) self.assertSetEqual(roles_subset, RolesSet([role_b, role_c1, role_c2]))
class TestHasRoles(TestCase): class TestRolesSetHasRoles(NoSocketsTestCase):
@classmethod
def setUp(self): def setUpClass(cls):
self.all_roles = DiscordRoles(ALL_ROLES) super().setUpClass()
role_a = create_role(id=1)
role_b = create_role(id=2)
role_c = create_role(id=3)
cls.all_roles = RolesSet([role_a, role_b, role_c])
def test_true_if_all_roles_exit(self): def test_true_if_all_roles_exit(self):
self.assertTrue(self.all_roles.has_roles([1, 2])) self.assertTrue(self.all_roles.has_roles([1, 2]))
def test_true_if_all_roles_exit_str(self): def test_true_if_all_roles_exit_str(self):
self.assertTrue(self.all_roles.has_roles(['1', '2'])) self.assertTrue(self.all_roles.has_roles(["1", "2"]))
def test_false_if_role_does_not_exit(self): def test_false_if_role_does_not_exit(self):
self.assertFalse(self.all_roles.has_roles([99])) self.assertFalse(self.all_roles.has_roles([99]))
@@ -183,74 +207,104 @@ class TestHasRoles(TestCase):
self.assertTrue(self.all_roles.has_roles([])) self.assertTrue(self.all_roles.has_roles([]))
class TestGetMatchingRolesByName(TestCase): class TestRolesSetGetMatchingRolesByName(NoSocketsTestCase):
def setUp(self):
self.all_roles = DiscordRoles(ALL_ROLES)
def test_return_role_if_matches(self): def test_return_role_if_matches(self):
role_name = 'alpha' # given
expected = ROLE_ALPHA role_a = create_role(name="alpha")
result = self.all_roles.role_by_name(role_name) role_b = create_role(name="bravo")
self.assertEqual(result, expected) roles = RolesSet([role_a, role_b])
# when
result = roles.role_by_name("alpha")
# then
self.assertEqual(result, role_a)
def test_return_role_if_matches_and_limit_max_length(self): def test_return_role_if_matches_and_limit_max_length(self):
role_name = 'x' * 120 # given
expected = create_role(77, 'x' * 100) role_name = "x" * 120
roles = DiscordRoles([expected]) role = create_role(name="x" * 100)
roles = RolesSet([role])
# when
result = roles.role_by_name(role_name) result = roles.role_by_name(role_name)
self.assertEqual(result, expected) # then
self.assertEqual(result, role)
def test_return_empty_if_not_matches(self): def test_return_empty_if_not_matches(self):
role_name = 'lima' # given
expected = {} role_a = create_role(name="alpha")
result = self.all_roles.role_by_name(role_name) role_b = create_role(name="bravo")
self.assertEqual(result, expected) roles = RolesSet([role_a, role_b])
# when
result = roles.role_by_name("unknown")
# then
self.assertIsNone(result)
class TestUnion(TestCase): class TestRolesSetUnion(NoSocketsTestCase):
def test_distinct_sets(self): def test_distinct_sets(self):
roles_1 = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO]) # given
roles_2 = DiscordRoles([ROLE_CHARLIE, ROLE_MIKE]) role_a = create_role()
roles_3 = roles_1.union(roles_2) role_b = create_role()
expected = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO, ROLE_CHARLIE, ROLE_MIKE]) roles_1 = RolesSet([role_a])
self.assertEqual(roles_3, expected) roles_2 = RolesSet([role_b])
# when
result = roles_1.union(roles_2)
# then
self.assertEqual(result, RolesSet([role_a, role_b]))
def test_overlapping_sets(self): def test_overlapping_sets(self):
roles_1 = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO]) # given
roles_2 = DiscordRoles([ROLE_BRAVO, ROLE_MIKE]) role_a = create_role()
roles_3 = roles_1.union(roles_2) role_b = create_role()
expected = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO, ROLE_MIKE]) role_c = create_role()
self.assertEqual(roles_3, expected) roles_1 = RolesSet([role_a, role_b])
roles_2 = RolesSet([role_b, role_c])
# when
result = roles_1.union(roles_2)
self.assertEqual(result, RolesSet([role_a, role_b, role_c]))
def test_identical_sets(self): def test_identical_sets(self):
roles_1 = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO]) role_a = create_role()
roles_2 = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO]) role_b = create_role()
roles_3 = roles_1.union(roles_2) roles_1 = RolesSet([role_a, role_b])
expected = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO]) roles_2 = RolesSet([role_a, role_b])
self.assertEqual(roles_3, expected) # when
result = roles_1.union(roles_2)
self.assertEqual(result, RolesSet([role_a, role_b]))
class TestDifference(TestCase): class TestRolesSetDifference(NoSocketsTestCase):
def test_distinct_sets(self): def test_distinct_sets(self):
roles_1 = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO]) # given
roles_2 = DiscordRoles([ROLE_CHARLIE, ROLE_MIKE]) role_a = create_role()
roles_3 = roles_1.difference(roles_2) role_b = create_role()
expected = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO]) role_c = create_role()
self.assertEqual(roles_3, expected) role_d = create_role()
roles_1 = RolesSet([role_a, role_b])
roles_2 = RolesSet([role_c, role_d])
# when
result = roles_1.difference(roles_2)
# then
self.assertEqual(result, RolesSet([role_a, role_b]))
def test_overlapping_sets(self): def test_overlapping_sets(self):
roles_1 = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO]) # given
roles_2 = DiscordRoles([ROLE_BRAVO, ROLE_MIKE]) role_a = create_role()
roles_3 = roles_1.difference(roles_2) role_b = create_role()
expected = DiscordRoles([ROLE_ALPHA]) role_c = create_role()
self.assertEqual(roles_3, expected) roles_1 = RolesSet([role_a, role_b])
roles_2 = RolesSet([role_b, role_c])
# when
result = roles_1.difference(roles_2)
# then
self.assertEqual(result, RolesSet([role_a]))
def test_identical_sets(self): def test_identical_sets(self):
roles_1 = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO]) # given
roles_2 = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO]) role_a = create_role()
roles_3 = roles_1.difference(roles_2) role_b = create_role()
expected = DiscordRoles([]) roles_1 = RolesSet([role_a, role_b])
self.assertEqual(roles_3, expected) roles_2 = RolesSet([role_a, role_b])
# when
result = roles_1.difference(roles_2)
# then
self.assertEqual(result, RolesSet([]))

View File

@@ -0,0 +1,156 @@
import json
from pathlib import Path
from unittest import TestCase
from ..models import Guild, GuildMember, Role, User
from .factories import create_guild, create_guild_member, create_role, create_user
def _fetch_example_objects() -> dict:
path = Path(__file__).parent / "example_objects.json"
with path.open("r", encoding="utf-8") as fp:
return json.load(fp)
class TestUser(TestCase):
def test_should_create_new_object(self):
# when
obj = User(id="42", username=123, discriminator=456)
# then
self.assertEqual(obj.id, 42)
self.assertEqual(obj.username, "123")
self.assertTrue(obj.discriminator, "456")
def test_should_create_from_dict(self):
# given
data = example_objects["users"]["80351110224678912"]
# when
obj = User.from_dict(data)
# then
self.assertEqual(obj.id, 80351110224678912)
self.assertEqual(obj.username, "Nelly")
self.assertEqual(obj.discriminator, "1337")
class TestRole(TestCase):
def test_should_create_new_object_with_defaults(self):
# when
obj = Role(id="42", name="x" * 110)
# then
self.assertEqual(obj.id, 42)
self.assertEqual(obj.name, "x" * 100)
self.assertFalse(obj.managed)
def test_should_create_new_object(self):
# when
obj = Role(id=42, name="name", managed=1)
# then
self.assertEqual(obj.id, 42)
self.assertEqual(obj.name, "name")
self.assertTrue(obj.managed)
def test_should_create_from_dict(self):
# given
data = example_objects["roles"]["41771983423143936"]
# when
obj = Role.from_dict(data)
# then
self.assertEqual(obj.id, 41771983423143936)
self.assertEqual(obj.name, "WE DEM BOYZZ!!!!!!")
self.assertFalse(obj.managed)
def test_should_convert_to_dict(self):
# given
role = create_role(id=42, name="Special Name", managed=True)
# when/then
self.assertDictEqual(
role.asdict(), {"id": 42, "name": "Special Name", "managed": True}
)
def test_sanitize_role_name(self):
# given
role_name_input = "x" * 110
role_name_expected = "x" * 100
# when
result = Role.sanitize_name(role_name_input)
# then
self.assertEqual(result, role_name_expected)
class TestGuild(TestCase):
def test_should_create_new_object(self):
# given
role_a = create_role()
# when
obj = Guild(id="42", name=123, roles=[role_a])
# then
self.assertEqual(obj.id, 42)
self.assertEqual(obj.name, "123")
self.assertEqual(obj.roles, frozenset([role_a]))
def test_should_create_from_dict(self):
# given
data = example_objects["guilds"]["2909267986263572999"]
# when
obj = Guild.from_dict(data)
# then
self.assertEqual(obj.id, 2909267986263572999)
self.assertEqual(obj.name, "Mason's Test Server")
(first_role,) = obj.roles
self.assertEqual(first_role.id, 2909267986263572999)
def test_should_raise_error_when_role_type_is_wrong(self):
with self.assertRaises(TypeError):
create_guild(roles=[create_role(), "invalid"])
class TestGuildMember(TestCase):
def test_should_create_new_object(self):
# given
user = create_user()
# when
obj = GuildMember(user=user, nick="x" * 40, roles=[1, 2])
# then
self.assertEqual(obj.user, user)
self.assertEqual(obj.nick, "x" * 32)
self.assertEqual(obj.roles, frozenset([1, 2]))
def test_should_create_from_dict_empty(self):
# given
data = example_objects["guildMembers"]["1"]
# when
obj = GuildMember.from_dict(data)
# then
self.assertIsNone(obj.user)
self.assertSetEqual(obj.roles, set())
self.assertIsNone(obj.nick)
def test_should_create_from_dict_full(self):
# given
data = example_objects["guildMembers"]["2"]
# when
obj = GuildMember.from_dict(data)
# then
self.assertEqual(obj.user.username, "Nelly")
self.assertSetEqual(obj.roles, {197150972374548480, 41771983423143936})
self.assertEqual(obj.nick, "Nelly the great")
def test_should_raise_error_when_user_type_is_wrong(self):
with self.assertRaises(TypeError):
create_guild_member(user="invalid")
def test_should_raise_error_when_role_type_is_wrong(self):
with self.assertRaises(TypeError):
GuildMember(roles=[1, 2, "invalid"])
def test_sanitize_nick(self):
# given
nick_input = "x" * 40
nick_expected = "x" * 32
# when
result = GuildMember.sanitize_nick(nick_input)
# then
self.assertEqual(result, nick_expected)
example_objects = _fetch_example_objects()

View File

@@ -1,30 +1,33 @@
import logging import logging
from urllib.parse import urlencode from urllib.parse import urlencode
from requests_oauthlib import OAuth2Session
from requests.exceptions import HTTPError from requests.exceptions import HTTPError
from requests_oauthlib import OAuth2Session
from django.contrib.auth.models import User, Group from django.contrib.auth.models import Group, User
from django.db import models from django.db import models
from django.utils.timezone import now from django.utils.timezone import now
from allianceauth.services.hooks import NameFormatter
from . import __title__ from . import __title__
from .app_settings import ( from .app_settings import (
DISCORD_APP_ID, DISCORD_APP_ID,
DISCORD_APP_SECRET, DISCORD_APP_SECRET,
DISCORD_BOT_TOKEN,
DISCORD_CALLBACK_URL, DISCORD_CALLBACK_URL,
DISCORD_GUILD_ID, DISCORD_GUILD_ID,
DISCORD_SYNC_NAMES DISCORD_SYNC_NAMES,
)
from .core import calculate_roles_for_user, create_bot_client
from .core import group_to_role as core_group_to_role
from .core import server_name as core_server_name
from .core import user_formatted_nick
from .discord_client import (
DISCORD_OAUTH_BASE_URL,
DISCORD_OAUTH_TOKEN_URL,
DiscordApiBackoff,
DiscordClient,
) )
from .discord_client import DiscordClient
from .discord_client.exceptions import DiscordClientException, DiscordApiBackoff
from .discord_client.helpers import match_or_create_roles_from_names
from .utils import LoggerAddTag from .utils import LoggerAddTag
logger = LoggerAddTag(logging.getLogger(__name__), __title__) logger = LoggerAddTag(logging.getLogger(__name__), __title__)
@@ -56,79 +59,68 @@ class DiscordUserManager(models.Manager):
Returns: True on success, else False or raises exception Returns: True on success, else False or raises exception
""" """
try: try:
nickname = self.user_formatted_nick(user) if DISCORD_SYNC_NAMES else None nickname = user_formatted_nick(user) if DISCORD_SYNC_NAMES else None
group_names = self.user_group_names(user)
access_token = self._exchange_auth_code_for_token(authorization_code) access_token = self._exchange_auth_code_for_token(authorization_code)
user_client = DiscordClient(access_token, is_rate_limited=is_rate_limited) user_client = DiscordClient(access_token, is_rate_limited=is_rate_limited)
discord_user = user_client.current_user() discord_user = user_client.current_user()
user_id = discord_user['id'] bot_client = create_bot_client(is_rate_limited=is_rate_limited)
bot_client = self._bot_client(is_rate_limited=is_rate_limited) roles, changed = calculate_roles_for_user(
user=user, client=bot_client, discord_uid=discord_user.id
if group_names:
role_ids = match_or_create_roles_from_names(
client=bot_client,
guild_id=DISCORD_GUILD_ID,
role_names=group_names
).ids()
else:
role_ids = None
created = bot_client.add_guild_member(
guild_id=DISCORD_GUILD_ID,
user_id=user_id,
access_token=access_token,
role_ids=role_ids,
nick=nickname
) )
if created is not False: if changed is None:
if created is None: # Handle new member
logger.debug( created = bot_client.add_guild_member(
"User %s with Discord ID %s is already a member. Forcing a Refresh", guild_id=DISCORD_GUILD_ID,
user_id=discord_user.id,
access_token=access_token,
role_ids=list(roles.ids()),
nick=nickname
)
if not created:
logger.warning(
"Failed to add user %s with Discord ID %s to Discord server",
user, user,
user_id, discord_user.id,
) )
return False
# Force an update cause the discord API won't do it for us.
if role_ids:
role_ids = list(role_ids)
updated = bot_client.modify_guild_member(
guild_id=DISCORD_GUILD_ID,
user_id=user_id,
role_ids=role_ids,
nick=nickname
)
if not updated:
# Could not update the new user so fail.
logger.warning(
"Failed to add user %s with Discord ID %s to Discord server",
user,
user_id,
)
return False
self.update_or_create(
user=user,
defaults={
'uid': user_id,
'username': discord_user['username'][:32],
'discriminator': discord_user['discriminator'][:4],
'activated': now()
}
)
logger.info(
"Added user %s with Discord ID %s to Discord server", user, user_id
)
return True
else: else:
logger.warning( # Handle existing member
"Failed to add user %s with Discord ID %s to Discord server", logger.debug(
"User %s with Discord ID %s is already a member. Forcing a Refresh",
user, user,
user_id, discord_user.id,
) )
return False # Force an update cause the discord API won't do it for us.
updated = bot_client.modify_guild_member(
guild_id=DISCORD_GUILD_ID,
user_id=discord_user.id,
role_ids=list(roles.ids()),
nick=nickname
)
if not updated:
# Could not update the new user so fail.
logger.warning(
"Failed to add user %s with Discord ID %s to Discord server",
user,
discord_user.id,
)
return False
self.update_or_create(
user=user,
defaults={
'uid': discord_user.id,
'username': discord_user.username[:32],
'discriminator': discord_user.discriminator[:4],
'activated': now()
}
)
logger.info(
"Added user %s with Discord ID %s to Discord server",
user,
discord_user.id
)
return True
except (HTTPError, ConnectionError, DiscordApiBackoff) as ex: except (HTTPError, ConnectionError, DiscordApiBackoff) as ex:
logger.exception( logger.exception(
@@ -136,31 +128,6 @@ class DiscordUserManager(models.Manager):
) )
return False return False
@staticmethod
def user_formatted_nick(user: User) -> str:
"""returns the name of the given users main character with name formatting
or None if user has no main
"""
from .auth_hooks import DiscordService
if user.profile.main_character:
return NameFormatter(DiscordService(), user).format_name()
else:
return None
@staticmethod
def user_group_names(user: User, state_name: str = None) -> list:
"""returns list of group names plus state the given user is a member of"""
if not state_name:
state_name = user.profile.state.name
group_names = (
[group.name for group in user.groups.all()] + [state_name]
)
logger.debug(
"Group names for roles updates of user %s are: %s", user, group_names
)
return group_names
def user_has_account(self, user: User) -> bool: def user_has_account(self, user: User) -> bool:
"""Returns True if the user has an Discord account, else False """Returns True if the user has an Discord account, else False
@@ -178,60 +145,41 @@ class DiscordUserManager(models.Manager):
'permissions': str(cls.BOT_PERMISSIONS) 'permissions': str(cls.BOT_PERMISSIONS)
}) })
return f'{DiscordClient.OAUTH_BASE_URL}?{params}' return f'{DISCORD_OAUTH_BASE_URL}?{params}'
@classmethod @classmethod
def generate_oauth_redirect_url(cls) -> str: def generate_oauth_redirect_url(cls) -> str:
oauth = OAuth2Session( oauth = OAuth2Session(
DISCORD_APP_ID, redirect_uri=DISCORD_CALLBACK_URL, scope=cls.SCOPES DISCORD_APP_ID, redirect_uri=DISCORD_CALLBACK_URL, scope=cls.SCOPES
) )
url, state = oauth.authorization_url(DiscordClient.OAUTH_BASE_URL) url, _ = oauth.authorization_url(DISCORD_OAUTH_BASE_URL)
return url return url
@staticmethod @staticmethod
def _exchange_auth_code_for_token(authorization_code: str) -> str: def _exchange_auth_code_for_token(authorization_code: str) -> str:
oauth = OAuth2Session(DISCORD_APP_ID, redirect_uri=DISCORD_CALLBACK_URL) oauth = OAuth2Session(DISCORD_APP_ID, redirect_uri=DISCORD_CALLBACK_URL)
token = oauth.fetch_token( token = oauth.fetch_token(
DiscordClient.OAUTH_TOKEN_URL, DISCORD_OAUTH_TOKEN_URL,
client_secret=DISCORD_APP_SECRET, client_secret=DISCORD_APP_SECRET,
code=authorization_code code=authorization_code
) )
logger.debug("Received token from OAuth") logger.debug("Received token from OAuth")
return token['access_token'] return token['access_token']
@classmethod @staticmethod
def server_name(cls, use_cache: bool = True) -> str: def group_to_role(group: Group) -> dict:
"""returns the name of the current Discord server """Fetch the Discord role matching the given Django group by name.
or an empty string if the name could not be retrieved
Params: Returns:
- use_cache: When set False will force an API call to get the server name - Discord role as dict
- empty dict if no matching role found
""" """
try: role = core_group_to_role(group)
server_name = cls._bot_client().guild_name( return role.asdict() if role else dict()
guild_id=DISCORD_GUILD_ID, use_cache=use_cache
)
except (HTTPError, DiscordClientException):
server_name = ""
except Exception:
logger.warning(
"Unexpected error when trying to retrieve the server name from Discord",
exc_info=True
)
server_name = ""
return server_name
@classmethod
def group_to_role(cls, group: Group) -> dict:
"""returns the Discord role matching the given Django group by name
or an empty dict() if no matching role exist
"""
return cls._bot_client().match_role_from_name(
guild_id=DISCORD_GUILD_ID, role_name=group.name
)
@staticmethod @staticmethod
def _bot_client(is_rate_limited: bool = True) -> DiscordClient: def server_name(use_cache: bool = True) -> str:
"""returns a bot client for access to the Discord API""" """Fetches the name of the current Discord server.
return DiscordClient(DISCORD_BOT_TOKEN, is_rate_limited=is_rate_limited) This method is kept to ensure backwards compatibility of this API.
"""
return core_server_name(use_cache)

View File

@@ -1,4 +1,5 @@
import logging import logging
from typing import Optional
from requests.exceptions import HTTPError from requests.exceptions import HTTPError
@@ -6,13 +7,17 @@ from django.contrib.auth.models import User
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy from django.utils.translation import gettext_lazy
from allianceauth.groupmanagement.models import ReservedGroupName
from allianceauth.notifications import notify from allianceauth.notifications import notify
from . import __title__ from . import __title__
from .app_settings import DISCORD_GUILD_ID from .app_settings import DISCORD_GUILD_ID
from .discord_client import DiscordApiBackoff, DiscordClient, DiscordRoles from .core import (
from .discord_client.helpers import match_or_create_roles_from_names create_bot_client,
default_bot_client,
calculate_roles_for_user,
user_formatted_nick
)
from .discord_client import DiscordApiBackoff
from .managers import DiscordUserManager from .managers import DiscordUserManager
from .utils import LoggerAddTag from .utils import LoggerAddTag
@@ -21,14 +26,13 @@ logger = LoggerAddTag(logging.getLogger(__name__), __title__)
class DiscordUser(models.Model): class DiscordUser(models.Model):
"""The Discord user account of an Auth user."""
USER_RELATED_NAME = 'discord'
user = models.OneToOneField( user = models.OneToOneField(
User, User,
primary_key=True, primary_key=True,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name=USER_RELATED_NAME, related_name='discord',
help_text='Auth user owning this Discord account' help_text='Auth user owning this Discord account'
) )
uid = models.BigIntegerField( uid = models.BigIntegerField(
@@ -80,24 +84,21 @@ class DiscordUser(models.Model):
- False on error or raises exception - False on error or raises exception
""" """
if not nickname: if not nickname:
nickname = DiscordUser.objects.user_formatted_nick(self.user) nickname = user_formatted_nick(self.user)
if nickname: if not nickname:
client = DiscordUser.objects._bot_client()
success = client.modify_guild_member(
guild_id=DISCORD_GUILD_ID,
user_id=self.uid,
nick=nickname
)
if success:
logger.info('Nickname for %s has been updated', self.user)
else:
logger.warning('Failed to update nickname for %s', self.user)
return success
else:
return False return False
success = default_bot_client.modify_guild_member(
guild_id=DISCORD_GUILD_ID,
user_id=self.uid,
nick=nickname
)
if success:
logger.info('Nickname for %s has been updated', self.user)
else:
logger.warning('Failed to update nickname for %s', self.user)
return success
def update_groups(self, state_name: str = None) -> bool: def update_groups(self, state_name: str = None) -> Optional[bool]:
"""update groups for a user based on his current group memberships. """update groups for a user based on his current group memberships.
Will add or remove roles of a user as needed. Will add or remove roles of a user as needed.
@@ -109,57 +110,18 @@ class DiscordUser(models.Model):
- None if user is no longer a member of the Discord server - None if user is no longer a member of the Discord server
- False on error or raises exception - False on error or raises exception
""" """
client = DiscordUser.objects._bot_client() new_roles, is_changed = calculate_roles_for_user(
member_roles = self._determine_member_roles(client) user=self.user,
if member_roles is None: client=default_bot_client,
discord_uid=self.uid,
state_name=state_name
)
if is_changed is None:
logger.debug('User is not a member of this guild %s', self.user)
return None return None
return self._update_roles_if_needed(client, state_name, member_roles) if is_changed:
def _determine_member_roles(self, client: DiscordClient) -> DiscordRoles:
"""Determine the roles of the current member / user."""
member_info = client.guild_member(guild_id=DISCORD_GUILD_ID, user_id=self.uid)
if member_info is None:
return None # User is no longer a member
guild_roles = DiscordRoles(client.guild_roles(guild_id=DISCORD_GUILD_ID))
logger.debug('Current guild roles: %s', guild_roles.ids())
if 'roles' in member_info:
if not guild_roles.has_roles(member_info['roles']):
guild_roles = DiscordRoles(
client.guild_roles(guild_id=DISCORD_GUILD_ID, use_cache=False)
)
if not guild_roles.has_roles(member_info['roles']):
raise RuntimeError(
'Member {} has unknown roles: {}'.format(
self.user,
set(member_info['roles']).difference(guild_roles.ids())
)
)
return guild_roles.subset(member_info['roles'])
raise RuntimeError('member_info from %s is not valid' % self.user)
def _update_roles_if_needed(
self, client: DiscordClient, state_name: str, member_roles: DiscordRoles
) -> bool:
"""Update the roles of this member/user if needed."""
requested_roles = match_or_create_roles_from_names(
client=client,
guild_id=DISCORD_GUILD_ID,
role_names=DiscordUser.objects.user_group_names(
user=self.user, state_name=state_name
)
)
logger.debug(
'Requested roles for user %s: %s', self.user, requested_roles.ids()
)
logger.debug('Current roles user %s: %s', self.user, member_roles.ids())
reserved_role_names = ReservedGroupName.objects.values_list("name", flat=True)
member_roles_reserved = member_roles.subset(role_names=reserved_role_names)
member_roles_managed = member_roles.subset(managed_only=True)
member_roles_persistent = member_roles_managed.union(member_roles_reserved)
if requested_roles != member_roles.difference(member_roles_persistent):
logger.debug('Need to update roles for user %s', self.user) logger.debug('Need to update roles for user %s', self.user)
new_roles = requested_roles.union(member_roles_persistent) success = default_bot_client.modify_guild_member(
success = client.modify_guild_member(
guild_id=DISCORD_GUILD_ID, guild_id=DISCORD_GUILD_ID,
user_id=self.uid, user_id=self.uid,
role_ids=list(new_roles.ids()) role_ids=list(new_roles.ids())
@@ -172,41 +134,32 @@ class DiscordUser(models.Model):
logger.info('No need to update roles for user %s', self.user) logger.info('No need to update roles for user %s', self.user)
return True return True
def update_username(self) -> bool: def update_username(self) -> Optional[bool]:
"""Updates the username incl. the discriminator """Updates the username incl. the discriminator
from the Discord server and saves it from the Discord server and saves it
Returns: Returns:
- True on success - True on success
- None if user is no longer a member of the Discord server - None if user is no longer a member of the Discord server
- False on error or raises exception
""" """
client = DiscordUser.objects._bot_client() member_info = default_bot_client.guild_member(
user_info = client.guild_member(guild_id=DISCORD_GUILD_ID, user_id=self.uid) guild_id=DISCORD_GUILD_ID, user_id=self.uid
if user_info is None: )
success = None if not member_info:
elif ( logger.warning('%s: User not a guild member', self.user)
user_info return None
and 'user' in user_info self.username = member_info.user.username
and 'username' in user_info['user'] self.discriminator = member_info.user.discriminator
and 'discriminator' in user_info['user'] self.save()
): logger.info('%s: Username has been updated', self.user)
self.username = user_info['user']['username'] return True
self.discriminator = user_info['user']['discriminator']
self.save()
logger.info('Username for %s has been updated', self.user)
success = True
else:
logger.warning('Failed to update username for %s', self.user)
success = False
return success
def delete_user( def delete_user(
self, self,
notify_user: bool = False, notify_user: bool = False,
is_rate_limited: bool = True, is_rate_limited: bool = True,
handle_api_exceptions: bool = False handle_api_exceptions: bool = False
) -> bool: ) -> Optional[bool]:
"""Deletes the Discount user both on the server and locally """Deletes the Discount user both on the server and locally
Params: Params:
@@ -221,7 +174,7 @@ class DiscordUser(models.Model):
""" """
try: try:
_user = self.user _user = self.user
client = DiscordUser.objects._bot_client(is_rate_limited=is_rate_limited) client = create_bot_client(is_rate_limited=is_rate_limited)
success = client.remove_guild_member( success = client.remove_guild_member(
guild_id=DISCORD_GUILD_ID, user_id=self.uid guild_id=DISCORD_GUILD_ID, user_id=self.uid
) )
@@ -241,15 +194,13 @@ class DiscordUser(models.Model):
) )
logger.info('Account for user %s was deleted.', _user) logger.info('Account for user %s was deleted.', _user)
return True return True
else: logger.debug('Account for user %s was already deleted.', _user)
logger.debug('Account for user %s was already deleted.', _user) return None
return None
else: logger.warning(
logger.warning( 'Failed to remove user %s from the Discord server', _user
'Failed to remove user %s from the Discord server', _user )
) return False
return False
except (HTTPError, ConnectionError, DiscordApiBackoff) as ex: except (HTTPError, ConnectionError, DiscordApiBackoff) as ex:
if handle_api_exceptions: if handle_api_exceptions:
@@ -257,5 +208,4 @@ class DiscordUser(models.Model):
'Failed to remove user %s from Discord server: %s',self.user, ex 'Failed to remove user %s from Discord server: %s',self.user, ex
) )
return False return False
else: raise ex
raise ex

View File

@@ -1,19 +1,6 @@
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from allianceauth.tests.auth_utils import AuthUtils from allianceauth.tests.auth_utils import AuthUtils
from ..discord_client.tests import ( # noqa
TEST_GUILD_ID,
TEST_USER_ID,
TEST_USER_NAME,
TEST_USER_DISCRIMINATOR,
create_role,
ROLE_ALPHA,
ROLE_BRAVO,
ROLE_CHARLIE,
ROLE_CHARLIE_2,
ROLE_MIKE,
ALL_ROLES,
create_user_info
)
DEFAULT_AUTH_GROUP = 'Member' DEFAULT_AUTH_GROUP = 'Member'
MODULE_PATH = 'allianceauth.services.modules.discord' MODULE_PATH = 'allianceauth.services.modules.discord'

View File

@@ -0,0 +1,31 @@
from django.utils.timezone import now
from allianceauth.authentication.backends import StateBackend
from allianceauth.tests.auth_utils import AuthUtils
from ..discord_client.tests.factories import (
TEST_USER_DISCRIMINATOR,
TEST_USER_ID,
TEST_USER_NAME,
)
from ..models import DiscordUser
def create_user(**kwargs):
params = {"username": TEST_USER_NAME}
params.update(kwargs)
username = StateBackend.iterate_username(params["username"])
user = AuthUtils.create_user(username)
return AuthUtils.add_permission_to_user_by_name("discord.access_discord", user)
def create_discord_user(user=None, **kwargs):
params = {
"user": user or create_user(),
"uid": TEST_USER_ID,
"username": TEST_USER_NAME,
"discriminator": TEST_USER_DISCRIMINATOR,
"activated": now(),
}
params.update(kwargs)
return DiscordUser.objects.create(**params)

View File

@@ -1,26 +1,32 @@
from django.test import TestCase, RequestFactory from unittest.mock import patch
from django.contrib.admin.sites import AdminSite from django.contrib.admin.sites import AdminSite
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import RequestFactory
from django.utils.timezone import now from django.utils.timezone import now
from allianceauth.authentication.models import CharacterOwnership from allianceauth.authentication.models import CharacterOwnership
from allianceauth.eveonline.models import ( from allianceauth.eveonline.models import (
EveCharacter, EveCorporationInfo, EveAllianceInfo EveAllianceInfo,
EveCharacter,
EveCorporationInfo,
) )
from allianceauth.utils.testing import NoSocketsTestCase
from ....admin import ( from ....admin import (
MainAllianceFilter,
MainCorporationsFilter,
ServicesUserAdmin,
user_main_organization,
user_profile_pic, user_profile_pic,
user_username, user_username,
user_main_organization,
ServicesUserAdmin,
MainCorporationsFilter,
MainAllianceFilter
) )
from ..admin import DiscordUserAdmin from ..admin import DiscordUserAdmin
from ..models import DiscordUser from ..models import DiscordUser
from . import MODULE_PATH
class TestDataMixin(TestCase): class TestDataMixin(NoSocketsTestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
@@ -168,7 +174,7 @@ class TestDataMixin(TestCase):
) )
class TestColumnRendering(TestDataMixin, TestCase): class TestColumnRendering(TestDataMixin, NoSocketsTestCase):
def test_user_profile_pic_u1(self): def test_user_profile_pic_u1(self):
expected = ( expected = (
@@ -229,7 +235,7 @@ class TestColumnRendering(TestDataMixin, TestCase):
# actions # actions
class TestFilters(TestDataMixin, TestCase): class TestFilters(TestDataMixin, NoSocketsTestCase):
def test_filter_main_corporations(self): def test_filter_main_corporations(self):
@@ -287,3 +293,16 @@ class TestFilters(TestDataMixin, TestCase):
queryset = changelist.get_queryset(request) queryset = changelist.get_queryset(request)
expected = [self.user_1.discord] expected = [self.user_1.discord]
self.assertSetEqual(set(queryset), set(expected)) self.assertSetEqual(set(queryset), set(expected))
@patch(MODULE_PATH + ".admin.DiscordUser.delete_user")
class TestDeleteQueryset(TestDataMixin, NoSocketsTestCase):
def test_should_delete_all_objects(self, mock_delete_user):
# given
request = self.factory.get('/')
request.user = self.user_1
queryset = DiscordUser.objects.filter(user__in=[self.user_2, self.user_3])
# when
self.modeladmin.delete_queryset(request, queryset)
# then
self.assertEqual(mock_delete_user.call_count, 2)

View File

@@ -0,0 +1,16 @@
from unittest.mock import patch
from allianceauth.utils.testing import NoSocketsTestCase
from ..api import discord_guild_id
from . import MODULE_PATH
class TestDiscordGuildId(NoSocketsTestCase):
@patch(MODULE_PATH + ".api.DISCORD_GUILD_ID", "123")
def test_should_return_guild_id_when_configured(self):
self.assertEqual(discord_guild_id(), 123)
@patch(MODULE_PATH + ".api.DISCORD_GUILD_ID", "")
def test_should_return_none_when_not_configured(self):
self.assertIsNone(discord_guild_id())

View File

@@ -1,23 +1,23 @@
from unittest.mock import patch from unittest.mock import patch
from django.test import TestCase, RequestFactory from django.test import RequestFactory
from django.test.utils import override_settings from django.test.utils import override_settings
from allianceauth.notifications.models import Notification from allianceauth.notifications.models import Notification
from allianceauth.tests.auth_utils import AuthUtils from allianceauth.tests.auth_utils import AuthUtils
from allianceauth.utils.testing import NoSocketsTestCase
from . import TEST_USER_NAME, TEST_USER_ID, add_permissions_to_members, MODULE_PATH
from ..auth_hooks import DiscordService from ..auth_hooks import DiscordService
from ..discord_client import DiscordClient from ..discord_client.tests.factories import TEST_USER_ID, TEST_USER_NAME
from ..models import DiscordUser from ..models import DiscordUser
from ..utils import set_logger_to_file from ..utils import set_logger_to_file
from . import MODULE_PATH, add_permissions_to_members
logger = set_logger_to_file(MODULE_PATH + '.auth_hooks', __file__) logger = set_logger_to_file(MODULE_PATH + '.auth_hooks', __file__)
@override_settings(CELERY_ALWAYS_EAGER=True) @override_settings(CELERY_ALWAYS_EAGER=True, CELERY_EAGER_PROPAGATES_EXCEPTIONS=True)
class TestDiscordService(TestCase): class TestDiscordService(NoSocketsTestCase):
def setUp(self): def setUp(self):
self.member = AuthUtils.create_member(TEST_USER_NAME) self.member = AuthUtils.create_member(TEST_USER_NAME)
@@ -64,11 +64,11 @@ class TestDiscordService(TestCase):
@patch(MODULE_PATH + '.models.notify') @patch(MODULE_PATH + '.models.notify')
@patch(MODULE_PATH + '.tasks.DiscordUser') @patch(MODULE_PATH + '.tasks.DiscordUser')
@patch(MODULE_PATH + '.managers.DiscordClient', spec=DiscordClient) @patch(MODULE_PATH + '.models.create_bot_client')
def test_validate_user( def test_validate_user(
self, mock_DiscordClient, mock_DiscordUser, mock_notify self, mock_create_bot_client, mock_DiscordUser, mock_notify
): ):
mock_DiscordClient.return_value.remove_guild_member.return_value = True mock_create_bot_client.return_value.remove_guild_member.return_value = True
# Test member is not deleted # Test member is not deleted
service = self.service() service = self.service()
@@ -92,33 +92,38 @@ class TestDiscordService(TestCase):
service.sync_nicknames_bulk([self.member]) service.sync_nicknames_bulk([self.member])
self.assertTrue(mock_update_nicknames_bulk.delay.called) self.assertTrue(mock_update_nicknames_bulk.delay.called)
@patch(MODULE_PATH + '.managers.DiscordClient', spec=DiscordClient) @patch(MODULE_PATH + '.models.create_bot_client')
def test_delete_user_is_member(self, mock_DiscordClient): def test_delete_user_is_member(self, mock_create_bot_client):
mock_DiscordClient.return_value.remove_guild_member.return_value = True # given
mock_create_bot_client.return_value.remove_guild_member.return_value = True
service = self.service() service = self.service()
# when
service.delete_user(self.member, notify_user=True) service.delete_user(self.member, notify_user=True)
# then
self.assertTrue(mock_DiscordClient.return_value.remove_guild_member.called) self.assertTrue(mock_create_bot_client.return_value.remove_guild_member.called)
self.assertFalse(DiscordUser.objects.filter(user=self.member).exists()) self.assertFalse(DiscordUser.objects.filter(user=self.member).exists())
self.assertTrue(Notification.objects.filter(user=self.member).exists()) self.assertTrue(Notification.objects.filter(user=self.member).exists())
@patch(MODULE_PATH + '.managers.DiscordClient', spec=DiscordClient) @patch(MODULE_PATH + '.models.create_bot_client')
def test_delete_user_is_not_member(self, mock_DiscordClient): def test_delete_user_is_not_member(self, mock_create_bot_client):
mock_DiscordClient.return_value.remove_guild_member.return_value = True # given
mock_create_bot_client.return_value.remove_guild_member.return_value = True
service = self.service() service = self.service()
# when
service.delete_user(self.none_member) service.delete_user(self.none_member)
# then
self.assertFalse(mock_create_bot_client.return_value.remove_guild_member.called)
self.assertFalse(mock_DiscordClient.return_value.remove_guild_member.called) @patch(MODULE_PATH + '.auth_hooks.server_name')
def test_render_services_ctrl_with_username(self, mock_server_name):
@patch(MODULE_PATH + '.managers.DiscordClient', spec=DiscordClient) # given
def test_render_services_ctrl_with_username(self, mock_DiscordClient): mock_server_name.return_value = "My server"
service = self.service() service = self.service()
request = self.factory.get('/services/') request = self.factory.get('/services/')
request.user = self.member request.user = self.member
# when
response = service.render_services_ctrl(request) response = service.render_services_ctrl(request)
# then
self.assertTemplateUsed(service.service_ctrl_template) self.assertTemplateUsed(service.service_ctrl_template)
self.assertIn('/discord/reset/', response) self.assertIn('/discord/reset/', response)
self.assertIn('/discord/deactivate/', response) self.assertIn('/discord/deactivate/', response)
@@ -130,15 +135,18 @@ class TestDiscordService(TestCase):
response = service.render_services_ctrl(request) response = service.render_services_ctrl(request)
self.assertIn('/discord/activate/', response) self.assertIn('/discord/activate/', response)
@patch(MODULE_PATH + '.managers.DiscordClient', spec=DiscordClient) @patch(MODULE_PATH + '.auth_hooks.server_name')
def test_render_services_ctrl_wo_username(self, mock_DiscordClient): def test_render_services_ctrl_wo_username(self, mock_server_name):
# given
mock_server_name.return_value = "My server"
my_member = AuthUtils.create_member('John Doe') my_member = AuthUtils.create_member('John Doe')
DiscordUser.objects.create(user=my_member, uid=111222333) DiscordUser.objects.create(user=my_member, uid=111222333)
service = self.service() service = self.service()
request = self.factory.get('/services/') request = self.factory.get('/services/')
request.user = my_member request.user = my_member
# when
response = service.render_services_ctrl(request) response = service.render_services_ctrl(request)
# then
self.assertTemplateUsed(service.service_ctrl_template) self.assertTemplateUsed(service.service_ctrl_template)
self.assertIn('/discord/reset/', response) self.assertIn('/discord/reset/', response)
self.assertIn('/discord/deactivate/', response) self.assertIn('/discord/deactivate/', response)

View File

@@ -0,0 +1,221 @@
from unittest.mock import Mock, patch
from requests.exceptions import HTTPError
from django.contrib.auth.models import Group
from allianceauth.groupmanagement.models import ReservedGroupName
from allianceauth.tests.auth_utils import AuthUtils
from allianceauth.utils.testing import NoSocketsTestCase
from ..core import (
_user_group_names,
calculate_roles_for_user,
group_to_role,
server_name,
user_formatted_nick,
)
from ..discord_client import DiscordApiBackoff, DiscordClient, RolesSet
from ..discord_client.tests.factories import TEST_USER_NAME, create_role
from . import MODULE_PATH, TEST_MAIN_ID, TEST_MAIN_NAME
class TestUserGroupNames(NoSocketsTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.group_1 = Group.objects.create(name="Group 1")
cls.group_2 = Group.objects.create(name="Group 2")
def setUp(self):
self.user = AuthUtils.create_member(TEST_USER_NAME)
def test_return_groups_and_state_names_for_user(self):
self.user.groups.add(self.group_1)
result = _user_group_names(self.user)
expected = ["Group 1", "Member"]
self.assertSetEqual(set(result), set(expected))
def test_return_state_only_if_user_has_no_groups(self):
result = _user_group_names(self.user)
expected = ["Member"]
self.assertSetEqual(set(result), set(expected))
class TestUserFormattedNick(NoSocketsTestCase):
def setUp(self):
self.user = AuthUtils.create_user(TEST_USER_NAME)
def test_return_nick_when_user_has_main(self):
AuthUtils.add_main_character_2(self.user, TEST_MAIN_NAME, TEST_MAIN_ID)
result = user_formatted_nick(self.user)
expected = TEST_MAIN_NAME
self.assertEqual(result, expected)
def test_return_none_if_user_has_no_main(self):
result = user_formatted_nick(self.user)
self.assertIsNone(result)
@patch(MODULE_PATH + ".core.default_bot_client", spec=True)
class TestRoleForGroup(NoSocketsTestCase):
def test_return_role_if_found(self, mock_bot_client):
# given
role = create_role(name="alpha")
mock_bot_client.match_role_from_name.side_effect = (
lambda guild_id, role_name: role if role.name == role_name else None
)
group = Group.objects.create(name="alpha")
# when/then
self.assertEqual(group_to_role(group), role)
def test_return_empty_dict_if_not_found(self, mock_bot_client):
# given
role = create_role(name="alpha")
mock_bot_client.match_role_from_name.side_effect = (
lambda guild_id, role_name: role if role.name == role_name else None
)
group = Group.objects.create(name="unknown")
# when/then
self.assertIsNone(group_to_role(group))
@patch(MODULE_PATH + ".core.default_bot_client", spec=True)
@patch(MODULE_PATH + ".core.logger", spec=True)
class TestServerName(NoSocketsTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.user = AuthUtils.create_user(TEST_USER_NAME)
def test_returns_name_when_api_returns_it(self, mock_logger, mock_bot_client):
# given
my_server_name = "El Dorado"
mock_bot_client.guild_name.return_value = my_server_name
# when
self.assertEqual(server_name(), my_server_name)
# then
self.assertFalse(mock_logger.warning.called)
def test_returns_empty_string_when_api_throws_http_error(
self, mock_logger, mock_bot_client
):
mock_exception = HTTPError("Test exception")
mock_exception.response = Mock(**{"status_code": 440})
mock_bot_client.guild_name.side_effect = mock_exception
self.assertEqual(server_name(), "")
self.assertFalse(mock_logger.warning.called)
def test_returns_empty_string_when_api_throws_service_error(
self, mock_logger, mock_bot_client
):
mock_bot_client.guild_name.side_effect = DiscordApiBackoff(1000)
self.assertEqual(server_name(), "")
self.assertFalse(mock_logger.warning.called)
def test_returns_empty_string_when_api_throws_unexpected_error(
self, mock_logger, mock_bot_client
):
mock_bot_client.guild_name.side_effect = RuntimeError
self.assertEqual(server_name(), "")
self.assertTrue(mock_logger.warning.called)
class TestCalculateRolesForUser(NoSocketsTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.user = AuthUtils.create_user(TEST_USER_NAME)
def test_should_return_roles_for_new_member(self):
# given
roles = RolesSet([create_role()])
my_client = Mock(spec=DiscordClient)
my_client.guild_member_roles.return_value = RolesSet([])
my_client.match_or_create_roles_from_names_2.return_value = roles
# when
roles_calculated, changed = calculate_roles_for_user(self.user, my_client, 42)
# then
self.assertTrue(changed)
self.assertEqual(roles_calculated, roles)
def test_should_return_changed_roles_for_existing_member(self):
# given
role_a = create_role()
role_b = create_role()
roles_current = RolesSet([role_a])
roles_matching = RolesSet([role_a, role_b])
my_client = Mock(spec=DiscordClient)
my_client.guild_member_roles.return_value = roles_current
my_client.match_or_create_roles_from_names_2.return_value = roles_matching
# when
roles_calculated, changed = calculate_roles_for_user(self.user, my_client, 42)
# then
self.assertTrue(changed)
self.assertEqual(roles_calculated, roles_matching)
def test_should_indicate_when_roles_are_unchanged(self):
# given
role_a = create_role()
roles_current = RolesSet([role_a])
roles_matching = RolesSet([role_a])
my_client = Mock(spec=DiscordClient)
my_client.guild_member_roles.return_value = roles_current
my_client.match_or_create_roles_from_names_2.return_value = roles_matching
# when
roles_calculated, changed = calculate_roles_for_user(self.user, my_client, 42)
# then
self.assertFalse(changed)
self.assertEqual(roles_calculated, roles_matching)
def test_should_indicate_when_user_is_no_guild_member(self):
# given
role_a = create_role()
roles_matching = RolesSet([role_a])
my_client = Mock(spec=DiscordClient)
my_client.guild_member_roles.return_value = None
my_client.match_or_create_roles_from_names_2.return_value = roles_matching
# when
roles_calculated, changed = calculate_roles_for_user(self.user, my_client, 42)
# then
self.assertIsNone(changed)
self.assertEqual(roles_calculated, roles_matching)
def test_should_preserve_managed_roles_for_existing_member(self):
# given
role_a = create_role()
role_b = create_role()
role_m = create_role(managed=True)
roles_current = RolesSet([role_a, role_m])
roles_matching = RolesSet([role_b])
my_client = Mock(spec=DiscordClient)
my_client.guild_member_roles.return_value = roles_current
my_client.match_or_create_roles_from_names_2.return_value = roles_matching
# when
roles_calculated, changed = calculate_roles_for_user(self.user, my_client, 42)
# then
self.assertTrue(changed)
self.assertEqual(roles_calculated, RolesSet([role_b, role_m]))
def test_should_preserve_reserved_roles_for_existing_member(self):
# given
role_a = create_role()
role_b = create_role()
role_c1 = create_role(name="charlie")
role_c2 = create_role(name="Charlie")
roles_current = RolesSet([role_a, role_c1, role_c2])
roles_matching = RolesSet([role_b])
my_client = Mock(spec=DiscordClient)
my_client.guild_member_roles.return_value = roles_current
my_client.match_or_create_roles_from_names_2.return_value = roles_matching
ReservedGroupName.objects.create(
name="charlie", reason="dummy", created_by="xyz"
)
# when
roles_calculated, changed = calculate_roles_for_user(self.user, my_client, 42)
# then
self.assertTrue(changed)
self.assertEqual(roles_calculated, RolesSet([role_b, role_c1, role_c2]))

View File

@@ -4,54 +4,68 @@ Testing all components of the service, with the exception of the Discord API.
Please note that these tests require Redis and will flush it Please note that these tests require Redis and will flush it
""" """
from collections import namedtuple import dataclasses
import json
import logging import logging
from unittest.mock import patch, Mock from unittest.mock import Mock, patch
from uuid import uuid1 from uuid import uuid1
from django_webtest import WebTest
from requests.exceptions import HTTPError
import requests_mock import requests_mock
from requests.exceptions import HTTPError
from django.contrib.auth.models import Group, User from django.contrib.auth.models import Group, User
from django.shortcuts import reverse from django.test import TransactionTestCase, override_settings
from django.test import TransactionTestCase, TestCase from django.urls import reverse
from django.test.utils import override_settings from django_webtest import WebTest
from allianceauth.authentication.models import State from allianceauth.authentication.models import State
from allianceauth.eveonline.models import EveCharacter from allianceauth.eveonline.models import EveCharacter
from allianceauth.groupmanagement.models import ReservedGroupName
from allianceauth.notifications.models import Notification from allianceauth.notifications.models import Notification
from allianceauth.tests.auth_utils import AuthUtils from allianceauth.tests.auth_utils import AuthUtils
from allianceauth.utils.cache import get_redis_client from allianceauth.utils.cache import get_redis_client
from allianceauth.utils.testing import NoSocketsTestCase
from . import (
TEST_GUILD_ID,
TEST_USER_NAME,
TEST_USER_ID,
TEST_USER_DISCRIMINATOR,
TEST_MAIN_NAME,
TEST_MAIN_ID,
MODULE_PATH,
add_permissions_to_members,
ROLE_ALPHA,
ROLE_BRAVO,
ROLE_CHARLIE,
ROLE_MIKE,
create_role,
create_user_info
)
from ..discord_client.app_settings import DISCORD_API_BASE_URL
from ..discord_client.exceptions import DiscordApiBackoff
from ..models import DiscordUser
from .. import tasks from .. import tasks
from ..core import create_bot_client
from ..discord_client import DiscordApiBackoff
from ..discord_client.app_settings import DISCORD_API_BASE_URL
from ..discord_client.tests.factories import (
TEST_GUILD_ID,
TEST_USER_ID,
TEST_USER_NAME,
create_discord_error_response_unknown_member,
create_discord_guild_member_object,
create_discord_guild_object,
create_discord_role_object,
create_discord_user_object,
)
from ..models import DiscordUser
from . import MODULE_PATH, TEST_MAIN_ID, TEST_MAIN_NAME, add_permissions_to_members
from .factories import create_discord_user, create_user
logger = logging.getLogger('allianceauth') logger = logging.getLogger('allianceauth')
ROLE_MEMBER = create_role(99, 'Member') ROLE_ALPHA = create_discord_role_object(id=1, name="alpha")
ROLE_BLUE = create_role(98, 'Blue') ROLE_BRAVO = create_discord_role_object(id=2, name="bravo")
ROLE_CHARLIE = create_discord_role_object(id=3, name="charlie")
ROLE_CHARLIE_2 = create_discord_role_object(id=4, name="Charlie") # Discord roles are case sensitive
ROLE_MIKE = create_discord_role_object(id=13, name="mike", managed=True)
ROLE_MEMBER = create_discord_role_object(99, 'Member')
ROLE_BLUE = create_discord_role_object(98, 'Blue')
@dataclasses.dataclass(frozen=True)
class DiscordRequest:
"""Helper for comparing requests made to the Discord API."""
method: str
url: str
text: str = dataclasses.field(compare=False, default=None)
def json(self):
return json.loads(self.text)
# Putting all requests to Discord into objects so we can compare them better
DiscordRequest = namedtuple('DiscordRequest', ['method', 'url'])
user_get_current_request = DiscordRequest( user_get_current_request = DiscordRequest(
method='GET', method='GET',
url=f'{DISCORD_API_BASE_URL}users/@me' url=f'{DISCORD_API_BASE_URL}users/@me'
@@ -102,8 +116,9 @@ def reset_testdata():
Notification.objects.all().delete() Notification.objects.all().delete()
@patch(MODULE_PATH + '.core.DISCORD_GUILD_ID', TEST_GUILD_ID)
@patch(MODULE_PATH + '.models.DISCORD_GUILD_ID', TEST_GUILD_ID) @patch(MODULE_PATH + '.models.DISCORD_GUILD_ID', TEST_GUILD_ID)
@override_settings(CELERY_ALWAYS_EAGER=True) @override_settings(CELERY_ALWAYS_EAGER=True, CELERY_EAGER_PROPAGATES_EXCEPTIONS=False)
@requests_mock.Mocker() @requests_mock.Mocker()
class TestServiceFeatures(TransactionTestCase): class TestServiceFeatures(TransactionTestCase):
fixtures = ['disable_analytics.json'] fixtures = ['disable_analytics.json']
@@ -191,7 +206,7 @@ class TestServiceFeatures(TransactionTestCase):
requests_mocker.patch(modify_guild_member_request.url, status_code=204) requests_mocker.patch(modify_guild_member_request.url, status_code=204)
# exhausting rate limit # exhausting rate limit
client = DiscordUser.objects._bot_client() client = create_bot_client()
client._redis.set( client._redis.set(
name=client._KEY_GLOBAL_RATE_LIMIT_REMAINING, name=client._KEY_GLOBAL_RATE_LIMIT_REMAINING,
value=0, value=0,
@@ -207,7 +222,6 @@ class TestServiceFeatures(TransactionTestCase):
requests_made = [ requests_made = [
DiscordRequest(r.method, r.url) for r in requests_mocker.request_history DiscordRequest(r.method, r.url) for r in requests_mocker.request_history
] ]
self.assertListEqual(requests_made, list()) self.assertListEqual(requests_made, list())
def test_when_member_is_demoted_to_guest_then_his_account_is_deleted( def test_when_member_is_demoted_to_guest_then_his_account_is_deleted(
@@ -245,7 +259,7 @@ class TestServiceFeatures(TransactionTestCase):
# request mocks # request mocks
requests_mocker.get( requests_mocker.get(
guild_member_request.url, guild_member_request.url,
json={'user': create_user_info(), 'roles': ['3', '13', '99']} json=create_discord_guild_member_object(roles=[3, 13, 99])
) )
requests_mocker.get( requests_mocker.get(
guild_roles_request.url, guild_roles_request.url,
@@ -281,10 +295,7 @@ class TestServiceFeatures(TransactionTestCase):
): ):
requests_mocker.get( requests_mocker.get(
guild_member_request.url, guild_member_request.url,
json={ json=create_discord_guild_member_object(roles=[13, 99])
'user': create_user_info(),
'roles': ['13', '99']
}
) )
requests_mocker.get( requests_mocker.get(
guild_roles_request.url, guild_roles_request.url,
@@ -313,10 +324,7 @@ class TestServiceFeatures(TransactionTestCase):
): ):
requests_mocker.get( requests_mocker.get(
guild_member_request.url, guild_member_request.url,
json={ json=create_discord_guild_member_object(roles=['13', '99'])
'user': {'id': str(TEST_USER_ID), 'username': TEST_MAIN_NAME},
'roles': ['13', '99']
}
) )
requests_mocker.get( requests_mocker.get(
guild_roles_request.url, guild_roles_request.url,
@@ -342,11 +350,33 @@ class TestServiceFeatures(TransactionTestCase):
self.assertTrue(DiscordUser.objects.user_has_account(self.user)) self.assertTrue(DiscordUser.objects.user_has_account(self.user))
@override_settings(CELERY_ALWAYS_EAGER=True) @override_settings(CELERY_ALWAYS_EAGER=True, CELERY_EAGER_PROPAGATES_EXCEPTIONS=True)
@patch(MODULE_PATH + '.managers.DISCORD_GUILD_ID', TEST_GUILD_ID) @patch(MODULE_PATH + '.managers.DISCORD_GUILD_ID', TEST_GUILD_ID)
@patch(MODULE_PATH + '.models.DISCORD_GUILD_ID', TEST_GUILD_ID) @patch(MODULE_PATH + '.models.DISCORD_GUILD_ID', TEST_GUILD_ID)
@requests_mock.Mocker() @requests_mock.Mocker()
class StateTestCase(TestCase): class TestTasks(NoSocketsTestCase):
def test_should_update_username(self, requests_mocker):
# given
user = create_user()
discord_user = create_discord_user(user)
discord_user_obj = create_discord_user_object()
data = create_discord_guild_member_object(user=discord_user_obj)
requests_mocker.get(guild_member_request.url, json=data)
# when
tasks.update_username.delay(user.pk)
# then
discord_user.refresh_from_db()
self.assertEqual(discord_user.username, discord_user_obj["username"])
self.assertEqual(
discord_user.discriminator, discord_user_obj["discriminator"]
)
@override_settings(CELERY_ALWAYS_EAGER=True, CELERY_EAGER_PROPAGATES_EXCEPTIONS=True)
@patch(MODULE_PATH + '.managers.DISCORD_GUILD_ID', TEST_GUILD_ID)
@patch(MODULE_PATH + '.models.DISCORD_GUILD_ID', TEST_GUILD_ID)
@requests_mock.Mocker()
class StateTestCase(NoSocketsTestCase):
def setUp(self): def setUp(self):
clear_cache() clear_cache()
@@ -430,6 +460,7 @@ class StateTestCase(TestCase):
self.user.discord self.user.discord
@patch(MODULE_PATH + '.core.DISCORD_GUILD_ID', TEST_GUILD_ID)
@patch(MODULE_PATH + '.managers.DISCORD_GUILD_ID', TEST_GUILD_ID) @patch(MODULE_PATH + '.managers.DISCORD_GUILD_ID', TEST_GUILD_ID)
@patch(MODULE_PATH + '.models.DISCORD_GUILD_ID', TEST_GUILD_ID) @patch(MODULE_PATH + '.models.DISCORD_GUILD_ID', TEST_GUILD_ID)
@requests_mock.Mocker() @requests_mock.Mocker()
@@ -448,24 +479,25 @@ class TestUserFeatures(WebTest):
) )
add_permissions_to_members() add_permissions_to_members()
@patch(MODULE_PATH + '.views.messages') @patch(MODULE_PATH + '.views.messages', spec=True)
@patch(MODULE_PATH + '.managers.OAuth2Session') @patch(MODULE_PATH + '.managers.OAuth2Session', spec=True)
def test_user_activation_normal( def test_user_activation_normal(
self, requests_mocker, mock_OAuth2Session, mock_messages self, requests_mocker, mock_OAuth2Session, mock_messages
): ):
# setup # setup
requests_mocker.get( requests_mocker.get(
guild_infos_request.url, json={'id': TEST_GUILD_ID, 'name': 'Test Guild'} guild_infos_request.url, json=create_discord_guild_object()
) )
requests_mocker.get( requests_mocker.get(
user_get_current_request.url, user_get_current_request.url, json=create_discord_user_object()
json=create_user_info(
TEST_USER_ID, TEST_USER_NAME, TEST_USER_DISCRIMINATOR
)
) )
requests_mocker.get( requests_mocker.get(
guild_roles_request.url, guild_roles_request.url, json=[ROLE_ALPHA, ROLE_BRAVO, ROLE_MEMBER]
json=[ROLE_ALPHA, ROLE_BRAVO, ROLE_MIKE, ROLE_MEMBER] )
requests_mocker.get(
guild_member_request.url,
status_code=404,
json=create_discord_error_response_unknown_member()
) )
requests_mocker.put(add_guild_member_request.url, status_code=201) requests_mocker.put(add_guild_member_request.url, status_code=201)
@@ -503,33 +535,93 @@ class TestUserFeatures(WebTest):
for r in requests_mocker.request_history: for r in requests_mocker.request_history:
obj = DiscordRequest(r.method, r.url) obj = DiscordRequest(r.method, r.url)
requests_made.append(obj) requests_made.append(obj)
self.assertIn(add_guild_member_request, requests_made)
expected = [ @patch(MODULE_PATH + '.views.messages', spec=True)
guild_infos_request, @patch(MODULE_PATH + '.managers.OAuth2Session', spec=True)
user_get_current_request, def test_should_activate_existing_user_and_keep_managed_and_reserved_roles(
guild_roles_request, self, requests_mocker, mock_OAuth2Session, mock_messages
add_guild_member_request ):
] # setup
self.assertListEqual(requests_made, expected) requests_mocker.get(
guild_infos_request.url, json=create_discord_guild_object()
)
requests_mocker.get(
user_get_current_request.url, json=create_discord_user_object()
)
requests_mocker.get(
guild_roles_request.url, json=[
ROLE_ALPHA, ROLE_CHARLIE, ROLE_MEMBER, ROLE_MIKE
]
)
requests_mocker.get(
guild_member_request.url,
json=create_discord_guild_member_object(roles=[1, 3, 13])
)
requests_mocker.patch(modify_guild_member_request.url, status_code=204)
ReservedGroupName.objects.create(
name="charlie", reason="dummy", created_by="xyz"
)
@patch(MODULE_PATH + '.views.messages') authentication_code = 'auth_code'
@patch(MODULE_PATH + '.managers.OAuth2Session') oauth_url = 'https://www.example.com/oauth'
state = ''
mock_OAuth2Session.return_value.authorization_url.return_value = \
oauth_url, state
# login
self.app.set_user(self.member)
# user opens services page
services_page = self.app.get(reverse('services:services'))
self.assertEqual(services_page.status_code, 200)
# user clicks Discord service activation link on page
response = services_page.click(href=reverse('discord:activate'))
# check we got a redirect to Discord OAuth
self.assertRedirects(
response, expected_url=oauth_url, fetch_redirect_response=False
)
# simulate Discord callback
response = self.app.get(
reverse('discord:callback'), params={'code': authentication_code}
)
# user got a success message
self.assertTrue(mock_messages.success.called)
self.assertFalse(mock_messages.error.called)
my_request = None
for r in requests_mocker.request_history:
obj = DiscordRequest(r.method, r.url, r.text)
if obj == modify_guild_member_request:
my_request = obj
break
else:
self.fail("Request not found")
self.assertSetEqual(set(my_request.json()["roles"]), {3, 13, 99})
@patch(MODULE_PATH + '.views.messages', spec=True)
@patch(MODULE_PATH + '.managers.OAuth2Session', spec=True)
def test_user_activation_failed( def test_user_activation_failed(
self, requests_mocker, mock_OAuth2Session, mock_messages self, requests_mocker, mock_OAuth2Session, mock_messages
): ):
# setup # setup
requests_mocker.get( requests_mocker.get(
guild_infos_request.url, json={'id': TEST_GUILD_ID, 'name': 'Test Guild'} guild_infos_request.url, json=create_discord_guild_object()
) )
requests_mocker.get( requests_mocker.get(
user_get_current_request.url, user_get_current_request.url, json=create_discord_user_object()
json=create_user_info(
TEST_USER_ID, TEST_USER_NAME, TEST_USER_DISCRIMINATOR
)
) )
requests_mocker.get( requests_mocker.get(
guild_roles_request.url, guild_roles_request.url, json=[ROLE_ALPHA, ROLE_BRAVO, ROLE_MEMBER]
json=[ROLE_ALPHA, ROLE_BRAVO, ROLE_MIKE, ROLE_MEMBER] )
requests_mocker.get(
guild_member_request.url,
status_code=404,
json=create_discord_error_response_unknown_member()
) )
mock_exception = HTTPError('error') mock_exception = HTTPError('error')
@@ -571,20 +663,13 @@ class TestUserFeatures(WebTest):
for r in requests_mocker.request_history: for r in requests_mocker.request_history:
obj = DiscordRequest(r.method, r.url) obj = DiscordRequest(r.method, r.url)
requests_made.append(obj) requests_made.append(obj)
self.assertIn(add_guild_member_request, requests_made)
expected = [ @patch(MODULE_PATH + '.views.messages', spec=True)
guild_infos_request,
user_get_current_request,
guild_roles_request,
add_guild_member_request
]
self.assertListEqual(requests_made, expected)
@patch(MODULE_PATH + '.views.messages')
def test_user_deactivation_normal(self, requests_mocker, mock_messages): def test_user_deactivation_normal(self, requests_mocker, mock_messages):
# setup # setup
requests_mocker.get( requests_mocker.get(
guild_infos_request.url, json={'id': TEST_GUILD_ID, 'name': 'Test Guild'} guild_infos_request.url, json=create_discord_guild_object()
) )
requests_mocker.delete(remove_guild_member_request.url, status_code=204) requests_mocker.delete(remove_guild_member_request.url, status_code=204)
DiscordUser.objects.create(user=self.member, uid=TEST_USER_ID) DiscordUser.objects.create(user=self.member, uid=TEST_USER_ID)
@@ -610,15 +695,13 @@ class TestUserFeatures(WebTest):
for r in requests_mocker.request_history: for r in requests_mocker.request_history:
obj = DiscordRequest(r.method, r.url) obj = DiscordRequest(r.method, r.url)
requests_made.append(obj) requests_made.append(obj)
self.assertIn(remove_guild_member_request, requests_made)
expected = [guild_infos_request, remove_guild_member_request] @patch(MODULE_PATH + '.views.messages', spec=True)
self.assertListEqual(requests_made, expected)
@patch(MODULE_PATH + '.views.messages')
def test_user_deactivation_fails(self, requests_mocker, mock_messages): def test_user_deactivation_fails(self, requests_mocker, mock_messages):
# setup # setup
requests_mocker.get( requests_mocker.get(
guild_infos_request.url, json={'id': TEST_GUILD_ID, 'name': 'Test Guild'} guild_infos_request.url, json=create_discord_guild_object()
) )
mock_exception = HTTPError('error') mock_exception = HTTPError('error')
mock_exception.response = Mock() mock_exception.response = Mock()
@@ -648,11 +731,9 @@ class TestUserFeatures(WebTest):
for r in requests_mocker.request_history: for r in requests_mocker.request_history:
obj = DiscordRequest(r.method, r.url) obj = DiscordRequest(r.method, r.url)
requests_made.append(obj) requests_made.append(obj)
self.assertIn(remove_guild_member_request, requests_made)
expected = [guild_infos_request, remove_guild_member_request] @patch(MODULE_PATH + '.views.messages', spec=True)
self.assertListEqual(requests_made, expected)
@patch(MODULE_PATH + '.views.messages')
def test_user_add_new_server(self, requests_mocker, mock_messages): def test_user_add_new_server(self, requests_mocker, mock_messages):
# setup # setup
mock_exception = HTTPError(Mock(**{"response.status_code": 400})) mock_exception = HTTPError(Mock(**{"response.status_code": 400}))
@@ -684,14 +765,13 @@ class TestUserFeatures(WebTest):
services_page = self.app.get(reverse('services:services')) services_page = self.app.get(reverse('services:services'))
self.assertEqual(services_page.status_code, 200) self.assertEqual(services_page.status_code, 200)
@override_settings(CELERY_ALWAYS_EAGER=True) @override_settings(CELERY_ALWAYS_EAGER=True, CELERY_EAGER_PROPAGATES_EXCEPTIONS=True)
@patch(MODULE_PATH + ".core.default_bot_client", spec=True)
def test_server_name_is_updated_by_task( def test_server_name_is_updated_by_task(
self, requests_mocker self, requests_mocker, mock_bot_client
): ):
# setup # setup
requests_mocker.get( mock_bot_client.guild_name.return_value = "Test Guild"
guild_infos_request.url, json={'id': TEST_GUILD_ID, 'name': 'Test Guild'}
)
# run task to update usernames # run task to update usernames
tasks.update_all_usernames() tasks.update_all_usernames()

View File

@@ -1,364 +1,395 @@
from unittest.mock import patch, Mock
import urllib import urllib
from unittest.mock import Mock, patch
from requests.exceptions import HTTPError from requests.exceptions import HTTPError
from django.contrib.auth.models import Group, User from django.contrib.auth.models import User
from django.test import TestCase
from allianceauth.tests.auth_utils import AuthUtils from allianceauth.tests.auth_utils import AuthUtils
from allianceauth.utils.testing import NoSocketsTestCase
from . import ( from ..app_settings import DISCORD_APP_ID, DISCORD_APP_SECRET, DISCORD_CALLBACK_URL
from ..discord_client import (
DISCORD_OAUTH_BASE_URL,
DISCORD_OAUTH_TOKEN_URL,
DiscordApiBackoff,
DiscordClient,
RolesSet,
)
from ..discord_client.tests.factories import (
TEST_GUILD_ID, TEST_GUILD_ID,
TEST_USER_NAME,
TEST_USER_ID, TEST_USER_ID,
TEST_MAIN_NAME, TEST_USER_NAME,
TEST_MAIN_ID, create_role,
MODULE_PATH, create_user,
ROLE_ALPHA,
ROLE_BRAVO,
ROLE_CHARLIE,
) )
from ..discord_client.tests import create_matched_role
from ..app_settings import (
DISCORD_APP_ID,
DISCORD_APP_SECRET,
DISCORD_CALLBACK_URL,
)
from ..discord_client import DiscordClient, DiscordApiBackoff
from ..models import DiscordUser from ..models import DiscordUser
from ..utils import set_logger_to_file from ..utils import set_logger_to_file
from . import MODULE_PATH, TEST_MAIN_NAME
logger = set_logger_to_file(MODULE_PATH + '.managers', __file__) logger = set_logger_to_file(MODULE_PATH + '.managers', __file__)
@patch(MODULE_PATH + '.managers.DISCORD_GUILD_ID', TEST_GUILD_ID) @patch(MODULE_PATH + '.managers.DISCORD_GUILD_ID', TEST_GUILD_ID)
@patch(MODULE_PATH + '.managers.DiscordClient', spec=DiscordClient) @patch(MODULE_PATH + '.managers.DiscordClient', spec=DiscordClient)
@patch(MODULE_PATH + '.models.DiscordUser.objects._exchange_auth_code_for_token') @patch(MODULE_PATH + '.managers.create_bot_client', spec=True)
@patch(MODULE_PATH + '.models.DiscordUser.objects.user_group_names') @patch(
@patch(MODULE_PATH + '.models.DiscordUser.objects.user_formatted_nick') MODULE_PATH + '.models.DiscordUser.objects._exchange_auth_code_for_token', spec=True
class TestAddUser(TestCase): )
@patch(MODULE_PATH + '.managers.calculate_roles_for_user', spec=True)
@patch(MODULE_PATH + '.managers.user_formatted_nick', spec=True)
class TestAddUser(NoSocketsTestCase):
def setUp(self): def setUp(self):
self.user = AuthUtils.create_user(TEST_USER_NAME) self.user = AuthUtils.create_user(TEST_USER_NAME)
self.user_info = {
'id': TEST_USER_ID,
'name': TEST_USER_NAME,
'username': TEST_USER_NAME,
'discriminator': '1234',
}
self.access_token = 'accesstoken' self.access_token = 'accesstoken'
def test_can_create_user_no_roles_no_nick( def test_can_create_user_no_roles_no_nick(
self, self,
mock_user_formatted_nick, mock_user_formatted_nick,
mock_user_group_names, mock_calculate_roles_for_user,
mock_exchange_auth_code_for_token, mock_exchange_auth_code_for_token,
mock_DiscordClient mock_create_bot_client,
mock_DiscordClient,
): ):
# given
discord_user = create_user(id=TEST_USER_ID)
mock_user_formatted_nick.return_value = None mock_user_formatted_nick.return_value = None
mock_user_group_names.return_value = []
mock_exchange_auth_code_for_token.return_value = self.access_token mock_exchange_auth_code_for_token.return_value = self.access_token
mock_DiscordClient.return_value.current_user.return_value = self.user_info mock_DiscordClient.return_value.current_user.return_value = discord_user
mock_DiscordClient.return_value.match_or_create_roles_from_names\ mock_calculate_roles_for_user.return_value = RolesSet([]), None
.return_value = [] mock_create_bot_client.return_value.add_guild_member.return_value = True
mock_DiscordClient.return_value.add_guild_member.return_value = True # when
result = DiscordUser.objects.add_user(self.user, authorization_code='abcdef') result = DiscordUser.objects.add_user(self.user, authorization_code='abcdef')
# then
self.assertTrue(result) self.assertTrue(result)
self.assertTrue( self.assertTrue(
DiscordUser.objects.filter(user=self.user, uid=TEST_USER_ID).exists() DiscordUser.objects.filter(user=self.user, uid=TEST_USER_ID).exists()
) )
self.assertTrue(mock_DiscordClient.return_value.add_guild_member.called) _, kwargs = mock_create_bot_client.return_value.add_guild_member.call_args
args, kwargs = mock_DiscordClient.return_value.add_guild_member.call_args
self.assertEqual(kwargs['guild_id'], TEST_GUILD_ID) self.assertEqual(kwargs['guild_id'], TEST_GUILD_ID)
self.assertEqual(kwargs['user_id'], TEST_USER_ID) self.assertEqual(kwargs['user_id'], TEST_USER_ID)
self.assertEqual(kwargs['access_token'], self.access_token) self.assertEqual(kwargs['access_token'], self.access_token)
self.assertIsNone(kwargs['role_ids']) self.assertFalse(kwargs['role_ids'])
self.assertIsNone(kwargs['nick']) self.assertIsNone(kwargs['nick'])
def test_can_create_user_with_roles_no_nick( def test_can_create_user_with_roles_no_nick(
self, self,
mock_user_formatted_nick, mock_user_formatted_nick,
mock_user_group_names, mock_calculate_roles_for_user,
mock_exchange_auth_code_for_token, mock_exchange_auth_code_for_token,
mock_DiscordClient mock_create_bot_client,
mock_DiscordClient,
): ):
roles = [ # given
create_matched_role(ROLE_ALPHA), role_a = create_role(id=1)
create_matched_role(ROLE_BRAVO), role_b = create_role(id=2)
create_matched_role(ROLE_CHARLIE) roles_calculated = RolesSet([role_a, role_b])
] discord_user = create_user(id=TEST_USER_ID)
mock_user_formatted_nick.return_value = None mock_user_formatted_nick.return_value = None
mock_user_group_names.return_value = ['a', 'b', 'c']
mock_exchange_auth_code_for_token.return_value = self.access_token mock_exchange_auth_code_for_token.return_value = self.access_token
mock_DiscordClient.return_value.current_user.return_value = self.user_info mock_DiscordClient.return_value.current_user.return_value = discord_user
mock_DiscordClient.return_value.match_or_create_roles_from_names\ mock_calculate_roles_for_user.return_value = roles_calculated, None
.return_value = roles mock_create_bot_client.return_value.add_guild_member.return_value = True
mock_DiscordClient.return_value.add_guild_member.return_value = True # when
result = DiscordUser.objects.add_user(self.user, authorization_code='abcdef') result = DiscordUser.objects.add_user(self.user, authorization_code='abcdef')
# then
self.assertTrue(result) self.assertTrue(result)
self.assertTrue( self.assertTrue(
DiscordUser.objects.filter(user=self.user, uid=TEST_USER_ID).exists() DiscordUser.objects.filter(user=self.user, uid=TEST_USER_ID).exists()
) )
self.assertTrue(mock_DiscordClient.return_value.add_guild_member.called) _, kwargs = mock_create_bot_client.return_value.add_guild_member.call_args
args, kwargs = mock_DiscordClient.return_value.add_guild_member.call_args
self.assertEqual(kwargs['guild_id'], TEST_GUILD_ID) self.assertEqual(kwargs['guild_id'], TEST_GUILD_ID)
self.assertEqual(kwargs['user_id'], TEST_USER_ID) self.assertEqual(kwargs['user_id'], TEST_USER_ID)
self.assertEqual(kwargs['access_token'], self.access_token) self.assertEqual(kwargs['access_token'], self.access_token)
self.assertSetEqual(set(kwargs['role_ids']), {1, 2, 3}) self.assertSetEqual(set(kwargs['role_ids']), {1, 2})
self.assertIsNone(kwargs['nick']) self.assertIsNone(kwargs['nick'])
def test_can_activate_existing_user_with_roles_no_nick( def test_can_activate_existing_user_with_roles_no_nick(
self, self,
mock_user_formatted_nick, mock_user_formatted_nick,
mock_user_group_names, mock_calculate_roles_for_user,
mock_exchange_auth_code_for_token, mock_exchange_auth_code_for_token,
mock_DiscordClient mock_create_bot_client,
mock_DiscordClient,
): ):
roles = [ # given
create_matched_role(ROLE_ALPHA), role_a = create_role(id=1)
create_matched_role(ROLE_BRAVO), role_b = create_role(id=2)
create_matched_role(ROLE_CHARLIE) roles_calculated = RolesSet([role_a, role_b])
] discord_user = create_user(id=TEST_USER_ID)
mock_user_formatted_nick.return_value = None mock_user_formatted_nick.return_value = None
mock_user_group_names.return_value = ['a', 'b', 'c']
mock_exchange_auth_code_for_token.return_value = self.access_token mock_exchange_auth_code_for_token.return_value = self.access_token
mock_DiscordClient.return_value.current_user.return_value = self.user_info mock_DiscordClient.return_value.current_user.return_value = discord_user
mock_DiscordClient.return_value.match_or_create_roles_from_names\ mock_calculate_roles_for_user.return_value = roles_calculated, False
.return_value = roles mock_create_bot_client.return_value.add_guild_member.return_value = None
mock_DiscordClient.return_value.add_guild_member.return_value = None mock_create_bot_client.return_value.modify_guild_member.return_value = True
mock_DiscordClient.return_value.modify_guild_member.return_value = True # when
result = DiscordUser.objects.add_user(self.user, authorization_code='abcdef') result = DiscordUser.objects.add_user(self.user, authorization_code='abcdef')
# then
self.assertTrue(result) self.assertTrue(result)
self.assertTrue( self.assertTrue(
DiscordUser.objects.filter(user=self.user, uid=TEST_USER_ID).exists() DiscordUser.objects.filter(user=self.user, uid=TEST_USER_ID).exists()
) )
self.assertTrue(mock_DiscordClient.return_value.add_guild_member.called) _, kwargs = mock_create_bot_client.return_value.modify_guild_member.call_args
self.assertTrue(mock_DiscordClient.return_value.modify_guild_member.called)
args, kwargs = mock_DiscordClient.return_value.modify_guild_member.call_args
self.assertEqual(kwargs['guild_id'], TEST_GUILD_ID) self.assertEqual(kwargs['guild_id'], TEST_GUILD_ID)
self.assertEqual(kwargs['user_id'], TEST_USER_ID) self.assertEqual(kwargs['user_id'], TEST_USER_ID)
self.assertSetEqual(set(kwargs['role_ids']), {1, 2, 3}) self.assertSetEqual(set(kwargs['role_ids']), {1, 2})
self.assertIsNone(kwargs['nick']) self.assertIsNone(kwargs['nick'])
@patch(MODULE_PATH + '.managers.DISCORD_SYNC_NAMES', True) @patch(MODULE_PATH + '.managers.DISCORD_SYNC_NAMES', True)
def test_can_create_user_no_roles_with_nick( def test_can_create_user_no_roles_with_nick(
self, self,
mock_user_formatted_nick, mock_user_formatted_nick,
mock_user_group_names, mock_calculate_roles_for_user,
mock_exchange_auth_code_for_token, mock_exchange_auth_code_for_token,
mock_DiscordClient mock_create_bot_client,
mock_DiscordClient,
): ):
# given
discord_user = create_user(id=TEST_USER_ID)
mock_user_formatted_nick.return_value = TEST_MAIN_NAME mock_user_formatted_nick.return_value = TEST_MAIN_NAME
mock_user_group_names.return_value = []
mock_exchange_auth_code_for_token.return_value = self.access_token mock_exchange_auth_code_for_token.return_value = self.access_token
mock_DiscordClient.return_value.current_user.return_value = self.user_info mock_DiscordClient.return_value.current_user.return_value = discord_user
mock_DiscordClient.return_value.match_or_create_roles_from_names\ mock_calculate_roles_for_user.return_value = RolesSet([]), None
.return_value = [] mock_create_bot_client.return_value.add_guild_member.return_value = True
mock_DiscordClient.return_value.add_guild_member.return_value = True # when
result = DiscordUser.objects.add_user(self.user, authorization_code='abcdef') result = DiscordUser.objects.add_user(self.user, authorization_code='abcdef')
# then
self.assertTrue(result) self.assertTrue(result)
self.assertTrue( self.assertTrue(
DiscordUser.objects.filter(user=self.user, uid=TEST_USER_ID).exists() DiscordUser.objects.filter(user=self.user, uid=TEST_USER_ID).exists()
) )
self.assertTrue(mock_DiscordClient.return_value.add_guild_member.called) _, kwargs = mock_create_bot_client.return_value.add_guild_member.call_args
args, kwargs = mock_DiscordClient.return_value.add_guild_member.call_args
self.assertEqual(kwargs['guild_id'], TEST_GUILD_ID) self.assertEqual(kwargs['guild_id'], TEST_GUILD_ID)
self.assertEqual(kwargs['user_id'], TEST_USER_ID) self.assertEqual(kwargs['user_id'], TEST_USER_ID)
self.assertEqual(kwargs['access_token'], self.access_token) self.assertEqual(kwargs['access_token'], self.access_token)
self.assertIsNone(kwargs['role_ids']) self.assertFalse(kwargs['role_ids'])
self.assertEqual(kwargs['nick'], TEST_MAIN_NAME) self.assertEqual(kwargs['nick'], TEST_MAIN_NAME)
@patch(MODULE_PATH + '.managers.DISCORD_SYNC_NAMES', True) @patch(MODULE_PATH + '.managers.DISCORD_SYNC_NAMES', True)
def test_can_activate_existing_user_no_roles_with_nick( def test_can_activate_existing_user_no_roles_with_nick(
self, self,
mock_user_formatted_nick, mock_user_formatted_nick,
mock_user_group_names, mock_calculate_roles_for_user,
mock_exchange_auth_code_for_token, mock_exchange_auth_code_for_token,
mock_DiscordClient mock_create_bot_client,
mock_DiscordClient,
): ):
# given
discord_user = create_user(id=TEST_USER_ID)
mock_user_formatted_nick.return_value = TEST_MAIN_NAME mock_user_formatted_nick.return_value = TEST_MAIN_NAME
mock_user_group_names.return_value = []
mock_exchange_auth_code_for_token.return_value = self.access_token mock_exchange_auth_code_for_token.return_value = self.access_token
mock_DiscordClient.return_value.current_user.return_value = self.user_info mock_DiscordClient.return_value.current_user.return_value = discord_user
mock_DiscordClient.return_value.match_or_create_roles_from_names\ mock_calculate_roles_for_user.return_value = RolesSet([]), False
.return_value = [] mock_create_bot_client.return_value.add_guild_member.return_value = None
mock_DiscordClient.return_value.add_guild_member.return_value = None mock_create_bot_client.return_value.modify_guild_member.return_value = True
mock_DiscordClient.return_value.modify_guild_member.return_value = True # when
result = DiscordUser.objects.add_user(self.user, authorization_code='abcdef') result = DiscordUser.objects.add_user(self.user, authorization_code='abcdef')
# then
self.assertTrue(result) self.assertTrue(result)
self.assertTrue( self.assertTrue(
DiscordUser.objects.filter(user=self.user, uid=TEST_USER_ID).exists() DiscordUser.objects.filter(user=self.user, uid=TEST_USER_ID).exists()
) )
self.assertTrue(mock_DiscordClient.return_value.add_guild_member.called) _, kwargs = mock_create_bot_client.return_value.modify_guild_member.call_args
self.assertTrue(mock_DiscordClient.return_value.modify_guild_member.called)
args, kwargs = mock_DiscordClient.return_value.modify_guild_member.call_args
self.assertEqual(kwargs['guild_id'], TEST_GUILD_ID) self.assertEqual(kwargs['guild_id'], TEST_GUILD_ID)
self.assertEqual(kwargs['user_id'], TEST_USER_ID) self.assertEqual(kwargs['user_id'], TEST_USER_ID)
self.assertIsNone(kwargs['role_ids']) self.assertFalse(kwargs['role_ids'])
self.assertEqual(kwargs['nick'], TEST_MAIN_NAME) self.assertEqual(kwargs['nick'], TEST_MAIN_NAME)
@patch(MODULE_PATH + '.managers.DISCORD_SYNC_NAMES', False) @patch(MODULE_PATH + '.managers.DISCORD_SYNC_NAMES', False)
def test_can_create_user_no_roles_and_without_nick_if_turned_off( def test_can_create_user_no_roles_and_without_nick_if_turned_off(
self, self,
mock_user_formatted_nick, mock_user_formatted_nick,
mock_user_group_names, mock_calculate_roles_for_user,
mock_exchange_auth_code_for_token, mock_exchange_auth_code_for_token,
mock_DiscordClient mock_create_bot_client,
mock_DiscordClient,
): ):
# given
discord_user = create_user(id=TEST_USER_ID)
mock_user_formatted_nick.return_value = TEST_MAIN_NAME mock_user_formatted_nick.return_value = TEST_MAIN_NAME
mock_user_group_names.return_value = []
mock_exchange_auth_code_for_token.return_value = self.access_token mock_exchange_auth_code_for_token.return_value = self.access_token
mock_DiscordClient.return_value.current_user.return_value = self.user_info mock_DiscordClient.return_value.current_user.return_value = discord_user
mock_DiscordClient.return_value.match_or_create_roles_from_names\ mock_calculate_roles_for_user.return_value = RolesSet([]), None
.return_value = [] mock_create_bot_client.return_value.add_guild_member.return_value = True
mock_DiscordClient.return_value.add_guild_member.return_value = True # when
result = DiscordUser.objects.add_user(self.user, authorization_code='abcdef') result = DiscordUser.objects.add_user(self.user, authorization_code='abcdef')
# then
self.assertTrue(result) self.assertTrue(result)
self.assertTrue( self.assertTrue(
DiscordUser.objects.filter(user=self.user, uid=TEST_USER_ID).exists() DiscordUser.objects.filter(user=self.user, uid=TEST_USER_ID).exists()
) )
self.assertTrue(mock_DiscordClient.return_value.add_guild_member.called) _, kwargs = mock_create_bot_client.return_value.add_guild_member.call_args
args, kwargs = mock_DiscordClient.return_value.add_guild_member.call_args
self.assertEqual(kwargs['guild_id'], TEST_GUILD_ID) self.assertEqual(kwargs['guild_id'], TEST_GUILD_ID)
self.assertEqual(kwargs['user_id'], TEST_USER_ID) self.assertEqual(kwargs['user_id'], TEST_USER_ID)
self.assertEqual(kwargs['access_token'], self.access_token) self.assertEqual(kwargs['access_token'], self.access_token)
self.assertIsNone(kwargs['role_ids']) self.assertFalse(kwargs['role_ids'])
self.assertIsNone(kwargs['nick']) self.assertIsNone(kwargs['nick'])
def test_can_activate_existing_guild_member( def test_can_activate_existing_guild_member(
self, self,
mock_user_formatted_nick, mock_user_formatted_nick,
mock_user_group_names, mock_calculate_roles_for_user,
mock_exchange_auth_code_for_token, mock_exchange_auth_code_for_token,
mock_DiscordClient mock_create_bot_client,
mock_DiscordClient,
): ):
# given
discord_user = create_user(id=TEST_USER_ID)
roles_calculated = RolesSet([create_role()])
mock_user_formatted_nick.return_value = None mock_user_formatted_nick.return_value = None
mock_user_group_names.return_value = []
mock_exchange_auth_code_for_token.return_value = self.access_token mock_exchange_auth_code_for_token.return_value = self.access_token
mock_DiscordClient.return_value.current_user.return_value = self.user_info mock_DiscordClient.return_value.current_user.return_value = discord_user
mock_DiscordClient.return_value.match_or_create_roles_from_names\ mock_calculate_roles_for_user.return_value = roles_calculated, False
.return_value = [] mock_create_bot_client.return_value.add_guild_member.return_value = None
mock_DiscordClient.return_value.add_guild_member.return_value = None mock_create_bot_client.return_value.modify_guild_member.return_value = True
mock_DiscordClient.return_value.modify_guild_member.return_value = True # when
result = DiscordUser.objects.add_user(self.user, authorization_code='abcdef') result = DiscordUser.objects.add_user(self.user, authorization_code='abcdef')
# then
self.assertTrue(result) self.assertTrue(result)
self.assertTrue( self.assertTrue(
DiscordUser.objects.filter(user=self.user, uid=TEST_USER_ID).exists() DiscordUser.objects.filter(user=self.user, uid=TEST_USER_ID).exists()
) )
self.assertTrue(mock_DiscordClient.return_value.add_guild_member.called) self.assertTrue(mock_create_bot_client.return_value.modify_guild_member.called)
self.assertTrue(mock_DiscordClient.return_value.modify_guild_member.called)
def test_can_activate_existing_member_with_roles(
self,
mock_user_formatted_nick,
mock_calculate_roles_for_user,
mock_exchange_auth_code_for_token,
mock_create_bot_client,
mock_DiscordClient,
):
# given
discord_user = create_user(id=TEST_USER_ID)
roles_calculated = RolesSet([create_role(id=1)])
mock_user_formatted_nick.return_value = None
mock_exchange_auth_code_for_token.return_value = self.access_token
mock_DiscordClient.return_value.current_user.return_value = discord_user
mock_calculate_roles_for_user.return_value = roles_calculated, False
mock_create_bot_client.return_value.add_guild_member.return_value = None
mock_create_bot_client.return_value.modify_guild_member.return_value = True
# when
result = DiscordUser.objects.add_user(self.user, authorization_code='abcdef')
# then
self.assertTrue(result)
self.assertTrue(
DiscordUser.objects.filter(user=self.user, uid=TEST_USER_ID).exists()
)
_, kwargs = mock_create_bot_client.return_value.modify_guild_member.call_args
self.assertSetEqual(set(kwargs['role_ids']), {1})
def test_can_activate_existing_guild_member_failure( def test_can_activate_existing_guild_member_failure(
self, self,
mock_user_formatted_nick, mock_user_formatted_nick,
mock_user_group_names, mock_calculate_roles_for_user,
mock_exchange_auth_code_for_token, mock_exchange_auth_code_for_token,
mock_DiscordClient mock_create_bot_client,
mock_DiscordClient,
): ):
# given
discord_user = create_user(id=TEST_USER_ID)
roles_calculated = RolesSet([create_role()])
mock_user_formatted_nick.return_value = None mock_user_formatted_nick.return_value = None
mock_user_group_names.return_value = []
mock_exchange_auth_code_for_token.return_value = self.access_token mock_exchange_auth_code_for_token.return_value = self.access_token
mock_DiscordClient.return_value.current_user.return_value = self.user_info mock_DiscordClient.return_value.current_user.return_value = discord_user
mock_DiscordClient.return_value.match_or_create_roles_from_names\ mock_calculate_roles_for_user.return_value = roles_calculated, False
.return_value = [] mock_create_bot_client.return_value.add_guild_member.return_value = None
mock_DiscordClient.return_value.add_guild_member.return_value = None mock_create_bot_client.return_value.modify_guild_member.return_value = False
mock_DiscordClient.return_value.modify_guild_member.return_value = False # when
result = DiscordUser.objects.add_user(self.user, authorization_code='abcdef') result = DiscordUser.objects.add_user(self.user, authorization_code='abcdef')
# then
self.assertFalse(result) self.assertFalse(result)
self.assertFalse( self.assertFalse(
DiscordUser.objects.filter(user=self.user, uid=TEST_USER_ID).exists() DiscordUser.objects.filter(user=self.user, uid=TEST_USER_ID).exists()
) )
self.assertTrue(mock_DiscordClient.return_value.add_guild_member.called) self.assertTrue(mock_create_bot_client.return_value.modify_guild_member.called)
self.assertTrue(mock_DiscordClient.return_value.modify_guild_member.called)
def test_return_false_when_user_creation_fails( def test_return_false_when_user_creation_fails(
self, self,
mock_user_formatted_nick, mock_user_formatted_nick,
mock_user_group_names, mock_calculate_roles_for_user,
mock_exchange_auth_code_for_token, mock_exchange_auth_code_for_token,
mock_DiscordClient mock_create_bot_client,
mock_DiscordClient,
): ):
# given
discord_user = create_user(id=TEST_USER_ID)
mock_user_formatted_nick.return_value = None mock_user_formatted_nick.return_value = None
mock_user_group_names.return_value = []
mock_exchange_auth_code_for_token.return_value = self.access_token mock_exchange_auth_code_for_token.return_value = self.access_token
mock_DiscordClient.return_value.current_user.return_value = self.user_info mock_DiscordClient.return_value.current_user.return_value = discord_user
mock_DiscordClient.return_value.match_or_create_roles_from_names\ mock_calculate_roles_for_user.return_value = RolesSet([]), None
.return_value = [] mock_create_bot_client.return_value.add_guild_member.return_value = False
mock_DiscordClient.return_value.add_guild_member.return_value = False # when
result = DiscordUser.objects.add_user(self.user, authorization_code='abcdef') result = DiscordUser.objects.add_user(self.user, authorization_code='abcdef')
# then
self.assertFalse(result) self.assertFalse(result)
self.assertFalse( self.assertFalse(
DiscordUser.objects.filter(user=self.user, uid=TEST_USER_ID).exists() DiscordUser.objects.filter(user=self.user, uid=TEST_USER_ID).exists()
) )
self.assertTrue(mock_DiscordClient.return_value.add_guild_member.called) self.assertTrue(mock_create_bot_client.return_value.add_guild_member.called)
def test_return_false_when_on_api_backoff( def test_return_false_when_on_api_backoff(
self, self,
mock_user_formatted_nick, mock_user_formatted_nick,
mock_user_group_names, mock_calculate_roles_for_user,
mock_exchange_auth_code_for_token, mock_exchange_auth_code_for_token,
mock_DiscordClient mock_create_bot_client,
mock_DiscordClient,
): ):
# given
discord_user = create_user(id=TEST_USER_ID)
mock_user_formatted_nick.return_value = None mock_user_formatted_nick.return_value = None
mock_user_group_names.return_value = []
mock_exchange_auth_code_for_token.return_value = self.access_token mock_exchange_auth_code_for_token.return_value = self.access_token
mock_DiscordClient.return_value.current_user.return_value = self.user_info mock_DiscordClient.return_value.current_user.return_value = discord_user
mock_DiscordClient.return_value.match_or_create_roles_from_names\ mock_calculate_roles_for_user.return_value = RolesSet([]), None
.return_value = [] mock_create_bot_client.return_value.add_guild_member.side_effect = \
mock_DiscordClient.return_value.add_guild_member.side_effect = \
DiscordApiBackoff(999) DiscordApiBackoff(999)
# when
result = DiscordUser.objects.add_user(self.user, authorization_code='abcdef') result = DiscordUser.objects.add_user(self.user, authorization_code='abcdef')
# then
self.assertFalse(result) self.assertFalse(result)
self.assertFalse( self.assertFalse(
DiscordUser.objects.filter(user=self.user, uid=TEST_USER_ID).exists() DiscordUser.objects.filter(user=self.user, uid=TEST_USER_ID).exists()
) )
self.assertTrue(mock_DiscordClient.return_value.add_guild_member.called) self.assertTrue(mock_create_bot_client.return_value.add_guild_member.called)
def test_return_false_on_http_error( def test_return_false_on_http_error(
self, self,
mock_user_formatted_nick, mock_user_formatted_nick,
mock_user_group_names, mock_calculate_roles_for_user,
mock_exchange_auth_code_for_token, mock_exchange_auth_code_for_token,
mock_DiscordClient mock_create_bot_client,
mock_DiscordClient,
): ):
# given
discord_user = create_user(id=TEST_USER_ID)
mock_user_formatted_nick.return_value = None mock_user_formatted_nick.return_value = None
mock_user_group_names.return_value = []
mock_exchange_auth_code_for_token.return_value = self.access_token mock_exchange_auth_code_for_token.return_value = self.access_token
mock_DiscordClient.return_value.current_user.return_value = self.user_info mock_DiscordClient.return_value.current_user.return_value = discord_user
mock_DiscordClient.return_value.match_or_create_roles_from_names\ mock_calculate_roles_for_user.return_value = RolesSet([]), None
.return_value = []
mock_exception = HTTPError('error') mock_exception = HTTPError('error')
mock_exception.response = Mock() mock_exception.response = Mock()
mock_exception.response.status_code = 500 mock_exception.response.status_code = 500
mock_DiscordClient.return_value.add_guild_member.side_effect = mock_exception mock_create_bot_client.return_value.add_guild_member.side_effect = mock_exception
# when
result = DiscordUser.objects.add_user(self.user, authorization_code='abcdef') result = DiscordUser.objects.add_user(self.user, authorization_code='abcdef')
# then
self.assertFalse(result) self.assertFalse(result)
self.assertFalse( self.assertFalse(
DiscordUser.objects.filter(user=self.user, uid=TEST_USER_ID).exists() DiscordUser.objects.filter(user=self.user, uid=TEST_USER_ID).exists()
) )
self.assertTrue(mock_DiscordClient.return_value.add_guild_member.called) self.assertTrue(mock_create_bot_client.return_value.add_guild_member.called)
class TestOauthHelpers(TestCase): class TestOauthHelpers(NoSocketsTestCase):
@patch(MODULE_PATH + '.managers.DISCORD_APP_ID', '123456') @patch(MODULE_PATH + '.managers.DISCORD_APP_ID', '123456')
def test_generate_bot_add_url(self): def test_generate_bot_add_url(self):
bot_add_url = DiscordUser.objects.generate_bot_add_url() bot_add_url = DiscordUser.objects.generate_bot_add_url()
auth_url = DiscordClient.OAUTH_BASE_URL auth_url = DISCORD_OAUTH_BASE_URL
real_bot_add_url = ( real_bot_add_url = (
f'{auth_url}?client_id=123456&scope=bot' f'{auth_url}?client_id=123456&scope=bot'
f'&permissions={DiscordUser.objects.BOT_PERMISSIONS}' f'&permissions={DiscordUser.objects.BOT_PERMISSIONS}'
@@ -368,12 +399,12 @@ class TestOauthHelpers(TestCase):
def test_generate_oauth_redirect_url(self): def test_generate_oauth_redirect_url(self):
oauth_url = DiscordUser.objects.generate_oauth_redirect_url() oauth_url = DiscordUser.objects.generate_oauth_redirect_url()
self.assertIn(DiscordClient.OAUTH_BASE_URL, oauth_url) self.assertIn(DISCORD_OAUTH_BASE_URL, oauth_url)
self.assertIn('+'.join(DiscordUser.objects.SCOPES), oauth_url) self.assertIn('+'.join(DiscordUser.objects.SCOPES), oauth_url)
self.assertIn(DISCORD_APP_ID, oauth_url) self.assertIn(DISCORD_APP_ID, oauth_url)
self.assertIn(urllib.parse.quote_plus(DISCORD_CALLBACK_URL), oauth_url) self.assertIn(urllib.parse.quote_plus(DISCORD_CALLBACK_URL), oauth_url)
@patch(MODULE_PATH + '.managers.OAuth2Session') @patch(MODULE_PATH + '.managers.OAuth2Session', spec=True)
def test_process_callback_code(self, oauth): def test_process_callback_code(self, oauth):
instance = oauth.return_value instance = oauth.return_value
instance.fetch_token.return_value = {'access_token': 'mywonderfultoken'} instance.fetch_token.return_value = {'access_token': 'mywonderfultoken'}
@@ -386,52 +417,13 @@ class TestOauthHelpers(TestCase):
self.assertEqual(kwargs['redirect_uri'], DISCORD_CALLBACK_URL) self.assertEqual(kwargs['redirect_uri'], DISCORD_CALLBACK_URL)
self.assertTrue(instance.fetch_token.called) self.assertTrue(instance.fetch_token.called)
args, kwargs = instance.fetch_token.call_args args, kwargs = instance.fetch_token.call_args
self.assertEqual(args[0], DiscordClient.OAUTH_TOKEN_URL) self.assertEqual(args[0], DISCORD_OAUTH_TOKEN_URL)
self.assertEqual(kwargs['client_secret'], DISCORD_APP_SECRET) self.assertEqual(kwargs['client_secret'], DISCORD_APP_SECRET)
self.assertEqual(kwargs['code'], '12345') self.assertEqual(kwargs['code'], '12345')
self.assertEqual(token, 'mywonderfultoken') self.assertEqual(token, 'mywonderfultoken')
class TestUserFormattedNick(TestCase): class TestUserHasAccount(NoSocketsTestCase):
def setUp(self):
self.user = AuthUtils.create_user(TEST_USER_NAME)
def test_return_nick_when_user_has_main(self):
AuthUtils.add_main_character_2(self.user, TEST_MAIN_NAME, TEST_MAIN_ID)
result = DiscordUser.objects.user_formatted_nick(self.user)
expected = TEST_MAIN_NAME
self.assertEqual(result, expected)
def test_return_none_if_user_has_no_main(self):
result = DiscordUser.objects.user_formatted_nick(self.user)
self.assertIsNone(result)
class TestUserGroupNames(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.group_1 = Group.objects.create(name='Group 1')
cls.group_2 = Group.objects.create(name='Group 2')
def setUp(self):
self.user = AuthUtils.create_member(TEST_USER_NAME)
def test_return_groups_and_state_names_for_user(self):
self.user.groups.add(self.group_1)
result = DiscordUser.objects.user_group_names(self.user)
expected = ['Group 1', 'Member']
self.assertSetEqual(set(result), set(expected))
def test_return_state_only_if_user_has_no_groups(self):
result = DiscordUser.objects.user_group_names(self.user)
expected = ['Member']
self.assertSetEqual(set(result), set(expected))
class TestUserHasAccount(TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
@@ -453,59 +445,22 @@ class TestUserHasAccount(TestCase):
self.assertFalse(DiscordUser.objects.user_has_account('abc')) self.assertFalse(DiscordUser.objects.user_has_account('abc'))
@patch(MODULE_PATH + '.managers.DiscordClient', spec=DiscordClient) class TestOtherMethods(NoSocketsTestCase):
@patch(MODULE_PATH + '.managers.logger') @patch(MODULE_PATH + '.managers.core_group_to_role', spec=True)
class TestServerName(TestCase): def test_should_call_group_to_role(self, mock_core_group_to_role):
# given
role = create_role(id=1, name="alpha", managed=False)
mock_core_group_to_role.return_value = role
# when
result = DiscordUser.objects.group_to_role(Mock())
# then
self.assertEqual(result["id"], 1)
self.assertEqual(result["name"], "alpha")
self.assertEqual(result["managed"], False)
@classmethod @patch(MODULE_PATH + '.managers.core_server_name', spec=True)
def setUpClass(cls): def test_should_call_server_name(self, mock_core_server_name):
super().setUpClass() # when
cls.user = AuthUtils.create_user(TEST_USER_NAME) DiscordUser.objects.server_name()
# then
def test_returns_name_when_api_returns_it(self, mock_logger, mock_DiscordClient): self.assertTrue(mock_core_server_name.called)
server_name = "El Dorado"
mock_DiscordClient.return_value.guild_name.return_value = server_name
self.assertEqual(DiscordUser.objects.server_name(), server_name)
self.assertFalse(mock_logger.warning.called)
def test_returns_empty_string_when_api_throws_http_error(
self, mock_logger, mock_DiscordClient
):
mock_exception = HTTPError('Test exception')
mock_exception.response = Mock(**{"status_code": 440})
mock_DiscordClient.return_value.guild_name.side_effect = mock_exception
self.assertEqual(DiscordUser.objects.server_name(), "")
self.assertFalse(mock_logger.warning.called)
def test_returns_empty_string_when_api_throws_service_error(
self, mock_logger, mock_DiscordClient
):
mock_DiscordClient.return_value.guild_name.side_effect = DiscordApiBackoff(1000)
self.assertEqual(DiscordUser.objects.server_name(), "")
self.assertFalse(mock_logger.warning.called)
def test_returns_empty_string_when_api_throws_unexpected_error(
self, mock_logger, mock_DiscordClient
):
mock_DiscordClient.return_value.guild_name.side_effect = RuntimeError
self.assertEqual(DiscordUser.objects.server_name(), "")
self.assertTrue(mock_logger.warning.called)
@patch(MODULE_PATH + '.managers.DiscordClient', spec=DiscordClient)
class TestRoleForGroup(TestCase):
def test_return_role_if_found(self, mock_DiscordClient):
mock_DiscordClient.return_value.match_role_from_name.return_value = ROLE_ALPHA
group = Group.objects.create(name='alpha')
self.assertEqual(DiscordUser.objects.group_to_role(group), ROLE_ALPHA)
def test_return_empty_dict_if_not_found(self, mock_DiscordClient):
mock_DiscordClient.return_value.match_role_from_name.return_value = dict()
group = Group.objects.create(name='unknown')
self.assertEqual(DiscordUser.objects.group_to_role(group), dict())

View File

@@ -1,34 +1,27 @@
from unittest.mock import patch, Mock from unittest.mock import Mock, patch
from requests.exceptions import HTTPError from requests.exceptions import HTTPError
from django.test import TestCase
from allianceauth.tests.auth_utils import AuthUtils from allianceauth.tests.auth_utils import AuthUtils
from allianceauth.groupmanagement.models import ReservedGroupName from allianceauth.utils.testing import NoSocketsTestCase
from . import ( from ..discord_client import DiscordApiBackoff, RolesSet
TEST_USER_NAME, from ..discord_client.tests.factories import (
TEST_USER_ID, TEST_USER_ID,
TEST_MAIN_NAME, TEST_USER_NAME,
TEST_MAIN_ID, create_guild_member,
MODULE_PATH, create_role,
ROLE_ALPHA,
ROLE_BRAVO,
ROLE_CHARLIE,
ROLE_CHARLIE_2,
ROLE_MIKE,
) )
from ..discord_client import DiscordClient, DiscordApiBackoff from ..discord_client.tests.factories import create_user as create_guild_user
from ..discord_client.tests import create_matched_role
from ..models import DiscordUser from ..models import DiscordUser
from ..utils import set_logger_to_file from ..utils import set_logger_to_file
from . import MODULE_PATH, TEST_MAIN_ID, TEST_MAIN_NAME
from .factories import create_discord_user, create_user
logger = set_logger_to_file(MODULE_PATH + '.models', __file__) logger = set_logger_to_file(MODULE_PATH + '.models', __file__)
class TestBasicsAndHelpers(TestCase): class TestBasicsAndHelpers(NoSocketsTestCase):
def test_str(self): def test_str(self):
user = AuthUtils.create_user(TEST_USER_NAME) user = AuthUtils.create_user(TEST_USER_NAME)
@@ -43,8 +36,8 @@ class TestBasicsAndHelpers(TestCase):
self.assertEqual(repr(discord_user), expected) self.assertEqual(repr(discord_user), expected)
@patch(MODULE_PATH + '.managers.DiscordClient', spec=DiscordClient) @patch(MODULE_PATH + '.models.default_bot_client', spec=True)
class TestUpdateNick(TestCase): class TestUpdateNick(NoSocketsTestCase):
def setUp(self): def setUp(self):
self.user = AuthUtils.create_user(TEST_USER_NAME) self.user = AuthUtils.create_user(TEST_USER_NAME)
@@ -52,119 +45,92 @@ class TestUpdateNick(TestCase):
user=self.user, uid=TEST_USER_ID user=self.user, uid=TEST_USER_ID
) )
def test_can_update(self, mock_DiscordClient): def test_can_update(self, mock_default_bot_client):
AuthUtils.add_main_character_2(self.user, TEST_MAIN_NAME, TEST_MAIN_ID) # given
mock_DiscordClient.return_value.modify_guild_member.return_value = True AuthUtils.add_main_character_2(
self.user, TEST_MAIN_NAME, TEST_MAIN_ID, disconnect_signals=True
)
mock_default_bot_client.modify_guild_member.return_value = True
# when
result = self.discord_user.update_nickname() result = self.discord_user.update_nickname()
# then
self.assertTrue(result) self.assertTrue(result)
self.assertTrue(mock_DiscordClient.return_value.modify_guild_member.called) self.assertTrue(mock_default_bot_client.modify_guild_member.called)
def test_dont_update_if_user_has_no_main(self, mock_DiscordClient):
mock_DiscordClient.return_value.modify_guild_member.return_value = False
def test_dont_update_if_user_has_no_main(self, mock_default_bot_client):
# given
mock_default_bot_client.modify_guild_member.return_value = False
# when
result = self.discord_user.update_nickname() result = self.discord_user.update_nickname()
# then
self.assertFalse(result) self.assertFalse(result)
self.assertFalse(mock_DiscordClient.return_value.modify_guild_member.called) self.assertFalse(mock_default_bot_client.modify_guild_member.called)
def test_return_none_if_user_no_longer_a_member(self, mock_DiscordClient):
AuthUtils.add_main_character_2(self.user, TEST_MAIN_NAME, TEST_MAIN_ID)
mock_DiscordClient.return_value.modify_guild_member.return_value = None
def test_return_none_if_user_no_longer_a_member(self, mock_default_bot_client):
# given
AuthUtils.add_main_character_2(
self.user, TEST_MAIN_NAME, TEST_MAIN_ID, disconnect_signals=True
)
mock_default_bot_client.modify_guild_member.return_value = None
# when
result = self.discord_user.update_nickname() result = self.discord_user.update_nickname()
# then
self.assertIsNone(result) self.assertIsNone(result)
self.assertTrue(mock_DiscordClient.return_value.modify_guild_member.called) self.assertTrue(mock_default_bot_client.modify_guild_member.called)
def test_return_false_if_api_returns_false(self, mock_DiscordClient):
AuthUtils.add_main_character_2(self.user, TEST_MAIN_NAME, TEST_MAIN_ID)
mock_DiscordClient.return_value.modify_guild_member.return_value = False
def test_return_false_if_api_returns_false(self, mock_default_bot_client):
# given
AuthUtils.add_main_character_2(
self.user, TEST_MAIN_NAME, TEST_MAIN_ID, disconnect_signals=True
)
mock_default_bot_client.modify_guild_member.return_value = False
# when
result = self.discord_user.update_nickname() result = self.discord_user.update_nickname()
# then
self.assertFalse(result) self.assertFalse(result)
self.assertTrue(mock_DiscordClient.return_value.modify_guild_member.called) self.assertTrue(mock_default_bot_client.modify_guild_member.called)
@patch(MODULE_PATH + '.managers.DiscordClient', spec=DiscordClient) @patch(MODULE_PATH + '.models.default_bot_client.guild_member', spec=True)
class TestUpdateUsername(TestCase): class TestUpdateUsername(NoSocketsTestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
super().setUpClass() super().setUpClass()
cls.user = AuthUtils.create_user(TEST_USER_NAME) cls.user = create_user()
def setUp(self): def test_can_update(self, mock_guild_member):
self.discord_user = DiscordUser.objects.create( # given
user=self.user, discord_user = create_discord_user(user=self.user)
uid=TEST_USER_ID,
username=TEST_MAIN_NAME,
discriminator='1234'
)
def test_can_update(self, mock_DiscordClient):
new_username = 'New name' new_username = 'New name'
new_discriminator = '9876' new_discriminator = '9876'
user_info = { guild_user = create_guild_user(
'user': { username='New name', discriminator=new_discriminator
'id': str(TEST_USER_ID), )
'username': new_username, mock_guild_member.return_value = create_guild_member(user=guild_user)
'discriminator': new_discriminator, # when
} result = discord_user.update_username()
} # then
mock_DiscordClient.return_value.guild_member.return_value = user_info
result = self.discord_user.update_username()
self.assertTrue(result) self.assertTrue(result)
self.assertTrue(mock_DiscordClient.return_value.guild_member.called) self.assertTrue(mock_guild_member.called)
self.discord_user.refresh_from_db() discord_user.refresh_from_db()
self.assertEqual(self.discord_user.username, new_username) self.assertEqual(discord_user.username, new_username)
self.assertEqual(self.discord_user.discriminator, new_discriminator) self.assertEqual(discord_user.discriminator, new_discriminator)
def test_return_none_if_user_no_longer_a_member(self, mock_DiscordClient): def test_return_none_if_user_no_longer_a_member(self, mock_guild_member):
mock_DiscordClient.return_value.guild_member.return_value = None # given
result = self.discord_user.update_username() discord_user = create_discord_user(user=self.user)
mock_guild_member.return_value = None
# when
result = discord_user.update_username()
# then
self.assertIsNone(result) self.assertIsNone(result)
self.assertTrue(mock_DiscordClient.return_value.guild_member.called) self.assertTrue(mock_guild_member.called)
def test_return_false_if_api_returns_false(self, mock_DiscordClient):
mock_DiscordClient.return_value.guild_member.return_value = False
result = self.discord_user.update_username()
self.assertFalse(result)
self.assertTrue(mock_DiscordClient.return_value.guild_member.called)
def test_return_false_if_api_returns_corrput_data_1(self, mock_DiscordClient):
mock_DiscordClient.return_value.guild_member.return_value = {'invalid': True}
result = self.discord_user.update_username()
self.assertFalse(result)
self.assertTrue(mock_DiscordClient.return_value.guild_member.called)
def test_return_false_if_api_returns_corrput_data_2(self, mock_DiscordClient):
user_info = {
'user': {
'id': str(TEST_USER_ID),
'discriminator': '1234',
}
}
mock_DiscordClient.return_value.guild_member.return_value = user_info
result = self.discord_user.update_username()
self.assertFalse(result)
self.assertTrue(mock_DiscordClient.return_value.guild_member.called)
def test_return_false_if_api_returns_corrput_data_3(self, mock_DiscordClient):
user_info = {
'user': {
'id': str(TEST_USER_ID),
'username': TEST_USER_NAME,
}
}
mock_DiscordClient.return_value.guild_member.return_value = user_info
result = self.discord_user.update_username()
self.assertFalse(result)
self.assertTrue(mock_DiscordClient.return_value.guild_member.called)
@patch(MODULE_PATH + '.models.notify') @patch(MODULE_PATH + '.models.notify', spec=True)
@patch(MODULE_PATH + '.managers.DiscordClient', spec=DiscordClient) @patch(MODULE_PATH + '.models.create_bot_client', spec=True)
class TestDeleteUser(TestCase): class TestDeleteUser(NoSocketsTestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
@@ -176,272 +142,168 @@ class TestDeleteUser(TestCase):
user=self.user, uid=TEST_USER_ID user=self.user, uid=TEST_USER_ID
) )
def test_can_delete_user(self, mock_DiscordClient, mock_notify): def test_can_delete_user(self, mock_create_bot_client, mock_notify):
mock_DiscordClient.return_value.remove_guild_member.return_value = True # given
mock_create_bot_client.return_value.remove_guild_member.return_value = True
# when
result = self.discord_user.delete_user() result = self.discord_user.delete_user()
# then
self.assertTrue(result) self.assertTrue(result)
self.assertFalse( self.assertFalse(
DiscordUser.objects.filter(user=self.user, uid=TEST_USER_ID).exists() DiscordUser.objects.filter(user=self.user, uid=TEST_USER_ID).exists()
) )
self.assertTrue(mock_DiscordClient.return_value.remove_guild_member.called) self.assertTrue(mock_create_bot_client.return_value.remove_guild_member.called)
self.assertFalse(mock_notify.called) self.assertFalse(mock_notify.called)
def test_can_delete_user_and_notify_user(self, mock_DiscordClient, mock_notify): def test_can_delete_user_and_notify_user(self, mock_create_bot_client, mock_notify):
mock_DiscordClient.return_value.remove_guild_member.return_value = True # given
mock_create_bot_client.return_value.remove_guild_member.return_value = True
# when
result = self.discord_user.delete_user(notify_user=True) result = self.discord_user.delete_user(notify_user=True)
# then
self.assertTrue(result) self.assertTrue(result)
self.assertTrue(mock_notify.called) self.assertTrue(mock_notify.called)
def test_can_delete_user_when_member_is_unknown( def test_can_delete_user_when_member_is_unknown(
self, mock_DiscordClient, mock_notify self, mock_create_bot_client, mock_notify
): ):
mock_DiscordClient.return_value.remove_guild_member.return_value = None # given
mock_create_bot_client.return_value.remove_guild_member.return_value = None
# when
result = self.discord_user.delete_user() result = self.discord_user.delete_user()
# then
self.assertTrue(result) self.assertTrue(result)
self.assertFalse( self.assertFalse(
DiscordUser.objects.filter(user=self.user, uid=TEST_USER_ID).exists() DiscordUser.objects.filter(user=self.user, uid=TEST_USER_ID).exists()
) )
self.assertTrue(mock_DiscordClient.return_value.remove_guild_member.called) self.assertTrue(mock_create_bot_client.return_value.remove_guild_member.called)
self.assertFalse(mock_notify.called) self.assertFalse(mock_notify.called)
def test_return_false_when_api_fails(self, mock_DiscordClient, mock_notify): def test_return_false_when_api_fails(self, mock_create_bot_client, mock_notify):
mock_DiscordClient.return_value.remove_guild_member.return_value = False # given
mock_create_bot_client.return_value.remove_guild_member.return_value = False
# when
result = self.discord_user.delete_user() result = self.discord_user.delete_user()
# then
self.assertFalse(result) self.assertFalse(result)
def test_dont_notify_if_user_was_already_deleted_and_return_none( def test_dont_notify_if_user_was_already_deleted_and_return_none(
self, mock_DiscordClient, mock_notify self, mock_create_bot_client, mock_notify
): ):
mock_DiscordClient.return_value.remove_guild_member.return_value = None # given
mock_create_bot_client.return_value.remove_guild_member.return_value = None
DiscordUser.objects.get(pk=self.discord_user.pk).delete() DiscordUser.objects.get(pk=self.discord_user.pk).delete()
# when
result = self.discord_user.delete_user() result = self.discord_user.delete_user()
# then
self.assertIsNone(result) self.assertIsNone(result)
self.assertFalse( self.assertFalse(
DiscordUser.objects.filter(user=self.user, uid=TEST_USER_ID).exists() DiscordUser.objects.filter(user=self.user, uid=TEST_USER_ID).exists()
) )
self.assertTrue(mock_DiscordClient.return_value.remove_guild_member.called) self.assertTrue(mock_create_bot_client.return_value.remove_guild_member.called)
self.assertFalse(mock_notify.called) self.assertFalse(mock_notify.called)
def test_raise_exception_on_api_backoff( def test_raise_exception_on_api_backoff(
self, mock_DiscordClient, mock_notify self, mock_create_bot_client, mock_notify
): ):
mock_DiscordClient.return_value.remove_guild_member.side_effect = \ # given
mock_create_bot_client.return_value.remove_guild_member.side_effect = \
DiscordApiBackoff(999) DiscordApiBackoff(999)
# when/then
with self.assertRaises(DiscordApiBackoff): with self.assertRaises(DiscordApiBackoff):
self.discord_user.delete_user() self.discord_user.delete_user()
def test_return_false_on_api_backoff_and_exception_handling_on( def test_return_false_on_api_backoff_and_exception_handling_on(
self, mock_DiscordClient, mock_notify self, mock_create_bot_client, mock_notify
): ):
mock_DiscordClient.return_value.remove_guild_member.side_effect = \ # given
mock_create_bot_client.return_value.remove_guild_member.side_effect = \
DiscordApiBackoff(999) DiscordApiBackoff(999)
# when
result = self.discord_user.delete_user(handle_api_exceptions=True) result = self.discord_user.delete_user(handle_api_exceptions=True)
# then
self.assertFalse(result) self.assertFalse(result)
def test_raise_exception_on_http_error( def test_raise_exception_on_http_error(
self, mock_DiscordClient, mock_notify self, mock_create_bot_client, mock_notify
): ):
# given
mock_exception = HTTPError('error') mock_exception = HTTPError('error')
mock_exception.response = Mock() mock_exception.response = Mock()
mock_exception.response.status_code = 500 mock_exception.response.status_code = 500
mock_DiscordClient.return_value.remove_guild_member.side_effect = \ mock_create_bot_client.return_value.remove_guild_member.side_effect = \
mock_exception mock_exception
# when/then
with self.assertRaises(HTTPError): with self.assertRaises(HTTPError):
self.discord_user.delete_user() self.discord_user.delete_user()
def test_return_false_on_http_error_and_exception_handling_on( def test_return_false_on_http_error_and_exception_handling_on(
self, mock_DiscordClient, mock_notify self, mock_create_bot_client, mock_notify
): ):
# given
mock_exception = HTTPError('error') mock_exception = HTTPError('error')
mock_exception.response = Mock() mock_exception.response = Mock()
mock_exception.response.status_code = 500 mock_exception.response.status_code = 500
mock_DiscordClient.return_value.remove_guild_member.side_effect = \ mock_create_bot_client.return_value.remove_guild_member.side_effect = \
mock_exception mock_exception
# when
result = self.discord_user.delete_user(handle_api_exceptions=True) result = self.discord_user.delete_user(handle_api_exceptions=True)
# then
self.assertFalse(result) self.assertFalse(result)
@patch(MODULE_PATH + '.managers.DiscordClient', spec=DiscordClient) @patch(MODULE_PATH + '.models.default_bot_client', spec=True)
@patch(MODULE_PATH + '.models.DiscordUser.objects.user_group_names') @patch(MODULE_PATH + '.models.calculate_roles_for_user', spec=True)
class TestUpdateGroups(TestCase): class TestUpdateGroups(NoSocketsTestCase):
def setUp(self): def setUp(self):
self.user = AuthUtils.create_user(TEST_USER_NAME) user = AuthUtils.create_user(TEST_USER_NAME)
self.discord_user = DiscordUser.objects.create( self.discord_user = DiscordUser.objects.create(user=user, uid=TEST_USER_ID)
user=self.user, uid=TEST_USER_ID
)
self.guild_roles = [ROLE_ALPHA, ROLE_BRAVO, ROLE_CHARLIE, ROLE_MIKE]
self.roles_requested = [
create_matched_role(ROLE_ALPHA), create_matched_role(ROLE_BRAVO)
]
def test_update_if_needed( def test_should_update_when_roles_have_changed(
self, self, mock_calculate_roles_for_user, mock_client
mock_user_group_names,
mock_DiscordClient
):
roles_current = [1]
mock_user_group_names.return_value = []
mock_DiscordClient.return_value.match_or_create_roles_from_names\
.return_value = self.roles_requested
mock_DiscordClient.return_value.guild_roles.return_value = self.guild_roles
mock_DiscordClient.return_value.guild_member.return_value = \
{'roles': roles_current}
mock_DiscordClient.return_value.modify_guild_member.return_value = True
result = self.discord_user.update_groups()
self.assertTrue(result)
self.assertTrue(mock_DiscordClient.return_value.modify_guild_member.called)
args, kwargs = mock_DiscordClient.return_value.modify_guild_member.call_args
self.assertEqual(set(kwargs['role_ids']), {1, 2})
def test_should_update_and_preserve_managed_and_reserved_roles(
self,
mock_user_group_names,
mock_DiscordClient
): ):
# given # given
roles_current = [1, 3, 4, 13] mock_calculate_roles_for_user.return_value = RolesSet([create_role()]), True
mock_user_group_names.return_value = [] mock_client.modify_guild_member.return_value = True
mock_DiscordClient.return_value.match_or_create_roles_from_names\
.return_value = self.roles_requested
mock_DiscordClient.return_value.guild_roles.return_value = [
ROLE_ALPHA, ROLE_BRAVO, ROLE_CHARLIE, ROLE_MIKE, ROLE_CHARLIE_2
]
mock_DiscordClient.return_value.guild_member.return_value = {
'roles': roles_current
}
mock_DiscordClient.return_value.modify_guild_member.return_value = True
ReservedGroupName.objects.create(
name="charlie", reason="dummy", created_by="xyz"
)
# when # when
result = self.discord_user.update_groups() result = self.discord_user.update_groups()
# then # then
self.assertTrue(result) self.assertTrue(result)
self.assertTrue(mock_DiscordClient.return_value.modify_guild_member.called) self.assertTrue(mock_client.modify_guild_member.called)
args, kwargs = mock_DiscordClient.return_value.modify_guild_member.call_args
self.assertEqual(set(kwargs['role_ids']), {1, 2, 3, 4, 13})
def test_dont_update_if_not_needed( def test_should_not_update_when_roles_have_not_changed(
self, self, mock_calculate_roles_for_user, mock_client
mock_user_group_names,
mock_DiscordClient
): ):
roles_current = [1, 2, 13] # given
mock_user_group_names.return_value = [] mock_calculate_roles_for_user.return_value = RolesSet([create_role()]), False
mock_DiscordClient.return_value.match_or_create_roles_from_names\ mock_client.modify_guild_member.return_value = True
.return_value = self.roles_requested # when
mock_DiscordClient.return_value.guild_roles.return_value = self.guild_roles
mock_DiscordClient.return_value.guild_member.return_value = \
{'roles': roles_current}
result = self.discord_user.update_groups() result = self.discord_user.update_groups()
# then
self.assertTrue(result) self.assertTrue(result)
self.assertFalse(mock_DiscordClient.return_value.modify_guild_member.called) self.assertFalse(mock_client.modify_guild_member.called)
def test_update_if_user_has_no_roles_on_discord( def test_should_not_update_when_user_not_guild_member(
self, self, mock_calculate_roles_for_user, mock_client
mock_user_group_names,
mock_DiscordClient
): ):
roles_current = [] # given
mock_user_group_names.return_value = [] mock_calculate_roles_for_user.return_value = RolesSet([create_role()]), None
mock_DiscordClient.return_value.match_or_create_roles_from_names\ mock_client.modify_guild_member.return_value = True
.return_value = self.roles_requested # when
mock_DiscordClient.return_value.guild_roles.return_value = self.guild_roles
mock_DiscordClient.return_value.guild_member.return_value = \
{'roles': roles_current}
mock_DiscordClient.return_value.modify_guild_member.return_value = True
result = self.discord_user.update_groups()
self.assertTrue(result)
self.assertTrue(mock_DiscordClient.return_value.modify_guild_member.called)
args, kwargs = mock_DiscordClient.return_value.modify_guild_member.call_args
self.assertEqual(set(kwargs['role_ids']), {1, 2})
def test_return_none_if_user_no_longer_a_member(
self,
mock_user_group_names,
mock_DiscordClient
):
mock_DiscordClient.return_value.guild_member.return_value = None
result = self.discord_user.update_groups() result = self.discord_user.update_groups()
# then
self.assertIsNone(result) self.assertIsNone(result)
self.assertFalse(mock_DiscordClient.return_value.modify_guild_member.called) self.assertFalse(mock_client.modify_guild_member.called)
def test_return_false_if_api_returns_false( def test_should_return_false_when_update_failed(
self, self, mock_calculate_roles_for_user, mock_client
mock_user_group_names,
mock_DiscordClient
): ):
roles_current = [1] # given
mock_user_group_names.return_value = [] mock_calculate_roles_for_user.return_value = RolesSet([create_role()]), True
mock_DiscordClient.return_value.match_or_create_roles_from_names\ mock_client.modify_guild_member.return_value = False
.return_value = self.roles_requested # when
mock_DiscordClient.return_value.guild_roles.return_value = self.guild_roles
mock_DiscordClient.return_value.guild_member.return_value = \
{'roles': roles_current}
mock_DiscordClient.return_value.modify_guild_member.return_value = False
result = self.discord_user.update_groups() result = self.discord_user.update_groups()
# then
self.assertFalse(result) self.assertFalse(result)
self.assertTrue(mock_DiscordClient.return_value.modify_guild_member.called) self.assertTrue(mock_client.modify_guild_member.called)
def test_raise_exception_if_member_has_unknown_roles(
self,
mock_user_group_names,
mock_DiscordClient
):
roles_current = [99]
mock_user_group_names.return_value = []
mock_DiscordClient.return_value.match_or_create_roles_from_names\
.return_value = self.roles_requested
mock_DiscordClient.return_value.guild_roles.return_value = self.guild_roles
mock_DiscordClient.return_value.guild_member.return_value = \
{'roles': roles_current}
mock_DiscordClient.return_value.modify_guild_member.return_value = True
with self.assertRaises(RuntimeError):
self.discord_user.update_groups()
def test_refresh_guild_roles_user_roles_dont_not_match(
self,
mock_user_group_names,
mock_DiscordClient
):
def my_guild_roles(guild_id, use_cache=True):
if use_cache:
return [ROLE_ALPHA, ROLE_BRAVO, ROLE_MIKE]
else:
return [ROLE_ALPHA, ROLE_BRAVO, ROLE_CHARLIE, ROLE_MIKE]
roles_current = [3]
mock_user_group_names.return_value = []
mock_DiscordClient.return_value.match_or_create_roles_from_names\
.return_value = self.roles_requested
mock_DiscordClient.return_value.guild_roles.side_effect = my_guild_roles
mock_DiscordClient.return_value.guild_member.return_value = \
{'roles': roles_current}
mock_DiscordClient.return_value.modify_guild_member.return_value = True
result = self.discord_user.update_groups()
self.assertTrue(result)
self.assertEqual(mock_DiscordClient.return_value.guild_roles.call_count, 2)
def test_raise_exception_if_member_info_is_invalid(
self,
mock_user_group_names,
mock_DiscordClient
):
mock_user_group_names.return_value = []
mock_DiscordClient.return_value.match_or_create_roles_from_names\
.return_value = self.roles_requested
mock_DiscordClient.return_value.guild_roles.return_value = self.guild_roles
mock_DiscordClient.return_value.guild_member.return_value = \
{'user': 'dummy'}
mock_DiscordClient.return_value.modify_guild_member.return_value = True
with self.assertRaises(RuntimeError):
self.discord_user.update_groups()

View File

@@ -3,18 +3,18 @@ from unittest.mock import MagicMock, patch
from celery.exceptions import Retry from celery.exceptions import Retry
from requests.exceptions import HTTPError from requests.exceptions import HTTPError
from django.test import TestCase
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.test.utils import override_settings from django.test.utils import override_settings
from allianceauth.tests.auth_utils import AuthUtils from allianceauth.tests.auth_utils import AuthUtils
from allianceauth.utils.testing import NoSocketsTestCase
from . import TEST_USER_NAME, TEST_USER_ID, TEST_MAIN_NAME, TEST_MAIN_ID
from ..models import DiscordUser
from ..discord_client import DiscordApiBackoff
from .. import tasks from .. import tasks
from ..discord_client import DiscordApiBackoff
from ..discord_client.tests.factories import TEST_USER_ID, TEST_USER_NAME
from ..models import DiscordUser
from ..utils import set_logger_to_file from ..utils import set_logger_to_file
from . import TEST_MAIN_ID, TEST_MAIN_NAME
MODULE_PATH = 'allianceauth.services.modules.discord.tasks' MODULE_PATH = 'allianceauth.services.modules.discord.tasks'
logger = set_logger_to_file(MODULE_PATH, __file__) logger = set_logger_to_file(MODULE_PATH, __file__)
@@ -22,7 +22,7 @@ logger = set_logger_to_file(MODULE_PATH, __file__)
@patch(MODULE_PATH + '.DiscordUser.update_groups') @patch(MODULE_PATH + '.DiscordUser.update_groups')
@patch(MODULE_PATH + ".logger") @patch(MODULE_PATH + ".logger")
class TestUpdateGroups(TestCase): class TestUpdateGroups(NoSocketsTestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
@@ -110,7 +110,7 @@ class TestUpdateGroups(TestCase):
@patch(MODULE_PATH + '.DiscordUser.update_nickname') @patch(MODULE_PATH + '.DiscordUser.update_nickname')
class TestUpdateNickname(TestCase): class TestUpdateNickname(NoSocketsTestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
@@ -163,7 +163,7 @@ class TestUpdateNickname(TestCase):
@patch(MODULE_PATH + '.DiscordUser.update_username') @patch(MODULE_PATH + '.DiscordUser.update_username')
class TestUpdateUsername(TestCase): class TestUpdateUsername(NoSocketsTestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
@@ -179,7 +179,7 @@ class TestUpdateUsername(TestCase):
@patch(MODULE_PATH + '.DiscordUser.delete_user') @patch(MODULE_PATH + '.DiscordUser.delete_user')
class TestDeleteUser(TestCase): class TestDeleteUser(NoSocketsTestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
@@ -213,7 +213,7 @@ class TestDeleteUser(TestCase):
@patch(MODULE_PATH + '.DiscordUser.update_groups') @patch(MODULE_PATH + '.DiscordUser.update_groups')
class TestTaskPerformUserAction(TestCase): class TestTaskPerformUserAction(NoSocketsTestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
@@ -236,7 +236,7 @@ class TestTaskPerformUserAction(TestCase):
@patch(MODULE_PATH + '.DiscordUser.objects.server_name') @patch(MODULE_PATH + '.DiscordUser.objects.server_name')
@patch(MODULE_PATH + ".logger") @patch(MODULE_PATH + ".logger")
class TestTaskUpdateServername(TestCase): class TestTaskUpdateServername(NoSocketsTestCase):
def test_normal(self, mock_logger, mock_server_name): def test_normal(self, mock_logger, mock_server_name):
tasks.update_servername() tasks.update_servername()
@@ -281,7 +281,7 @@ class TestTaskUpdateServername(TestCase):
@patch(MODULE_PATH + '.DiscordUser.objects.server_name') @patch(MODULE_PATH + '.DiscordUser.objects.server_name')
class TestTaskPerformUsersAction(TestCase): class TestTaskPerformUsersAction(NoSocketsTestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
@@ -300,8 +300,8 @@ class TestTaskPerformUsersAction(TestCase):
tasks._task_perform_users_action(mock_task, 'server_name') tasks._task_perform_users_action(mock_task, 'server_name')
@override_settings(CELERY_ALWAYS_EAGER=True) @override_settings(CELERY_ALWAYS_EAGER=True, CELERY_EAGER_PROPAGATES_EXCEPTIONS=True)
class TestBulkTasks(TestCase): class TestBulkTasks(NoSocketsTestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):

View File

@@ -1,4 +1,5 @@
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from django.test import TestCase from django.test import TestCase
from ..utils import clean_setting from ..utils import clean_setting

View File

@@ -1,28 +1,28 @@
from unittest.mock import patch from unittest.mock import patch
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import TestCase, RequestFactory from django.test import RequestFactory
from django.urls import reverse from django.urls import reverse
from allianceauth.tests.auth_utils import AuthUtils from allianceauth.tests.auth_utils import AuthUtils
from allianceauth.utils.testing import NoSocketsTestCase
from . import MODULE_PATH, add_permissions_to_members, TEST_USER_NAME, TEST_USER_ID from ..discord_client.tests.factories import TEST_USER_ID, TEST_USER_NAME
from ..discord_client import DiscordClient
from ..models import DiscordUser from ..models import DiscordUser
from ..utils import set_logger_to_file from ..utils import set_logger_to_file
from ..views import ( from ..views import (
discord_callback, activate_discord,
reset_discord,
deactivate_discord, deactivate_discord,
discord_add_bot, discord_add_bot,
activate_discord discord_callback,
reset_discord,
) )
from . import MODULE_PATH, add_permissions_to_members
logger = set_logger_to_file(MODULE_PATH + '.views', __file__) logger = set_logger_to_file(MODULE_PATH + '.views', __file__)
class SetupClassMixin(TestCase): class SetupClassMixin(NoSocketsTestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
@@ -33,7 +33,7 @@ class SetupClassMixin(TestCase):
cls.services_url = reverse('services:services') cls.services_url = reverse('services:services')
class TestActivateDiscord(SetupClassMixin, TestCase): class TestActivateDiscord(SetupClassMixin, NoSocketsTestCase):
@patch(MODULE_PATH + '.views.DiscordUser.objects.generate_oauth_redirect_url') @patch(MODULE_PATH + '.views.DiscordUser.objects.generate_oauth_redirect_url')
def test_redirects_to_correct_url(self, mock_generate_oauth_redirect_url): def test_redirects_to_correct_url(self, mock_generate_oauth_redirect_url):
@@ -47,31 +47,37 @@ class TestActivateDiscord(SetupClassMixin, TestCase):
@patch(MODULE_PATH + '.views.messages') @patch(MODULE_PATH + '.views.messages')
@patch(MODULE_PATH + '.managers.DiscordClient', spec=DiscordClient) @patch(MODULE_PATH + '.models.create_bot_client')
class TestDeactivateDiscord(SetupClassMixin, TestCase): class TestDeactivateDiscord(SetupClassMixin, NoSocketsTestCase):
def setUp(self): def setUp(self):
DiscordUser.objects.create(user=self.user, uid=TEST_USER_ID) DiscordUser.objects.create(user=self.user, uid=TEST_USER_ID)
def test_when_successful_show_success_message( def test_when_successful_show_success_message(
self, mock_DiscordClient, mock_messages self, mock_create_bot_client, mock_messages
): ):
mock_DiscordClient.return_value.remove_guild_member.return_value = True # given
mock_create_bot_client.return_value.remove_guild_member.return_value = True
request = self.factory.get(reverse('discord:deactivate')) request = self.factory.get(reverse('discord:deactivate'))
request.user = self.user request.user = self.user
# when
response = deactivate_discord(request) response = deactivate_discord(request)
# then
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, self.services_url) self.assertEqual(response.url, self.services_url)
self.assertTrue(mock_messages.success.called) self.assertTrue(mock_messages.success.called)
self.assertFalse(mock_messages.error.called) self.assertFalse(mock_messages.error.called)
def test_when_unsuccessful_show_error_message( def test_when_unsuccessful_show_error_message(
self, mock_DiscordClient, mock_messages self, mock_create_bot_client, mock_messages
): ):
mock_DiscordClient.return_value.remove_guild_member.return_value = False # given
mock_create_bot_client.return_value.remove_guild_member.return_value = False
request = self.factory.get(reverse('discord:deactivate')) request = self.factory.get(reverse('discord:deactivate'))
request.user = self.user request.user = self.user
# when
response = deactivate_discord(request) response = deactivate_discord(request)
# then
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, self.services_url) self.assertEqual(response.url, self.services_url)
self.assertFalse(mock_messages.success.called) self.assertFalse(mock_messages.success.called)
@@ -79,30 +85,36 @@ class TestDeactivateDiscord(SetupClassMixin, TestCase):
@patch(MODULE_PATH + '.views.messages') @patch(MODULE_PATH + '.views.messages')
@patch(MODULE_PATH + '.managers.DiscordClient') @patch(MODULE_PATH + '.models.create_bot_client')
class TestResetDiscord(SetupClassMixin, TestCase): class TestResetDiscord(SetupClassMixin, NoSocketsTestCase):
def setUp(self): def setUp(self):
DiscordUser.objects.create(user=self.user, uid=TEST_USER_ID) DiscordUser.objects.create(user=self.user, uid=TEST_USER_ID)
def test_when_successful_redirect_to_activate( def test_when_successful_redirect_to_activate(
self, mock_DiscordClient, mock_messages self, mock_create_bot_client, mock_messages
): ):
mock_DiscordClient.return_value.remove_guild_member.return_value = True # given
mock_create_bot_client.return_value.remove_guild_member.return_value = True
request = self.factory.get(reverse('discord:reset')) request = self.factory.get(reverse('discord:reset'))
request.user = self.user request.user = self.user
# when
response = reset_discord(request) response = reset_discord(request)
# then
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse("discord:activate")) self.assertEqual(response.url, reverse("discord:activate"))
self.assertFalse(mock_messages.error.called) self.assertFalse(mock_messages.error.called)
def test_when_unsuccessful_message_error_and_redirect_to_service( def test_when_unsuccessful_message_error_and_redirect_to_service(
self, mock_DiscordClient, mock_messages self, mock_create_bot_client, mock_messages
): ):
mock_DiscordClient.return_value.remove_guild_member.return_value = False # given
mock_create_bot_client.return_value.remove_guild_member.return_value = False
request = self.factory.get(reverse('discord:reset')) request = self.factory.get(reverse('discord:reset'))
request.user = self.user request.user = self.user
# when
response = reset_discord(request) response = reset_discord(request)
# then
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, self.services_url) self.assertEqual(response.url, self.services_url)
self.assertTrue(mock_messages.error.called) self.assertTrue(mock_messages.error.called)
@@ -110,7 +122,7 @@ class TestResetDiscord(SetupClassMixin, TestCase):
@patch(MODULE_PATH + '.views.messages') @patch(MODULE_PATH + '.views.messages')
@patch(MODULE_PATH + '.views.DiscordUser.objects.add_user') @patch(MODULE_PATH + '.views.DiscordUser.objects.add_user')
class TestDiscordCallback(SetupClassMixin, TestCase): class TestDiscordCallback(SetupClassMixin, NoSocketsTestCase):
def setUp(self): def setUp(self):
DiscordUser.objects.create(user=self.user, uid=TEST_USER_ID) DiscordUser.objects.create(user=self.user, uid=TEST_USER_ID)
@@ -155,7 +167,7 @@ class TestDiscordCallback(SetupClassMixin, TestCase):
@patch(MODULE_PATH + '.views.DiscordUser.objects.generate_bot_add_url') @patch(MODULE_PATH + '.views.DiscordUser.objects.generate_bot_add_url')
class TestDiscordAddBot(TestCase): class TestDiscordAddBot(NoSocketsTestCase):
def test_add_bot(self, mock_generate_bot_add_url): def test_add_bot(self, mock_generate_bot_add_url):
bot_url = 'https://www.example.com/bot' bot_url = 'https://www.example.com/bot'

View File

@@ -4,17 +4,17 @@
<td class="text-center"><a href="mumble://{{ service_url }}">{{ service_url }}</a></td> <td class="text-center"><a href="mumble://{{ service_url }}">{{ service_url }}</a></td>
<td class="text-center"> <td class="text-center">
{% if username == "" %} {% if username == "" %}
<a href="{% url urls.auth_activate %}" title="Activate" class="btn btn-warning"> <a href="{% url 'mumble:activate' %}" title="Activate" class="btn btn-warning">
<span class="glyphicon glyphicon-ok"></span> <span class="glyphicon glyphicon-ok"></span>
</a> </a>
{% else %} {% else %}
<a href="{% url urls.auth_set_password %}" title="Set Password" class="btn btn-warning"> <a href="{% url 'mumble:set_password' %}" title="Set Password" class="btn btn-warning">
<span class="glyphicon glyphicon-pencil"></span> <span class="glyphicon glyphicon-pencil"></span>
</a> </a>
<a href="{% url urls.auth_reset_password %}" title="Reset Password" class="btn btn-primary"> <a href="{% url 'mumble:reset_password' %}" title="Reset Password" class="btn btn-primary">
<span class="glyphicon glyphicon-refresh"></span> <span class="glyphicon glyphicon-refresh"></span>
</a> </a>
<a href="{% url urls.auth_deactivate %}" title="Deactivate" class="btn btn-danger"> <a href="{% url 'mumble:deactivate' %}" title="Deactivate" class="btn btn-danger">
<span class="glyphicon glyphicon-remove"></span> <span class="glyphicon glyphicon-remove"></span>
</a> </a>
<a href="mumble://{{ connect_url }}" class="btn btn-success" title="Connect"> <a href="mumble://{{ connect_url }}" class="btn btn-success" title="Connect">

View File

@@ -10,7 +10,7 @@
<span class="glyphicon glyphicon-ok"></span> <span class="glyphicon glyphicon-ok"></span>
</a> </a>
{% else %} {% else %}
<a href="{% url 'teamspeak3:verify' %}" title="Verify Client ID" class="btn btn-success" title="Verify"> <a href="{% url 'teamspeak3:verify' %}" title="Verify Client ID" class="btn btn-success">
<span class="glyphicon glyphicon-log-in"></span> <span class="glyphicon glyphicon-log-in"></span>
</a> </a>
<a href="{% url 'teamspeak3:reset_perm' %}" title="Refresh Token" class="btn btn-primary"> <a href="{% url 'teamspeak3:reset_perm' %}" title="Refresh Token" class="btn btn-primary">

View File

@@ -12,7 +12,7 @@
{% for key, value in credentials.items %} {% for key, value in credentials.items %}
<div class="form-group"> <div class="form-group">
<label class="control-label" for="id_{{ key }}">{{ key|capfirst }}</label> <label class="control-label" for="id_{{ key }}">{{ key|capfirst }}</label>
<input class="form-control" value="{{ value }}" readonly> <input class="form-control" value="{{ value }}" id="id_{{ key }}" readonly>
</div> </div>
{% endfor %} {% endfor %}
</form> </form>

View File

@@ -1,14 +1,13 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load bootstrap %}
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% load humanize %} {% load humanize %}
{% block page_title %}Srp Fleet Data{% endblock page_title %} {% block page_title %}{% translate "Srp Fleet Data" %}{% endblock page_title %}
{% block extra_css %} {% block extra_css %}
{% include 'bundles/datatables-css.html' %} {% include 'bundles/datatables-css.html' %}
{% include 'bundles/x-editable.css.html' %} {% include 'bundles/x-editable.css.html' %}
<link href="{% static 'allianceauth/css/checkbox.css' %}" rel="stylesheet" type="text/css"> <link href="{% static 'allianceauth/css/checkbox.css' %}" rel="stylesheet">
<style> <style>
.copy-text-fa-icon:hover { .copy-text-fa-icon:hover {
cursor: pointer; cursor: pointer;
@@ -96,7 +95,7 @@
<th class="text-center">{% translate "SRP ISK Cost" %} <th class="text-center">{% translate "SRP ISK Cost" %}
<i class="glyphicon glyphicon-question-sign" rel="tooltip" title="{% blocktrans trimmed %}Click value to edit <i class="glyphicon glyphicon-question-sign" rel="tooltip" title="{% blocktrans trimmed %}Click value to edit
Enter to save & next Enter to save & next
ESC to cancel{% endblocktrans %}"id="blah"></i></th> ESC to cancel{% endblocktrans %}" id="blah"></i></th>
<th class="text-center">{% translate "Post Time" %}</th> <th class="text-center">{% translate "Post Time" %}</th>
<th class="text-center">{% translate "Status" %}</th> <th class="text-center">{% translate "Status" %}</th>
{% if perms.auth.srp_management %} {% if perms.auth.srp_management %}
@@ -113,7 +112,6 @@ ESC to cancel{% endblocktrans %}"id="blah"></i></th>
{% endif %} {% endif %}
[{{ srpfleetrequest.character.corporation.corporation_ticker }}] [{{ srpfleetrequest.character.corporation.corporation_ticker }}]
{{ srpfleetrequest.character.character_name }}&nbsp;<i class="copy-text-fa-icon far fa-copy" data-clipboard-text="{{ srpfleetrequest.character.character_name }}"></i> {{ srpfleetrequest.character.character_name }}&nbsp;<i class="copy-text-fa-icon far fa-copy" data-clipboard-text="{{ srpfleetrequest.character.character_name }}"></i>
</span>
</td> </td>
<td class="text-center"> <td class="text-center">
<a href="{{ srpfleetrequest.killboard_link }}" <a href="{{ srpfleetrequest.killboard_link }}"
@@ -189,7 +187,7 @@ ESC to cancel{% endblocktrans %}"id="blah"></i></th>
{% include 'bundles/clipboard-js.html' %} {% include 'bundles/clipboard-js.html' %}
<script> <script>
var clipboard = new ClipboardJS('.copy-text-fa-icon'); const clipboard = new ClipboardJS('.copy-text-fa-icon');
clipboard.on('success', function (e) { clipboard.on('success', function (e) {
console.info('Action:', e.action); console.info('Action:', e.action);
console.info('Text:', e.text); console.info('Text:', e.text);

View File

@@ -1,5 +1,4 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load bootstrap %}
{% load i18n %} {% load i18n %}
{% load humanize %} {% load humanize %}
@@ -42,7 +41,7 @@
<th class="text-center">{% translate "Fleet ISK Cost" %}</th> <th class="text-center">{% translate "Fleet ISK Cost" %}</th>
<th class="text-center">{% translate "SRP Status" %}</th> <th class="text-center">{% translate "SRP Status" %}</th>
<th class="text-center">{% translate "Pending Requests" %}</th> <th class="text-center">{% translate "Pending Requests" %}</th>
<th width="100px" class="text-center">{% translate "Actions" %}</th> <th class="text-center" style="width: 100px;">{% translate "Actions" %}</th>
</tr> </tr>
{% for srpfleet in srpfleets %} {% for srpfleet in srpfleets %}
<tr> <tr>

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017 Erik Kalkoken
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,239 @@
/*
* filterDropDown.js
*
* Copyright (C) 2017-18 Erik Kalkoken
*
* Extension for the jQuery plug-in DataTables (developed and tested with v1.10.15)
*
* Version 0.4.0
*
**/
(function ($) {
// parse initialization array and returns filterDef array to faster and easy use
// also sets defaults for properties that are not set
function parseInitArray(initArray) {
// initialization and setting defaults
let filterDef = {
"columns": [],
"columnsIdxList": [],
"bootstrap": false,
"autoSize": true,
"ajax": null,
"label": "Filter "
};
// set filter properties if they have been defined
// otherwise the defaults will be used
if (("bootstrap" in initArray) && (typeof initArray.bootstrap === 'boolean')) {
filterDef.bootstrap = initArray.bootstrap;
}
if (("autoSize" in initArray) && (typeof initArray.autoSize === 'boolean')) {
filterDef.autoSize = initArray.autoSize;
}
if (("ajax" in initArray) && (typeof initArray.ajax === 'string')) {
filterDef.ajax = initArray.ajax;
}
if (("label" in initArray) && (typeof initArray.label === 'string')) {
filterDef.label = initArray.label;
}
// add definition for each column
if ("columns" in initArray) {
initArray.columns.forEach(function (initColumn) {
if (("idx" in initColumn) && (typeof initColumn.idx === 'number')) {
// initialize column
let idx = initColumn.idx;
filterDef['columns'][idx] = {
"title": null,
"maxWidth": null,
"autoSize": true
};
// add to list of indices in same order they appear in the init array
filterDef['columnsIdxList'].push(idx);
// set column properties if they have been defined
// otherwise the defaults will be used
if (('title' in initColumn)
&& (typeof initColumn.title === 'string')
) {
filterDef['columns'][idx].title = initColumn.title;
}
if (('maxWidth' in initColumn)
&& (typeof initColumn.maxWidth === 'string')
) {
filterDef['columns'][idx].maxWidth = initColumn.maxWidth;
}
if (('autoSize' in initColumn)
&& (typeof initColumn.autoSize === 'boolean')
) {
filterDef['columns'][idx].autoSize = initColumn.autoSize;
}
}
});
}
return filterDef;
}
// Add option d to given select object
function addOption(select, d) {
if (d != "") {
select.append('<option value="' + d + '">' + d + '</option>');
}
}
// initalizing select for current column and applying event to react to changes
function initSelectForColumn(id, column) {
let select = $("#" + id + "_filterSelect" + column.index());
select.on('change', function () {
let val = $.fn.dataTable.util.escapeRegex($(this).val());
column
.search(val ? '^' + val + '$' : '', true, false)
.draw();
});
return select
}
// Add filterDropDown container div, draw select elements with default options
// use preInit so that elements are created and correctly shown before data is loaded
$(document).on('preInit.dt', function (e, settings) {
if (e.namespace !== 'dt') return;
// get api object for current dt table
var api = new $.fn.dataTable.Api(settings);
// get id of current table
var id = api.table().node().id;
// get initialization object for current table to retrieve custom settings
var initObj = api.init();
// only proceed if filter has been defined in current table, otherwise don't do anything.
if (!("filterDropDown" in initObj)) return;
// get current filter definition from init array
var filterDef = parseInitArray(initObj.filterDropDown);
// only proceed if there are any columns defined
if (filterDef.columns.length == 0) return;
// get container div for current data table to add new elements to
var container = api.table().container();
// add filter elements to DOM
var filterWrapperId = id + "_filterWrapper";
var divCssClass = filterWrapperId + " " + (
(filterDef.bootstrap)
? "form-inline"
: ""
);
$(container).prepend(
'<div id="'
+ filterWrapperId
+ '" class="'
+ divCssClass + '">'
+ filterDef.label
+ '</div>'
);
api.columns(filterDef.columnsIdxList).every(function () {
let idx = this.index();
// set title of current column
let colName = (filterDef.columns[idx].title !== null)
? filterDef.columns[idx].title
: $(this.header()).html();
if (colName == "") colName = 'column ' + (idx + 1);
// adding select element for current column to container
let selectId = id + "_filterSelect" + idx;
$('#' + filterWrapperId).append(
'<select id="'
+ selectId
+ '" class="form-control '
+ id
+ '_filterSelect"></select>'
);
// initalizing select for current column and applying event to react to changes
let select = $("#" + selectId).empty()
.append('<option value="">(' + colName + ')</option>');
// set max width of select elements to current width (which is defined by the size of the title)
// turn off on for very small screens for responsive design or if autoSize has been set to false
if (filterDef.autoSize && filterDef.columns[idx].autoSize && (screen.width > 768)) {
select.css('max-width', select.outerWidth());
}
// apply optional css style if defined in init array
// will override automatic max width setting
if (filterDef.columns[idx].maxWidth !== null) {
select.css('max-width', filterDef.columns[idx].maxWidth);
}
});
});
// filter table and add available options to dropDowns
$(document).on('init.dt', function (e, settings) {
if (e.namespace !== 'dt') return;
// get api object for current dt table
var api = new $.fn.dataTable.Api(settings);
// get id of current table
var id = api.table().node().id;
// get initialization object for current table to retrieve custom settings
var initObj = api.init();
// only proceed if filter has been defined in current table, otherwise don't do anything.
if (!("filterDropDown" in initObj)) return;
// get current filter definition
var filterDef = parseInitArray(initObj.filterDropDown);
if (filterDef.ajax == null) {
api.columns(filterDef.columnsIdxList).every(function () {
let column = this
let select = initSelectForColumn(id, column);
column.data().unique().sort().each(function (d) {
addOption(select, d)
});
});
} else {
// fetch column options from server for server side processing
let columnsQuery = (
"columns="
+ encodeURIComponent(
api.columns(filterDef.columnsIdxList).dataSrc().join()
)
)
$.getJSON(filterDef.ajax + "?" + columnsQuery, function (columnsOptions) {
api.columns(filterDef.columnsIdxList).every(function () {
let column = this;
let select = initSelectForColumn(id, column);
let columnName = column.dataSrc()
if (columnName in columnsOptions) {
columnsOptions[columnName].forEach(function (d) {
addOption(select, d)
});
} else {
console.warn(
"Missing column '" + columnName + "' in ajax response."
)
}
});
});
}
});
}(jQuery));

View File

@@ -1 +1 @@
!function(t){function e(t){var e={columns:[],columnsIdxList:[],bootstrap:!1,autoSize:!0,label:"Filter "};if("bootstrap"in t&&"boolean"==typeof t.bootstrap&&(e.bootstrap=t.bootstrap),"autoSize"in t&&"boolean"==typeof t.autoSize&&(e.autoSize=t.autoSize),"label"in t&&"string"==typeof t.label&&(e.label=t.label),"columns"in t)for(var i=0;i<t.columns.length;i++){var n=t.columns[i];if("idx"in n&&"number"==typeof n.idx){var o=n.idx;e.columns[o]={title:null,maxWidth:null,autoSize:!0},e.columnsIdxList.push(o),"title"in n&&"string"==typeof n.title&&(e.columns[o].title=n.title),"maxWidth"in n&&"string"==typeof n.maxWidth&&(e.columns[o].maxWidth=n.maxWidth),"autoSize"in n&&"boolean"==typeof n.autoSize&&(e.columns[o].autoSize=n.autoSize)}}return e}t(document).on("preInit.dt",function(i,n){if("dt"===i.namespace){var o=new t.fn.dataTable.Api(n),a=o.table().node().id,l=o.init();if("filterDropDown"in l){var r=e(l.filterDropDown);if(0!=r.columns.length){var u=o.table().container(),s=a+"_filterWrapper",c=s+" "+(r.bootstrap?"form-inline":"");t(u).prepend('<div id="'+s+'" class="'+c+'">'+r.label+"</div>"),o.columns(r.columnsIdxList).every(function(){var e=this.index(),i=null!==r.columns[e].title?r.columns[e].title:t(this.header()).html();""==i&&(i="column "+(e+1));var n="form-control "+a+"_filterSelect",o=a+"_filterSelect"+e;t("#"+s).append('<select id="'+o+'" class="'+n+'"></select>');var l=t("#"+o).empty().append('<option value="">('+i+")</option>");r.autoSize&&r.columns[e].autoSize&&screen.width>768&&l.css("max-width",l.outerWidth()),null!==r.columns[e].maxWidth&&l.css("max-width",r.columns[e].maxWidth)})}}}}),t(document).on("init.dt",function(i,n){if("dt"===i.namespace){var o=new t.fn.dataTable.Api(n),a=o.table().node().id,l=o.init();if("filterDropDown"in l){var r=e(l.filterDropDown);o.table().container();o.columns(r.columnsIdxList).every(function(){var e=this,i=e.index(),n=t("#"+(a+"_filterSelect"+i));n.on("change",function(){var i=t.fn.dataTable.util.escapeRegex(t(this).val());e.search(i?"^"+i+"$":"",!0,!1).draw()}),e.data().unique().sort().each(function(t,e){""!=t&&n.append('<option value="'+t+'">'+t+"</option>")})})}}})}(jQuery); !function(t){function n(t){let n={columns:[],columnsIdxList:[],bootstrap:!1,autoSize:!0,ajax:null,label:"Filter "};return"bootstrap"in t&&"boolean"==typeof t.bootstrap&&(n.bootstrap=t.bootstrap),"autoSize"in t&&"boolean"==typeof t.autoSize&&(n.autoSize=t.autoSize),"ajax"in t&&"string"==typeof t.ajax&&(n.ajax=t.ajax),"label"in t&&"string"==typeof t.label&&(n.label=t.label),"columns"in t&&t.columns.forEach(function(t){if("idx"in t&&"number"==typeof t.idx){let e=t.idx;n.columns[e]={title:null,maxWidth:null,autoSize:!0},n.columnsIdxList.push(e),"title"in t&&"string"==typeof t.title&&(n.columns[e].title=t.title),"maxWidth"in t&&"string"==typeof t.maxWidth&&(n.columns[e].maxWidth=t.maxWidth),"autoSize"in t&&"boolean"==typeof t.autoSize&&(n.columns[e].autoSize=t.autoSize)}}),n}function e(t,n){""!=n&&t.append('<option value="'+n+'">'+n+"</option>")}function i(n,e){let i=t("#"+n+"_filterSelect"+e.index());return i.on("change",function(){let n=t.fn.dataTable.util.escapeRegex(t(this).val());e.search(n?"^"+n+"$":"",!0,!1).draw()}),i}t(document).on("preInit.dt",function(e,i){if("dt"===e.namespace){var o=new t.fn.dataTable.Api(i),l=o.table().node().id,a=o.init();if("filterDropDown"in a){var u=n(a.filterDropDown);if(0!=u.columns.length){var s=o.table().container(),c=l+"_filterWrapper",r=c+" "+(u.bootstrap?"form-inline":"");t(s).prepend('<div id="'+c+'" class="'+r+'">'+u.label+"</div>"),o.columns(u.columnsIdxList).every(function(){let n=this.index(),e=null!==u.columns[n].title?u.columns[n].title:t(this.header()).html();""==e&&(e="column "+(n+1));let i=l+"_filterSelect"+n;t("#"+c).append('<select id="'+i+'" class="form-control '+l+'_filterSelect"></select>');let o=t("#"+i).empty().append('<option value="">('+e+")</option>");u.autoSize&&u.columns[n].autoSize&&screen.width>768&&o.css("max-width",o.outerWidth()),null!==u.columns[n].maxWidth&&o.css("max-width",u.columns[n].maxWidth)})}}}}),t(document).on("init.dt",function(o,l){if("dt"===o.namespace){var a=new t.fn.dataTable.Api(l),u=a.table().node().id,s=a.init();if("filterDropDown"in s){var c=n(s.filterDropDown);if(null==c.ajax)a.columns(c.columnsIdxList).every(function(){let t=i(u,this);this.data().unique().sort().each(function(n){e(t,n)})});else{let n="columns="+encodeURIComponent(a.columns(c.columnsIdxList).dataSrc().join());t.getJSON(c.ajax+"?"+n,function(t){a.columns(c.columnsIdxList).every(function(){let n=i(u,this),o=this.dataSrc();o in t?t[o].forEach(function(t){e(n,t)}):console.warn("Missing column '"+o+"' in ajax response.")})})}}}})}(jQuery);

View File

@@ -52,7 +52,7 @@
<li class="list-group-item list-group-item-{% if latest_patch %}success{% elif latest_minor %}warning{% else %}danger{% endif %}"> <li class="list-group-item list-group-item-{% if latest_patch %}success{% elif latest_minor %}warning{% else %}danger{% endif %}">
<h5 class="list-group-item-heading">{% translate "Latest Stable" %}</h5> <h5 class="list-group-item-heading">{% translate "Latest Stable" %}</h5>
<p class="list-group-item-text"> <p class="list-group-item-text">
<a href="https://gitlab.com/allianceauth/allianceauth/-/tags/v{{ latest_patch_version }}" style="color:#000"> <a href="https://gitlab.com/allianceauth/allianceauth/-/tags/v{{ latest_patch_version }}" style="color:#000;">
<i class="fab fa-gitlab hidden-xs" aria-hidden="true"></i> <i class="fab fa-gitlab hidden-xs" aria-hidden="true"></i>
{{ latest_patch_version }} {{ latest_patch_version }}
</a> </a>
@@ -63,7 +63,7 @@
<li class="list-group-item list-group-item-info"> <li class="list-group-item list-group-item-info">
<h5 class="list-group-item-heading">{% translate "Latest Pre-Release" %}</h5> <h5 class="list-group-item-heading">{% translate "Latest Pre-Release" %}</h5>
<p class="list-group-item-text"> <p class="list-group-item-text">
<a href="https://gitlab.com/allianceauth/allianceauth/-/tags/v{{ latest_beta_version }}" style="color:#000"> <a href="https://gitlab.com/allianceauth/allianceauth/-/tags/v{{ latest_beta_version }}" style="color:#000;">
<i class="fab fa-gitlab hidden-xs" aria-hidden="true"></i> <i class="fab fa-gitlab hidden-xs" aria-hidden="true"></i>
{{ latest_beta_version }} {{ latest_beta_version }}
</a> </a>

View File

@@ -17,7 +17,7 @@
{% include 'bundles/bootstrap-css.html' %} {% include 'bundles/bootstrap-css.html' %}
{% include 'bundles/fontawesome.html' %} {% include 'bundles/fontawesome.html' %}
<link href="{% static 'allianceauth/css/auth-base.css' %}" type="text/css" rel="stylesheet"> <link href="{% static 'allianceauth/css/auth-base.css' %}" rel="stylesheet">
{% block extra_css %}{% endblock extra_css %} {% block extra_css %}{% endblock extra_css %}
</head> </head>
@@ -42,15 +42,15 @@
{% include 'bundles/bootstrap-js.html' %} {% include 'bundles/bootstrap-js.html' %}
{% include 'bundles/jquery-visibility-js.html' %} {% include 'bundles/jquery-visibility-js.html' %}
<script type="application/javascript"> <script>
let notificationUPdateSettings = { let notificationUPdateSettings = {
notificationsListViewUrl: "{% url 'notifications:list' %}", notificationsListViewUrl: "{% url 'notifications:list' %}",
notificationsRefreshTime: "{% notifications_refresh_time %}", notificationsRefreshTime: "{% notifications_refresh_time %}",
userNotificationsCountViewUrl: "{% url 'notifications:user_notifications_count' request.user.pk %}" userNotificationsCountViewUrl: "{% url 'notifications:user_notifications_count' request.user.pk %}"
}; };
</script> </script>
<script src="{% static 'allianceauth/js/refresh_notifications.js' %}"></script> {% include 'bundles/refresh-notifications-js.html' %}
<script src="{% static 'allianceauth/js/eve-time.js' %}"></script> {% include 'bundles/evetime-js.html' %}
{% block extra_javascript %} {% block extra_javascript %}
{% endblock extra_javascript %} {% endblock extra_javascript %}

View File

@@ -12,7 +12,7 @@
</button> </button>
<a class="navbar-brand"> <a class="navbar-brand">
<img src="{% static 'allianceauth/icons/favicon-32x32.png' %}" style="display: inline-block;" height="32" width="32"/> <img src="{% static 'allianceauth/icons/favicon-32x32.png' %}" style="display: inline-block;" height="32" width="32" alt="{{ SITE_NAME }}"/>
{{ SITE_NAME }} {{ SITE_NAME }}
</a> </a>
</div> </div>

View File

@@ -3,17 +3,17 @@
{% if NIGHT_MODE %} {% if NIGHT_MODE %}
{% if debug %} {% if debug %}
<!-- In template debug, loading less file instead of CSS --> <!-- In template debug, loading less file instead of CSS -->
<link rel="stylesheet/less" type="text/css" href="{% static 'allianceauth/css/themes/darkly/darkly.less' %}"> <link rel="stylesheet/less" href="{% static 'allianceauth/css/themes/darkly/darkly.less' %}">
<script src="https://cdnjs.cloudflare.com/ajax/libs/less.js/4.1.2/less.min.js" integrity="sha512-eXBn7AaMbUOWb3PSDhwcjByoM89FeO1SF9Jww6kqPYQkBrGZvqAKFbtqLHh5O95rYA/AOtWZ0QRO2S6rP+KsUw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script> {% else %} <script src="https://cdnjs.cloudflare.com/ajax/libs/less.js/4.1.2/less.min.js" integrity="sha512-eXBn7AaMbUOWb3PSDhwcjByoM89FeO1SF9Jww6kqPYQkBrGZvqAKFbtqLHh5O95rYA/AOtWZ0QRO2S6rP+KsUw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script> {% else %}
<link rel="stylesheet" type="text/css" href="{% static 'allianceauth/css/themes/darkly/darkly.min.css' %}"> <link rel="stylesheet" href="{% static 'allianceauth/css/themes/darkly/darkly.min.css' %}">
{% endif %} {% endif %}
{% else %} {% else %}
{% if debug %} {% if debug %}
<!-- In template debug, loading less file instead of CSS --> <!-- In template debug, loading less file instead of CSS -->
<link rel="stylesheet/less" type="text/css" href="{% static 'allianceauth/css/themes/flatly/flatly.less' %}"> <link rel="stylesheet/less" href="{% static 'allianceauth/css/themes/flatly/flatly.less' %}">
<script src="https://cdnjs.cloudflare.com/ajax/libs/less.js/4.1.2/less.min.js" integrity="sha512-eXBn7AaMbUOWb3PSDhwcjByoM89FeO1SF9Jww6kqPYQkBrGZvqAKFbtqLHh5O95rYA/AOtWZ0QRO2S6rP+KsUw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/less.js/4.1.2/less.min.js" integrity="sha512-eXBn7AaMbUOWb3PSDhwcjByoM89FeO1SF9Jww6kqPYQkBrGZvqAKFbtqLHh5O95rYA/AOtWZ0QRO2S6rP+KsUw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
{% else %} {% else %}
<link rel="stylesheet" type="text/css" href="{% static 'allianceauth/css/themes/flatly/flatly.min.css' %}"> <link rel="stylesheet" href="{% static 'allianceauth/css/themes/flatly/flatly.min.css' %}">
{% endif %} {% endif %}
{% endif %} {% endif %}
<!-- End Bootstrap CSS --> <!-- End Bootstrap CSS -->

View File

@@ -1,3 +1,3 @@
<!-- Start Clipboard.js js from cdnjs --> <!-- Start Clipboard.js js from cdnjs -->
<script type="application/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/2.0.8/clipboard.min.js" integrity="sha512-sIqUEnRn31BgngPmHt2JenzleDDsXwYO+iyvQ46Mw6RL+udAUZj2n/u/PGY80NxRxynO7R9xIGx5LEzw4INWJQ==" crossorigin="anonymous"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/2.0.8/clipboard.min.js" integrity="sha512-sIqUEnRn31BgngPmHt2JenzleDDsXwYO+iyvQ46Mw6RL+udAUZj2n/u/PGY80NxRxynO7R9xIGx5LEzw4INWJQ==" crossorigin="anonymous"></script>
<!-- End Clipboard.js js from cdnjs --> <!-- End Clipboard.js js from cdnjs -->

View File

@@ -0,0 +1,3 @@
{% load static %}
<script src="{% static 'allianceauth/js/eve-time.js' %}"></script>

View File

@@ -0,0 +1,3 @@
{% load static %}
<script src="{% static 'allianceauth/js/filterDropDown/filterDropDown.min.js' %}"></script>

View File

@@ -2,6 +2,6 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/moment.min.js" integrity="sha512-qTXRIMyZIFb8iQcfjXWCO8+M5Tbc38Qi5WzdPOYZHIlZpzBHG3L3by84BBBOiRGiEb7KKtAOAs5qYdUiZiQNNQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/moment.min.js" integrity="sha512-qTXRIMyZIFb8iQcfjXWCO8+M5Tbc38Qi5WzdPOYZHIlZpzBHG3L3by84BBBOiRGiEb7KKtAOAs5qYdUiZiQNNQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
{% if locale and LANGUAGE_CODE != 'en' %} {% if locale and LANGUAGE_CODE != 'en' %}
<!-- Moment.JS Not EN-en --> <!-- Moment.JS Not EN-en -->
<script type="application/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/locale/{{ LANGUAGE_CODE }}.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/locale/{{ LANGUAGE_CODE }}.js"></script>
{% endif %} {% endif %}
<!-- End Moment JS from cdnjs --> <!-- End Moment JS from cdnjs -->

View File

@@ -0,0 +1,3 @@
{% load static %}
<script src="{% static 'allianceauth/js/refresh_notifications.js' %}"></script>

View File

@@ -0,0 +1,3 @@
{% load static %}
<script src="{% static 'allianceauth/js/timers.js' %}"></script>

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.0.6 on 2022-07-18 17:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('timerboard', '0004_timer_type'),
]
operations = [
migrations.AlterField(
model_name='timer',
name='planet_moon',
field=models.CharField(blank=True, default='', max_length=254),
),
]

View File

@@ -26,7 +26,7 @@ class Timer(models.Model):
details = models.CharField(max_length=254, default="") details = models.CharField(max_length=254, default="")
system = models.CharField(max_length=254, default="") system = models.CharField(max_length=254, default="")
planet_moon = models.CharField(max_length=254, default="") planet_moon = models.CharField(max_length=254, blank=True, default="")
structure = models.CharField(max_length=254, default="") structure = models.CharField(max_length=254, default="")
timer_type = models.CharField( timer_type = models.CharField(
max_length=254, max_length=254,

View File

@@ -539,8 +539,8 @@
</div> </div>
{% include 'bundles/moment-js.html' with locale=True %} {% include 'bundles/moment-js.html' with locale=True %}
<script src="{% static 'allianceauth/js/timers.js' %}"></script> {% include 'bundles/timers-js.html' %}
<script type="application/javascript"> <script>
let timers = [ let timers = [
{% for timer in timers %} {% for timer in timers %}
{ {
@@ -577,7 +577,7 @@
let updateAllTimers = function () { let updateAllTimers = function () {
let l = timers.length; let l = timers.length;
for (var i=0; i < l; ++i) { for (let i=0; i < l; ++i) {
if (timers[i].expired) continue; if (timers[i].expired) continue;
updateTimer(timers[i]); updateTimer(timers[i]);
@@ -603,7 +603,7 @@
let setAllLocalTimes = function () { let setAllLocalTimes = function () {
let l = timers.length; let l = timers.length;
for (var i=0; i < l; ++i) { for (let i=0; i < l; ++i) {
setLocalTime(timers[i]); setLocalTime(timers[i]);
} }
}; };

View File

@@ -0,0 +1,35 @@
import socket
from django.test import TestCase
class SocketAccessError(Exception):
"""Error raised when a test script accesses the network"""
class NoSocketsTestCase(TestCase):
"""Variation of Django's TestCase class that prevents any network use.
Example:
.. code-block:: python
class TestMyStuff(NoSocketsTestCase):
def test_should_do_what_i_need(self):
...
"""
@classmethod
def setUpClass(cls):
cls.socket_original = socket.socket
socket.socket = cls.guard
return super().setUpClass()
@classmethod
def tearDownClass(cls):
socket.socket = cls.socket_original
return super().tearDownClass()
@staticmethod
def guard(*args, **kwargs):
raise SocketAccessError("Attempted to access network")

View File

@@ -0,0 +1,9 @@
import requests
from allianceauth.utils.testing import NoSocketsTestCase, SocketAccessError
class TestNoSocketsTestCase(NoSocketsTestCase):
def test_raises_exception_on_attempted_network_access(self):
with self.assertRaises(SocketAccessError):
requests.get("https://www.google.com")

View File

@@ -1,7 +1,7 @@
PROTOCOL=https:// PROTOCOL=https://
AUTH_SUBDOMAIN=%AUTH_SUBDOMAIN% AUTH_SUBDOMAIN=%AUTH_SUBDOMAIN%
DOMAIN=%DOMAIN% DOMAIN=%DOMAIN%
AA_DOCKER_TAG=registry.gitlab.com/allianceauth/allianceauth/auth:v3.0.0b2 AA_DOCKER_TAG=registry.gitlab.com/allianceauth/allianceauth/auth:v3.0.0
# Nginx Proxy Manager # Nginx Proxy Manager
PROXY_HTTP_PORT=80 PROXY_HTTP_PORT=80

View File

@@ -1,5 +1,5 @@
FROM python:3.9-slim FROM python:3.9-slim
ARG AUTH_VERSION=v3.0.0b2 ARG AUTH_VERSION=v3.0.0
ARG AUTH_PACKAGE=allianceauth==${AUTH_VERSION} ARG AUTH_PACKAGE=allianceauth==${AUTH_VERSION}
ENV VIRTUAL_ENV=/opt/venv ENV VIRTUAL_ENV=/opt/venv
ENV AUTH_USER=allianceauth ENV AUTH_USER=allianceauth

View File

@@ -63,7 +63,7 @@ master_doc = 'index'
# General information about the project. # General information about the project.
project = 'Alliance Auth' project = 'Alliance Auth'
copyright = '2018-2022, Alliance Auth' copyright = '2018-2022, Alliance Auth'
author = 'R4stl1n' author = 'Alliance Auth Team'
# The version info for the project you're documenting, acts as replacement for # The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the # |version| and |release|, also used in various other places throughout the
@@ -174,6 +174,7 @@ texinfo_documents = [
'Miscellaneous'), 'Miscellaneous'),
] ]
def setup(app): def setup(app):
app.add_config_value('recommonmark_config', { app.add_config_value('recommonmark_config', {
'auto_toc_tree_section': 'Contents', 'auto_toc_tree_section': 'Contents',

View File

@@ -0,0 +1,36 @@
======================
Discord Client
======================
AA contains a web client for interacting with the Discord API. This client can be used independently from an installed Discord service in AA.
Location: ``allianceauth.services.modules.discord.discord_client``
.. contents:: :local:
client
======
.. automodule:: allianceauth.services.modules.discord.discord_client.client
:members:
models
======
.. automodule:: allianceauth.services.modules.discord.discord_client.models
:members:
:undoc-members:
:member-order: bysource
exceptions
==========
.. automodule:: allianceauth.services.modules.discord.discord_client.exceptions
:members:
settings
========
.. automodule:: allianceauth.services.modules.discord.discord_client.app_settings
:members:

View File

@@ -0,0 +1,27 @@
======================
Discord Service
======================
This page contains the technical documentation for the Discord service.
Location: ``allianceauth.services.modules.discord``
.. contents:: :local:
api
======
.. automodule:: allianceauth.services.modules.discord.api
:members:
:exclude-members: DiscordUser
.. autoclass:: DiscordUser
:no-members: delete_user
settings
========
.. automodule:: allianceauth.services.modules.discord.app_settings
:members:

View File

@@ -6,6 +6,8 @@ To reduce redundancy and help speed up development we encourage developers to ut
.. toctree:: .. toctree::
:maxdepth: 1 :maxdepth: 1
discord_client
discord_service
esi esi
evelinks evelinks
eveonline eveonline

View File

@@ -6,9 +6,22 @@ Utilities and helper functions.
Location: ``allianceauth.utils`` Location: ``allianceauth.utils``
.. contents:: :local:
cache cache
=========== ===========
.. automodule:: allianceauth.utils.cache .. automodule:: allianceauth.utils.cache
:members: :members:
:undoc-members: :undoc-members:
testing
===========
.. automodule:: allianceauth.utils.testing
:members:
:exclude-members: NoSocketsTestCase
.. autoclass:: NoSocketsTestCase
:no-members:

View File

@@ -7,11 +7,6 @@ Mumble is a free voice chat server. While not as flashy as TeamSpeak, it has all
Note that this guide assumes that you have installed Auth with the official :doc:`/installation/allianceauth` guide under ``/home/allianceserver`` and that it is called ``myauth``. Accordingly it assumes that you have a service user called ``allianceserver`` that is used to run all Auth services under supervisor. Note that this guide assumes that you have installed Auth with the official :doc:`/installation/allianceauth` guide under ``/home/allianceserver`` and that it is called ``myauth``. Accordingly it assumes that you have a service user called ``allianceserver`` that is used to run all Auth services under supervisor.
``` ```
```eval_rst
.. note::
Same as the official installation guide this guide is assuming you are performing all steps as ``root`` user.
```
```eval_rst ```eval_rst
.. warning:: .. warning::
This guide is currently for Ubuntu only. This guide is currently for Ubuntu only.
@@ -24,17 +19,17 @@ Mumble is a free voice chat server. While not as flashy as TeamSpeak, it has all
The mumble server package can be retrieved from a repository, which we need to add: The mumble server package can be retrieved from a repository, which we need to add:
```bash ```bash
apt-add-repository ppa:mumble/release sudo apt-add-repository ppa:mumble/release
``` ```
```bash ```bash
apt-get update sudo apt-get update
``` ```
Now three packages need to be installed: Now three packages need to be installed:
```bash ```bash
apt-get install python-software-properties mumble-server libqt5sql5-mysql sudo apt-get install python-software-properties mumble-server libqt5sql5-mysql
``` ```
### Installing Mumble Authenticator ### Installing Mumble Authenticator
@@ -51,7 +46,7 @@ We will now install the authenticator into your Auth virtual environment. Please
source /home/allianceserver/venv/auth/bin/activate source /home/allianceserver/venv/auth/bin/activate
``` ```
Install the python dependencies for the mumble authenticator. Note that this process can take a couple minutes to complete. Install the python dependencies for the mumble authenticator. Note that this process can take 2-10 minutes to complete.
```bash ```bash
pip install -r requirements.txt pip install -r requirements.txt
@@ -72,7 +67,7 @@ GRANT ALL PRIVILEGES ON alliance_mumble . * TO 'allianceserver'@'localhost';
Mumble ships with a configuration file that needs customization. By default its located at `/etc/mumble-server.ini`. Open it with your favorite text editor: Mumble ships with a configuration file that needs customization. By default its located at `/etc/mumble-server.ini`. Open it with your favorite text editor:
```bash ```bash
nano /etc/mumble-server.ini sudo nano /etc/mumble-server.ini
``` ```
We need to enable the ICE authenticator. Edit the following: We need to enable the ICE authenticator. Edit the following:
@@ -96,7 +91,7 @@ Save and close the file.
To get Mumble superuser account credentials, run the following: To get Mumble superuser account credentials, run the following:
```bash ```bash
dpkg-reconfigure mumble-server sudo dpkg-reconfigure mumble-server
``` ```
Set the password to something youll remember and write it down. This is your superuser password and later needed to manage ACLs. Set the password to something youll remember and write it down. This is your superuser password and later needed to manage ACLs.
@@ -104,7 +99,7 @@ Set the password to something youll remember and write it down. This is your
Now restart the server to see the changes reflected. Now restart the server to see the changes reflected.
```bash ```bash
service mumble-server restart sudo service mumble-server restart
``` ```
Thats it! Your server is ready to be connected to at example.com:64738 Thats it! Your server is ready to be connected to at example.com:64738
@@ -136,7 +131,7 @@ python /home/allianceserver/mumble-authenticator/authenticator.py
And finally ensure the allianceserver user has read/write permissions to the mumble authenticator files before proceeding: And finally ensure the allianceserver user has read/write permissions to the mumble authenticator files before proceeding:
```bash ```bash
chown -R allianceserver:allianceserver /home/allianceserver/mumble-authenticator sudo chown -R allianceserver:allianceserver /home/allianceserver/mumble-authenticator
``` ```
The authenticator needs to be running 24/7 to validate users on Mumble. This can be achieved by adding a section to your auth project's supervisor config file like the following example: The authenticator needs to be running 24/7 to validate users on Mumble. This can be achieved by adding a section to your auth project's supervisor config file like the following example:
@@ -165,8 +160,8 @@ priority=999
To enable the changes in your supervisor configuration you need to restart the supervisor process itself. And before we do that we are shutting down the current Auth supervisors gracefully: To enable the changes in your supervisor configuration you need to restart the supervisor process itself. And before we do that we are shutting down the current Auth supervisors gracefully:
```bash ```bash
supervisor stop myauth: sudo supervisor stop myauth:
systemctl restart supervisor sudo systemctl restart supervisor
``` ```
## Configuring Auth ## Configuring Auth
@@ -255,8 +250,8 @@ There is no way to force your users to update their clients or use Push to Talk,
<https://wiki.mumble.info/wiki/Murmur.ini#Miscellany> <https://wiki.mumble.info/wiki/Murmur.ini#Miscellany>
We suggest using Mumble 1.3.0+ for your server and Clients, you can tune this to the latest Patch version. We suggest using Mumble 1.4.0+ for your server and Clients, you can tune this to the latest Patch version.
`suggestVersion=1.3.0` `suggestVersion=1.4.230`
If Push to Talk is to your tastes, configure the suggestion as follows If Push to Talk is to your tastes, configure the suggestion as follows
`suggestPushToTalk=true` `suggestPushToTalk=true`
@@ -268,7 +263,7 @@ If Push to Talk is to your tastes, configure the suggestion as follows
With the default configuration your mumble server is public. Meaning that everyone who has the address can at least connect to it and might also be able join all channels that don't have any permissions set (Depending on your ACL configured for the root channel). If you want only registered member being able to join your mumble, you have to set a server password. To do so open your mumble server configuration which is by default located at `/etc/mumble-server.ini`. With the default configuration your mumble server is public. Meaning that everyone who has the address can at least connect to it and might also be able join all channels that don't have any permissions set (Depending on your ACL configured for the root channel). If you want only registered member being able to join your mumble, you have to set a server password. To do so open your mumble server configuration which is by default located at `/etc/mumble-server.ini`.
```bash ```bash
nano /etc/mumble-server.ini sudo nano /etc/mumble-server.ini
``` ```
Now search for `serverpassword=` and set your password here. If there is no such line, simply add it. Now search for `serverpassword=` and set your password here. If there is no such line, simply add it.
@@ -280,7 +275,7 @@ serverpassword=YourSuperSecretServerPassword
Save the file and restart your mumble server afterwards. Save the file and restart your mumble server afterwards.
```bash ```bash
service mumble-server restart sudo service mumble-server restart
``` ```
From now on, only registered member can join your mumble server. Now if you still want to allow guests to join you have 2 options. From now on, only registered member can join your mumble server. Now if you still want to allow guests to join you have 2 options.

View File

@@ -19,20 +19,25 @@ BROADCAST_USER_PASSWORD = ""
BROADCAST_SERVICE_NAME = "broadcast" BROADCAST_SERVICE_NAME = "broadcast"
``` ```
## Dependencies ## OS Dependencies
Openfire require a Java 8 runtime environment. Openfire require a Java 8 runtime environment.
Ubuntu: Ubuntu 1804, 2004, 2204:
```bash ```bash
apt-get install openjdk-8-jdk sudo apt-get install openjdk-11-jre
``` ```
CentOS: Centos 7:
```bash ```bash
yum -y install java-1.8.0-openjdk java-1.8.0-openjdk-devel sudo yum install java-11-openjdk java-11-openjdk-devel
```
Centos Stream 8, Stream 9:
```bash
sudo dnf install java-11-openjdk java-11-openjdk-devel
``` ```
## Setup ## Setup
@@ -45,22 +50,26 @@ On your PC, navigate to the [Ignite Realtime downloads section](https://www.igni
Retrieve the file location by copying the URL from the “click here” link, depending on your browser you may have a Copy Link or similar option in your right click menu. Retrieve the file location by copying the URL from the “click here” link, depending on your browser you may have a Copy Link or similar option in your right click menu.
In the console, ensure youre in your users home directory: `cd ~` In the console, ensure youre in your users home directory:
```bash
cd ~
```
Now download the package. Replace the link below with the link you got earlier. Download and install the package, replacing the URL with the latest you got from the Openfire download page earlier
`wget https://www.igniterealtime.org/downloadServlet?filename=openfire/openfire_4.2.3_all.deb` Ubuntu 1804, 2004, 2204:
Now install from the package. Replace the filename with your filename (the last part of the download URL is the file name) ```bash
wget https://www.igniterealtime.org/downloadServlet?filename=openfire/openfire_4.7.2_all.deb
dpkg -i openfire_4.7.2_all.deb
```
Ubuntu: Centos 7, Stream 8, Stream 9:
`dpkg -i openfire_4.2.3_all.deb`
CentOS:
`yum install -y openfire-4.2.3-1.noarch.rpm`
```bash
wget https://www.igniterealtime.org/downloadServlet?filename=openfire/openfire-4.7.2-1.noarch.rpm
yum install -y openfire-4.7.2-1.noarch.rpm
```
### Create Database ### Create Database
Performance is best when working from a SQL database. If you installed MySQL or MariaDB alongside your auth project, go ahead and create a database for Openfire: Performance is best when working from a SQL database. If you installed MySQL or MariaDB alongside your auth project, go ahead and create a database for Openfire:

View File

@@ -52,13 +52,13 @@ In the console, navigate to your users home directory: `cd ~`
Now download using wget, replacing the URL with the URL for the package you just retrieved Now download using wget, replacing the URL with the URL for the package you just retrieved
```bash ```bash
wget https://www.phpbb.com/files/release/phpBB-3.2.2.zip wget https://download.phpbb.com/pub/release/3.3/3.3.8/phpBB-3.3.8.zip
``` ```
This needs to be unpackaged. Unzip it, replacing the file name with that of the file you just downloaded This needs to be unpackaged. Unzip it, replacing the file name with that of the file you just downloaded
```bash ```bash
unzip phpBB-3.2.2.zip unzip phpBB-3.3.8.zip
``` ```
Now we need to move this to our web directory. Usually `/var/www/forums`. Now we need to move this to our web directory. Usually `/var/www/forums`.

View File

@@ -36,17 +36,17 @@ Using your browser, you can download the latest version of SMF to your desktop c
Download using wget, replacing the URL with the URL for the package you just retrieved Download using wget, replacing the URL with the URL for the package you just retrieved
```shell ```bash
wget https://download.simplemachines.org/index.php?thanks;filename=smf_2-0-15_install.zip wget https://download.simplemachines.org/index.php?thanks;filename=smf_2-1-2_install.tar.gz
``` ```
This needs to be unpackaged. Unzip it, replacing the file name with that of the file you just downloaded This needs to be unpackaged. Unzip it, replacing the file name with that of the file you just downloaded
```shell ```bash
unzip smf_2-0-15_install.zip unzip smf_2-1-2_install.zip
```` ```
Now we need to move this to our web directory. Usually `/var/www/forums`. Now we need to move this to our web directory. Usually `/var/www/forums`.
```shell ```bash
mv smf /var/www/forums mv smf /var/www/forums
```` ````

View File

@@ -34,18 +34,19 @@ CELERYBEAT_SCHEDULE['run_ts3_group_update'] = {
### Download Installer ### Download Installer
To install we need a copy of the server. You can find the latest version from [this dl server](http://dl.4players.de/ts/releases/) (Id recommend getting the latest stable version find this version number from the [TeamSpeak site](https://www.teamspeak.com/downloads#)). Be sure to get a link to the Linux version. To install we need a copy of the server. You can find the latest version from the [TeamSpeak site](https://www.teamspeak.com/downloads#)). Be sure to get a link to the Linux version.
Download the server, replacing the link with the link you got earlier. Download the server, replacing the link with the link you got earlier.
```text ``` bash
http://dl.4players.de/ts/releases/3.13.2/teamspeak3-server_linux_amd64-3.13.2.tar.bz2 cd ~
wget https://files.teamspeak-services.com/releases/server/3.13.7/teamspeak3-server_linux_amd64-3.13.7.tar.bz2
``` ```
Now we need to extract the file. Now we need to extract the file.
```bash ```bash
tar -xf teamspeak3-server_linux_amd64-3.1.0.tar.bz2 tar -xf teamspeak3-server_linux_amd64-3.13.7.tar.bz2
``` ```
### Create User ### Create User
@@ -82,14 +83,19 @@ service teamspeak start
### Update Settings ### Update Settings
The console will spit out a block of text. If it does not appear, it can be found with `service teamspeak status`. **SAVE THIS**. Set your Teamspeak Serveradmin password to a random string
```bash
./ts3server_minimal_runscript.sh inifile=ts3server.ini serveradmin_password=pleasegeneratearandomstring
```
If you plan on claiming the ServerAdmin token, do so with a different TeamSpeak client profile than the one used for your auth account, or you will lose your admin status. If you plan on claiming the ServerAdmin token, do so with a different TeamSpeak client profile than the one used for your auth account, or you will lose your admin status.
Edit the settings you added to your auth project's settings file earlier, entering the following: Edit the settings you added to your auth project's settings file earlier, entering the following:
- `TEAMSPEAK3_SERVERQUERY_USER` is `loginname` from that block of text it just spat out (usually `serveradmin`) - `TEAMSPEAK3_SERVERQUERY_USER` is `loginname` from the above bash command (usually `serveradmin`)
- `TEAMSPEAK3_SERVERQUERY_PASSWORD` is `password` from that block of text it just spat out - `TEAMSPEAK3_SERVERQUERY_PASSWORD` is `password` following the equals in `serveradmin_password=`
- `TEAMSPEAK_VIRTUAL_SERVER` is the virtual server ID of the server to be managed - it will only ever not be 1 if your server is hosted by a professional company - `TEAMSPEAK_VIRTUAL_SERVER` is the virtual server ID of the server to be managed - it will only ever not be 1 if your server is hosted by a professional company
- `TEAMSPEAK3_PUBLIC_URL` is the public address of your TeamSpeak server. Do not include any leading http:// or teamspeak:// - `TEAMSPEAK3_PUBLIC_URL` is the public address of your TeamSpeak server. Do not include any leading http:// or teamspeak://

View File

@@ -2,11 +2,6 @@
This document describes how to install **Alliance Auth** from scratch. This document describes how to install **Alliance Auth** from scratch.
```eval_rst
.. tip::
If you are uncomfortable with Linux permissions follow the steps below as the root user.
```
```eval_rst ```eval_rst
.. note:: .. note::
There are additional installation steps for activating services and apps that come with **Alliance Auth**. Please see the page for the respective service or apps in chapter :doc:`/features/index` for details. There are additional installation steps for activating services and apps that come with **Alliance Auth**. Please see the page for the respective service or apps in chapter :doc:`/features/index` for details.
@@ -14,34 +9,106 @@ This document describes how to install **Alliance Auth** from scratch.
## Dependencies ## Dependencies
### Operating System ### Operating Systems
Alliance Auth can be installed on any Unix like operating system. Dependencies are provided below for two of the most popular Linux platforms: Ubuntu and CentOS. To install on your favorite flavour of Linux, identify and install equivalent packages to the ones listed here. Alliance Auth can be installed on any in-support *nix operating system.
Our install documentation targets the following operating systems.
- Ubuntu 18.04
- Ubuntu 20.04
- Ubuntu 22.04
- Centos 7
- CentOS Stream 8
- CentOS Stream 9
To install on your favorite flavour of Linux, identify and install equivalent packages to the ones listed here.
### OS Maintenance
It is reccommended to ensure your OS is fully up to date before proceeding. We may also add Package Repositories here, used later in the documentation.
Ubuntu 1804, 2004, 2204:
```bash
sudo apt-get update
```
```bash
sudo apt-get upgrade
```
```bash
sudo do-dist-upgrade
```
CentOS 7
```bash
yum install epel-release
```
```bash
sudo yum upgrade
```
CentOS Stream 8
```bash
sudo dnf config-manager --set-enabled powertools
```
```bash
sudo dnf install epel-release epel-next-release
```
```bash
sudo yum upgrade
```
CentOS Stream 9
```bash
sudo dnf config-manager --set-enabled crb
```
```bash
dnf install epel-release epel-next-release
```
```bash
sudo yum upgrade
```
### Python ### Python
Alliance Auth requires Python 3.7 or higher. Ensure it is installed on your server before proceeding. Alliance Auth requires Python 3.8 or higher. Ensure it is installed on your server before proceeding.
Ubuntu 1604 1804:
Ubuntu 1804, 2004:
```eval_rst ```eval_rst
.. note:: .. note::
Ubuntu 2004 ships with Python 3.8, No updates required. Ubuntu 2204 ships with Python 3.10 already
``` ```
```bash ```bash
add-apt-repository ppa:deadsnakes/ppa sudo add-apt-repository ppa:deadsnakes/ppa
``` ```
```bash ```bash
apt-get update sudo apt-get update
``` ```
```bash ```bash
apt-get install python3.7 python3.7-dev python3.7-venv sudo apt-get install python3.10 python3.10-dev python3.10-venv
``` ```
CentOS 7/8: CentOS 7:
We need to build Python from source
Centos Stream 8/9:
```eval_rst
.. note::
A Python 3.9 Package is available for Stream 8 and 9. You _may_ use this instead of building your own package. But our documentation will assume Python3.10 and you may need to substitute as neccessary
sudo dnf install python39 python39-devel
```
```bash ```bash
cd ~ cd ~
@@ -52,15 +119,15 @@ sudo yum install gcc openssl-devel bzip2-devel libffi-devel wget
``` ```
```bash ```bash
wget https://www.python.org/ftp/python/3.7.11/Python-3.7.11.tgz wget https://www.python.org/ftp/python/3.10.5/Python-3.10.5.tgz
``` ```
```bash ```bash
tar xvf Python-3.7.11.tgz tar xvf Python-3.10.5.tgz
``` ```
```bash ```bash
cd Python-3.7.11/ cd Python-3.10.5/
``` ```
```bash ```bash
@@ -68,64 +135,111 @@ cd Python-3.7.11/
``` ```
```bash ```bash
make altinstall sudo make altinstall
``` ```
### Database ### Database
It's recommended to use a database service instead of SQLite. Many options are available, but this guide will use MariaDB. It's recommended to use a database service instead of SQLite. Many options are available, but this guide will use MariaDB.
```eval_rst ```eval_rst
.. warning:: .. note::
Many Ubuntu distributions come with an older version of Maria DB, which is not compatible with **Alliance Auth**. You need Maria DB 10.3 or higher! Many Ubuntu distributions come with an older version of Maria DB, which is not compatible with **Alliance Auth**. You need Maria DB 10.3 or higher!
For instructions on how To install a newer version of Maria DB on Ubuntu visit this page: `MariaDB Repositories <https://downloads.mariadb.org/mariadb/repositories/#distro=Ubuntu&mirror=osuosl>`_.
``` ```
Ubuntu: Ubuntu 1804, 2004, 2204:
```eval_rst
.. warning::
Please follow these steps to update MariaDB
https://mariadb.org/download/?t=repo-config&d=20.04+%22focal%22&v=10.6&r_m=osuosl
```
Ubuntu 1804, 2004, 2204
```bash ```bash
apt-get install mariadb-server mariadb-client libmysqlclient-dev apt-get install mariadb-server mariadb-client libmysqlclient-dev
``` ```
CentOS: CentOS 7
```eval_rst
.. warning::
Please follow these steps to update MariaDB
https://mariadb.org/download/?t=repo-config&d=CentOS+7+%28x86_64%29&v=10.6&r_m=osuosl
```
```bash ```bash
yum install mariadb-server mariadb-devel mariadb-shared mariadb sudo yum install MariaDB-server MariaDB-client MariaDB-devel MariaDB-shared
``` ```
CentOS Stream 8/9
```eval_rst ```eval_rst
.. note:: .. note::
If you don't plan on running the database on the same server as auth you still need to install the libmysqlclient-dev package on Ubuntu or mariadb-devel package on CentOS. We reccomend using the built in AppStream, as they are maintained by CentOS. Currently an AppStream is not available for 10.6
```
```bash
sudo dnf module enable mariadb:10.5
```
```bash
sudo dnf install mariadb mariadb-server mariadb-devel
```
```bash
sudo systemctl enable mariadb
```
```bash
sudo systemctl start mariadb
```
```eval_rst
.. important::
If you don't plan on running the database on the same server as auth you still need to install the ``libmysqlclient-dev`` package on Ubuntu or ``mariadb-devel`` package on CentOS.
``` ```
### Redis and Other Tools ### Redis and Other Tools
A few extra utilities are also required for installation of packages. A few extra utilities are also required for installation of packages.
Ubuntu: Ubuntu 1804, 2004, 2204:
```bash
sudo apt-get install unzip git redis-server curl libssl-dev libbz2-dev libffi-dev build-essential
```
CentOS 7:
```bash
sudo yum install gcc gcc-c++ unzip git redis curl bzip2-devel openssl-devel libffi-devel wget
```
```bash ```bash
apt-get install unzip git redis-server curl libssl-dev libbz2-dev libffi-dev sudo systemctl enable redis.service
``` ```
CentOS:
```bash ```bash
yum install gcc gcc-c++ unzip git redis curl bzip2-devel sudo systemctl start redis.service
``` ```
```eval_rst CentOS Stream 8, Stream 9:
.. important:: ```bash
CentOS: Make sure Redis is running before continuing. :: sudo dnf install gcc gcc-c++ unzip git redis curl bzip2-devel openssl-devel libffi-devel wget
systemctl enable redis.service
systemctl start redis.service
``` ```
```bash
sudo systemctl enable redis.service
```
```bash
sudo systemctl start redis.service
```
## Database Setup ## Database Setup
Alliance Auth needs a MySQL user account and database. Open an SQL shell with `mysql -u root -p` and create them as follows, replacing `PASSWORD` with an actual secure password: Alliance Auth needs a MySQL user account and database. Open an SQL shell with
```bash
sudo mysql -u root
```
and create them as follows, replacing `PASSWORD` with an actual secure password:
```sql ```sql
CREATE USER 'allianceserver'@'localhost' IDENTIFIED BY 'PASSWORD'; CREATE USER 'allianceserver'@'localhost' IDENTIFIED BY 'PASSWORD';
@@ -138,7 +252,7 @@ Once your database is set up, you can leave the SQL shell with `exit`.
Add timezone tables to your mysql installation: Add timezone tables to your mysql installation:
```bash ```bash
mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root -p mysql mysql_tzinfo_to_sql /usr/share/zoneinfo | sudo mysql -u root mysql
``` ```
```eval_rst ```eval_rst
@@ -162,29 +276,47 @@ mysql_secure_installation
For security and permissions, its highly recommended you create a separate user to install auth under. Do not log in as this account. For security and permissions, its highly recommended you create a separate user to install auth under. Do not log in as this account.
Ubuntu: Ubuntu 1804, 2004, 2204:
```bash ```bash
adduser --disabled-login allianceserver adduser --disabled-login allianceserver
``` ```
CentOS: CentOS 7, Stream 8, Stream 9:
```bash
sudo useradd -s /bin/bash allianceserver
```
```bash ```bash
useradd -s /bin/nologin allianceserver sudo passwd -l allianceserver
```
### Prepare Directories
```bash
sudo mkdir -p /var/www/myauth/static
```
```bash
sudo chown -R allianceserver:allianceserver /var/www/myauth/static/
```
```eval_rst
.. note::
When installing and performing maintenance on Alliance Auth, using the allianceserver user will greatly simplify permission management::
sudo su allianceserver
``` ```
### Virtual Environment ### Virtual Environment
Create a Python virtual environment and put it somewhere convenient (e.g. `/home/allianceserver/venv/auth/`) Create a Python virtual environment and put it somewhere convenient (e.g. `/home/allianceserver/venv/auth/`)
```bash ```eval_rst
python3 -m venv /home/allianceserver/venv/auth/ .. note::
Your python3.x command/version may vary depending on your installed python version.
``` ```
```eval_rst ```bash
.. warning:: python3.10 -m venv /home/allianceserver/venv/auth/
The python3 command may not be available on all installations. Try a specific version such as ``python3.7`` if this is the case.
``` ```
```eval_rst ```eval_rst
@@ -216,6 +348,12 @@ In `local.py` you will need to set `ESI_USER_CONTACT_EMAIL` to an email address
### Alliance Auth Project ### Alliance Auth Project
Update Pip before installing python packages:
```bash
pip install -U pip setuptools
```
Ensure wheel is available before continuing: Ensure wheel is available before continuing:
```bash ```bash
@@ -246,7 +384,7 @@ The following command bootstraps a Django project which will run your **Alliance
allianceauth start myauth allianceauth start myauth
``` ```
The settings file needs configuring. Edit the template at `myauth/myauth/settings/local.py`. Be sure to configure the EVE SSO and Email settings. The settings file needs configuring. Edit the template at `myauth/myauth/settings/local.py`. Be sure to configure the EVE SSO as defined earlier in **Eve Online Settings** and valid Email settings.
Django needs to install models to the database before it can start. Django needs to install models to the database before it can start.
@@ -257,7 +395,6 @@ python /home/allianceserver/myauth/manage.py migrate
Now we need to round up all the static files required to render templates. Make a directory to serve them from and populate it. Now we need to round up all the static files required to render templates. Make a directory to serve them from and populate it.
```bash ```bash
mkdir -p /var/www/myauth/static
python /home/allianceserver/myauth/manage.py collectstatic python /home/allianceserver/myauth/manage.py collectstatic
``` ```
@@ -267,10 +404,12 @@ Check to ensure your settings are valid.
python /home/allianceserver/myauth/manage.py check python /home/allianceserver/myauth/manage.py check
``` ```
Finally, ensure the allianceserver user has read/write permissions to this directory before proceeding.
```bash ```eval_rst
chown -R allianceserver:allianceserver /home/allianceserver/myauth .. hint::
If you are using root, ensure the allianceserver user has read/write permissions to this directory before proceeding::
chown -R allianceserver:allianceserver /home/allianceserver/myauth
``` ```
## Services ## Services
@@ -283,32 +422,62 @@ To run the **Alliance Auth** website a [WSGI Server](https://www.fullstackpython
The default configuration is good enough for most installations. Additional information is available in the [gunicorn](gunicorn.md) doc. The default configuration is good enough for most installations. Additional information is available in the [gunicorn](gunicorn.md) doc.
## Superuser
Before using your auth site, it is essential to create a superuser account. This account will have all permissions in Alliance Auth. It's OK to use this as your personal auth account.
```bash
python /home/allianceserver/myauth/manage.py createsuperuser
```
Once your install is complete, the superuser account is accessed by logging in via the admin site at `https://example.com/admin`.
If you intend to use this account as your personal auth account you need to add a main character. Navigate to the normal user dashboard (at `https://example.com`) after logging in via the admin site and select `Change Main`. Once a main character has been added, it is possible to use SSO to login to this account.
### Supervisor ### Supervisor
[Supervisor](http://supervisord.org/) is a process watchdog service: it makes sure other processes are started automatically and kept running. It can be used to automatically start the WSGI server and Celery workers for background tasks. Installation varies by OS: [Supervisor](http://supervisord.org/) is a process watchdog service: it makes sure other processes are started automatically and kept running. It can be used to automatically start the WSGI server and Celery workers for background tasks.
```eval_rst ```eval_rst
.. note:: .. note::
Many package managers will install Supervisor 3 by default, which requires Python 2. You will need to exit the allianceserver user back to a user with sudo capabilities to install supervisor::
exit
``` ```
Ubuntu: Ubuntu 1804, 2004, 2204:
```bash ```bash
apt-get install supervisor sudo apt-get install supervisor
``` ```
CentOS: CentOS 7:
```bash ```bash
yum install supervisor sudo dnf install supervisor
systemctl enable supervisord.service ```
systemctl start supervisord.service ```bash
sudo systemctl enable supervisord.service
```
```bash
sudo systemctl start supervisord.service
```
CentOS Stream 8, Stream 9:
```bash
sudo dnf install supervisor
```
```bash
sudo systemctl enable supervisord.service
```
```bash
sudo systemctl start supervisord.service
``` ```
Once installed, it needs a configuration file to know which processes to watch. Your Alliance Auth project comes with a ready-to-use template which will ensure the Celery workers, Celery task scheduler and Gunicorn are all running. Once installed, it needs a configuration file to know which processes to watch. Your Alliance Auth project comes with a ready-to-use template which will ensure the Celery workers, Celery task scheduler and Gunicorn are all running.
Ubuntu: Ubuntu 1804, 2004:
```bash ```bash
ln -s /home/allianceserver/myauth/supervisor.conf /etc/supervisor/conf.d/myauth.conf ln -s /home/allianceserver/myauth/supervisor.conf /etc/supervisor/conf.d/myauth.conf
@@ -337,23 +506,23 @@ Once installed, decide on whether you're going to use [NGINX](nginx.md) or [Apac
Note that Alliance Auth is designed to run with web servers on HTTPS. While running on HTTP is technically possible, it is not recommended for production use, and some functions (e.g. Email confirmation links) will not work properly. Note that Alliance Auth is designed to run with web servers on HTTPS. While running on HTTP is technically possible, it is not recommended for production use, and some functions (e.g. Email confirmation links) will not work properly.
## Superuser
Before using your auth site, it is essential to create a superuser account. This account will have all permissions in Alliance Auth. It's OK to use this as your personal auth account.
```bash
python /home/allianceserver/myauth/manage.py createsuperuser
```
The superuser account is accessed by logging in via the admin site at `https://example.com/admin`.
If you intend to use this account as your personal auth account you need to add a main character. Navigate to the normal user dashboard (at `https://example.com`) after logging in via the admin site and select `Change Main`. Once a main character has been added, it is possible to use SSO to login to this account.
## Updating ## Updating
Periodically [new releases](https://gitlab.com/allianceauth/allianceauth/tags) are issued with bug fixes and new features. Be sure to read the [release notes](https://gitlab.com/allianceauth/allianceauth/-/releases) which will highlight changes. Periodically [new releases](https://gitlab.com/allianceauth/allianceauth/tags) are issued with bug fixes and new features. Be sure to read the [release notes](https://gitlab.com/allianceauth/allianceauth/-/releases) which will highlight changes.
To update your install, simply activate your virtual environment and update with: To update your install, swap to your allianceserver user
```bash
sudo su allianceserver
```
Activate your virtual environment
```bash
source /home/allianceserver/venv/auth/bin/activate
```
and update with:
```bash ```bash
pip install --upgrade allianceauth pip install --upgrade allianceauth

View File

@@ -8,19 +8,39 @@ If you're using a small VPS to host services with very limited memory, consider
## Installation ## Installation
Ubuntu: Ubuntu 1804, 2004:
```bash
apt-get install apache2
```
apt-get install apache2 CentOS 7:
```bash
yum install httpd
```
Centos Stream 8, Stream 9
```bash
dnf install httpd
```
CentOS: CentOS 7, Stream 8, Stream 9
```bash
yum install httpd systemctl enable httpd
systemctl enable httpd ```
systemctl start httpd
```bash
systemctl start httpd
```
## Configuration ## Configuration
Apache needs to be able to read the folder containing your auth project's static files. On Ubuntu: `chown -R www-data:www-data /var/www/myauth/static`, and on CentOS: `chown -R apache:apache /var/www/myauth/static` Apache needs to be able to read the folder containing your auth project's static files.
Ubuntu 1804, 2004:
```
chown -R www-data:www-data /var/www/myauth/static
```
CentOS 7, Stream 8, Stream 9
```
chown -R apache:apache /var/www/myauth/static
```
Apache serves sites through defined virtual hosts. These are located in `/etc/apache2/sites-available/` on Ubuntu and `/etc/httpd/conf.d/httpd.conf` on CentOS. Apache serves sites through defined virtual hosts. These are located in `/etc/apache2/sites-available/` on Ubuntu and `/etc/httpd/conf.d/httpd.conf` on CentOS.
@@ -29,13 +49,20 @@ A virtual host for auth need only proxy requests to your WSGI server (Gunicorn i
### Ubuntu ### Ubuntu
To proxy and modify headers a few mods need to be enabled. To proxy and modify headers a few mods need to be enabled.
```bash
a2enmod proxy a2enmod proxy
a2enmod proxy_http a2enmod proxy_http
a2enmod headers a2enmod headers
```
Create a new config file for auth e.g. `/etc/apache2/sites-available/myauth.conf` and fill out the virtual host configuration. To enable your config use `a2ensite myauth.conf` and then reload apache with `service apache2 reload`. Create a new config file for auth e.g. `/etc/apache2/sites-available/myauth.conf` and fill out the virtual host configuration. To enable your config use `a2ensite myauth.conf` and then reload apache with `service apache2 reload`.
```eval_rst
.. warning::
In some scenarios, the Apache default page is still enabled. To disable it use::
a2dissite 000-default.conf
```
### CentOS ### CentOS
Place your virtual host configuration in the appropriate section within `/etc/httpd/conf.d/httpd.conf` and restart the httpd service with `systemctl restart httpd`. Place your virtual host configuration in the appropriate section within `/etc/httpd/conf.d/httpd.conf` and restart the httpd service with `systemctl restart httpd`.
@@ -77,3 +104,19 @@ After acquiring SSL the config file needs to be adjusted. Add the following line
RequestHeader set X-FORWARDED-PROTOCOL https RequestHeader set X-FORWARDED-PROTOCOL https
RequestHeader set X-FORWARDED-SSL On RequestHeader set X-FORWARDED-SSL On
``` ```
### Known Issues
#### Apache2 vs. Django
For some versions of Apache2 you might have to tell the Django framework explicitly
to use SSL, since the automatic detection doesn't work. SSL in general will work,
but internally created URLs by Django might still be prefixed with just `http://`
instead of `https://`, so it can't hurt to add these lines to
`myauth/myauth/settings/local.py`.
```python
# Setup support for proxy headers
USE_X_FORWARDED_HOST = True
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTOCOL", "https")
```

View File

@@ -15,10 +15,16 @@ Check out the full [Gunicorn docs](http://docs.gunicorn.org/en/latest/index.html
```eval_rst ```eval_rst
.. note:: .. note::
If you're using a virtual environment, activate it now. ``source /path/to/venv/bin/activate``. If you're using a virtual environment, activate it now::
sudo su allianceserver
source /home/allianceserver/venv/auth/bin/activate
``` ```
Install Gunicorn using pip, `pip install gunicorn`. Install Gunicorn using pip
```bash
pip install gunicorn
```
In your `myauth` base directory, try running `gunicorn --bind 0.0.0.0:8000 myauth.wsgi`. You should be able to browse to `http://yourserver:8000` and see your Alliance Auth installation running. Images and styling will be missing, but don't worry, your web server will provide them. In your `myauth` base directory, try running `gunicorn --bind 0.0.0.0:8000 myauth.wsgi`. You should be able to browse to `http://yourserver:8000` and see your Alliance Auth installation running. Images and styling will be missing, but don't worry, your web server will provide them.
@@ -26,7 +32,7 @@ Once you validate its running, you can kill the process with Ctrl+C and continue
## Running Gunicorn with Supervisor ## Running Gunicorn with Supervisor
You should use [Supervisor](allianceauth.md#supervisor) to keep all of Alliance Auth components running (instead of using screen). You don't _have to_ but we will be using it to start and run Gunicorn so you might as well. If you are following this guide, we already use [Supervisor](allianceauth.md#supervisor) to keep all of Alliance Auth components running. You don't _have to_ but we will be using it to start and run Gunicorn for consistency.
### Sample Supervisor config ### Sample Supervisor config
@@ -43,7 +49,6 @@ autostart=true
autorestart=true autorestart=true
stopsignal=INT stopsignal=INT
``` ```
- `[program:gunicorn]` - Change `gunicorn` to whatever you wish to call your process in Supervisor. - `[program:gunicorn]` - Change `gunicorn` to whatever you wish to call your process in Supervisor.
- `user = allianceserver` - Change to whatever user you wish Gunicorn to run as. You could even set this as allianceserver if you wished. I'll leave the question security of that up to you. - `user = allianceserver` - Change to whatever user you wish Gunicorn to run as. You could even set this as allianceserver if you wished. I'll leave the question security of that up to you.
- `directory=/home/allianceserver/myauth/` - Needs to be the path to your Alliance Auth project. - `directory=/home/allianceserver/myauth/` - Needs to be the path to your Alliance Auth project.
@@ -71,11 +76,14 @@ Change it by adding `--workers=5` to the command.
##### Running with a virtual environment ##### Running with a virtual environment
If you're running with a virtual environment, you'll need to add the path to the `command=` config line. Following this guide, you are running with a virtual environment. Therefore you'll need to add the path to the `command=` config line.
e.g. `command=/path/to/venv/bin/gunicorn myauth.wsgi` e.g. `command=/path/to/venv/bin/gunicorn myauth.wsgi`
The example config is using the myauth venv from the main installation guide: `command=/home/allianceserver/venv/auth/bin/gunicorn myauth.wsgi` The example config is using the myauth venv from the main installation guide:
```ini
command=/home/allianceserver/venv/auth/bin/gunicorn myauth.wsgi
```
### Starting via Supervisor ### Starting via Supervisor
@@ -89,4 +97,6 @@ Any web server capable of proxy passing should be able to sit in front of Gunico
In the past when you made changes you restarted the entire Apache server. This is no longer required. When you update or make configuration changes that ask you to restart Apache, instead you can just restart Gunicorn: In the past when you made changes you restarted the entire Apache server. This is no longer required. When you update or make configuration changes that ask you to restart Apache, instead you can just restart Gunicorn:
`supervisorctl restart gunicorn`, or the service name you chose for it. ```bash
supervisorctl restart myauth:gunicorn
```

Some files were not shown because too many files have changed in this diff Show More