Compare commits

..

49 Commits

Author SHA1 Message Date
Ariel Rin
1e9f5e6430 Version Bump 3.0.0a1 2022-02-26 06:26:36 +00:00
Ariel Rin
ceaa064e62 Merge branch 'usersettings' into 'v3.x'
Persistent User Settings

See merge request allianceauth/allianceauth!1333
2022-02-26 06:19:38 +00:00
Ariel Rin
1aad3e4512 Persistent User Settings 2022-02-26 06:19:38 +00:00
Ariel Rin
f83c3c2811 Merge branch 'ErikKalkoken/allianceauth-fix_character_names' into v3.x 2022-02-26 15:53:46 +10:00
Ariel Rin
a23ec6d318 switch new task module to new redis method 2022-02-26 15:52:15 +10:00
Ariel Rin
ecc53888bc Merge branch 'master' of https://gitlab.com/allianceauth/allianceauth into v3.x 2022-02-26 15:23:01 +10:00
Ariel Rin
e54f72091f specific known working dependency commit 2022-02-26 14:27:00 +10:00
Ariel Rin
75b5b28804 use Django-ESI 4.0.0a1, instead of Ariels branch 2022-02-20 23:24:26 +10:00
Ariel Rin
f81a2ed237 Merge branch 'django4' into 'v3.x'
dropin replace sleeksmpp with slixmpp, alter test

See merge request allianceauth/allianceauth!1399
2022-02-20 13:19:19 +00:00
Ariel Rin
49e01157e7 dropin replace sleeksmpp with slixmpp, alter test 2022-02-20 13:19:19 +00:00
Ariel Rin
28420a729e Merge branch 'html5-fixes' into 'v3.x'
[FIX] Use proper HTML5 tags instead of self-closing XML/(X)HTML tags

See merge request allianceauth/allianceauth!1398
2022-02-11 02:10:48 +00:00
Peter Pfeufer
52a4cf8d52 [FIX] Use proper HTML5 tags instead of self-closing XML/XHTML tags 2022-02-08 20:22:53 +01:00
Ariel Rin
703c2392a9 Merge branch 'django4' into 'v3.x'
v2.10.x Uplifts, DJ4, Py3.8 + More

See merge request allianceauth/allianceauth!1387
2022-02-08 13:04:45 +00:00
Ariel Rin
18c9a66437 Merge branch 'fix-ifequal-errors' into 'django4'
Fix ifequal errors

See merge request soratidus999/allianceauth!9
2022-02-06 07:16:56 +00:00
Ariel Rin
9687d57de9 Merge branch 'update-url-configs' into 'django4'
Switch to `path`, use `re_path` only when really needed

See merge request soratidus999/allianceauth!8
2022-02-06 07:16:46 +00:00
Peter Pfeufer
60c2e57d83 Fix ifequal errors 2022-02-02 16:12:43 +01:00
Peter Pfeufer
b14bff0145 We should do this properly .. 2022-02-02 15:28:36 +01:00
Peter Pfeufer
9166886665 That one slipped through the cracks ... 2022-02-02 15:27:07 +01:00
Peter Pfeufer
c74010d441 Docs updated 2022-02-02 15:25:45 +01:00
Peter Pfeufer
640a21e4db Switch to path, use re_path only when really needed 2022-02-02 15:09:48 +01:00
Ariel Rin
fd442a5735 django.conf.urls.url is deprecated 2022-02-02 21:56:01 +10:00
Ariel Rin
c7b99044bc django.conf.urls.url is deprecated, more to fix 2022-02-02 21:39:37 +10:00
Ariel Rin
234451a7d4 temporarily use django-esi MR 2022-02-02 21:37:01 +10:00
Ariel Rin
ffff904ab1 Pull specific commit from git temporarily 2022-02-02 15:18:20 +10:00
Ariel Rin
d71a26220c Merge branch 'v3.x' of https://gitlab.com/allianceauth/allianceauth into django4 2022-02-02 14:24:47 +10:00
Ariel Rin
beeeb8dc5d Merge branch 'v3.x' of https://gitlab.com/allianceauth/allianceauth into v3.x 2022-02-02 14:17:15 +10:00
Ariel Rin
19244cc4c6 Merge branch 'master' of https://gitlab.com/allianceauth/allianceauth into v3.x 2022-02-02 14:16:46 +10:00
Ariel Rin
cc94ba6b5e ensure latest django patch 2022-02-02 14:16:33 +10:00
Ariel Rin
c9926cc877 Merge tag 'v2.10.0' of https://gitlab.com/allianceauth/allianceauth into django4 2022-02-02 14:15:32 +10:00
Ariel Rin
1d14e1b0af Merge branch 'rediscache' into 'v3.x'
Swap the Redis Cache client

See merge request allianceauth/allianceauth!1394
2022-02-02 04:12:04 +00:00
Aaron Kable
297da44a5a Swap the Redis Cache client 2022-02-02 04:12:04 +00:00
Ariel Rin
402ff53a5c Merge branch 'master' of https://gitlab.com/allianceauth/allianceauth into django4 2022-02-02 12:38:24 +10:00
ErikKalkoken
2d6e4a0df1 Merge branch 'master' into fix_character_names 2022-02-01 00:31:25 +01:00
ErikKalkoken
defcfa3316 Character names are not unique 2022-01-05 19:47:15 +01:00
ErikKalkoken
3209b71b0a Fix imports and flake8 issues 2022-01-05 19:28:44 +01:00
Ariel Rin
80b3ca0a1e Merge branch 'v2.10.x' of https://gitlab.com/allianceauth/allianceauth into django4 2021-12-29 16:35:37 +10:00
Ariel Rin
8351bd2fa3 Forgot to remove a 3.7 test 2021-12-29 16:34:14 +10:00
Ariel Rin
255966ed3b Secret Detection was split from SAST 2021-12-29 16:32:42 +10:00
Ariel Rin
8d6ebf4770 Merge branch 'v2.10.x' of https://gitlab.com/allianceauth/allianceauth into django4 2021-12-29 15:52:39 +10:00
Ariel Rin
2ca752bf78 Merge branch 'master' of https://gitlab.com/allianceauth/allianceauth into django4 2021-12-29 15:52:01 +10:00
Ariel Rin
79e1192f67 Update Pre-Commit 2021-12-29 15:45:33 +10:00
Ariel Rin
ff610efc84 Merge branch 'master' of https://gitlab.com/allianceauth/allianceauth into v2.10.x 2021-12-28 21:58:38 +10:00
Ariel Rin
6b68a739ef Initial spam of version bumps 2021-12-24 17:43:15 +10:00
Ariel Rin
909bd0ba15 Fix deprecations removed in dj4 2021-12-24 17:42:53 +10:00
Ariel Rin
05110abc59 remove 3.7 testing 2021-12-24 16:25:29 +10:00
Ariel Rin
a64d99eb91 Target Py3.8 2021-12-24 14:48:30 +10:00
Ariel Rin
0e45403195 Merge branch 'master' of https://gitlab.com/allianceauth/allianceauth into v2.10.x 2021-12-24 14:41:09 +10:00
Ariel Rin
e16a9ffe65 update pre-commit 2021-12-24 14:26:15 +10:00
Ariel Rin
57de122ef8 Move away frfom 3.6 even for pre-commit 2021-12-24 14:23:41 +10:00
192 changed files with 3188 additions and 5191 deletions

View File

@@ -14,6 +14,7 @@ stages:
include:
- template: Dependency-Scanning.gitlab-ci.yml
- template: Security/SAST.gitlab-ci.yml
- template: Security/Secret-Detection.gitlab-ci.yml
before_script:
- apt-get update && apt-get install redis-server -y
@@ -24,7 +25,7 @@ before_script:
pre-commit-check:
<<: *only-default
stage: pre-commit
image: python:3.6-buster
image: python:3.8-bullseye
variables:
PRE_COMMIT_HOME: ${CI_PROJECT_DIR}/.cache/pre-commit
cache:
@@ -46,18 +47,6 @@ dependency_scanning:
- python -V
- pip install wheel tox
test-3.7-core:
<<: *only-default
image: python:3.7-bullseye
script:
- tox -e py37-core
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
test-3.8-core:
<<: *only-default
image: python:3.8-bullseye
@@ -66,9 +55,7 @@ test-3.8-core:
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
cobertura: coverage.xml
test-3.9-core:
<<: *only-default
@@ -78,9 +65,7 @@ test-3.9-core:
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
cobertura: coverage.xml
test-3.10-core:
<<: *only-default
@@ -90,9 +75,7 @@ test-3.10-core:
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
cobertura: coverage.xml
test-3.11-core:
<<: *only-default
@@ -102,23 +85,9 @@ test-3.11-core:
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
cobertura: coverage.xml
allow_failure: true
test-3.7-all:
<<: *only-default
image: python:3.7-bullseye
script:
- tox -e py37-all
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
test-3.8-all:
<<: *only-default
image: python:3.8-bullseye
@@ -127,9 +96,7 @@ test-3.8-all:
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
cobertura: coverage.xml
test-3.9-all:
<<: *only-default
@@ -139,9 +106,7 @@ test-3.9-all:
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
cobertura: coverage.xml
test-3.10-all:
<<: *only-default
@@ -151,9 +116,7 @@ test-3.10-all:
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
cobertura: coverage.xml
test-3.11-all:
<<: *only-default
@@ -163,17 +126,9 @@ test-3.11-all:
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
cobertura: coverage.xml
allow_failure: true
test-docs:
<<: *only-default
image: python:3.9-bullseye
script:
- tox -e docs
deploy_production:
stage: deploy
image: python:3.10-bullseye

View File

@@ -5,7 +5,7 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.0.1
rev: v4.1.0
hooks:
- id: check-case-conflict
- id: check-json
@@ -22,13 +22,13 @@ repos:
args: [ '--remove' ]
- repo: https://github.com/editorconfig-checker/editorconfig-checker.python
rev: 2.3.54
rev: 2.4.0
hooks:
- id: editorconfig-checker
exclude: ^(LICENSE|allianceauth\/static\/css\/themes\/bootstrap-locals.less|allianceauth\/eveonline\/swagger.json|(.*.po)|(.*.mo))
- repo: https://github.com/asottile/pyupgrade
rev: v2.29.0
rev: v2.30.0
hooks:
- id: pyupgrade
args: [ --py37-plus ]
args: [ --py38-plus ]

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
from allianceauth.analytics.middleware import AnalyticsMiddleware
from unittest.mock import Mock
from django.http import HttpResponse
from django.test.testcases import TestCase
@@ -7,7 +8,7 @@ from django.test.testcases import TestCase
class TestAnalyticsMiddleware(TestCase):
def setUp(self):
self.middleware = AnalyticsMiddleware()
self.middleware = AnalyticsMiddleware(HttpResponse)
self.request = Mock()
self.request.headers = {
"User-Agent": "AUTOMATED TEST"

View File

@@ -1,22 +1,12 @@
import requests_mock
from django.test.utils import override_settings
from allianceauth.analytics.tasks import (
analytics_event,
send_ga_tracking_celery_event,
send_ga_tracking_web_view)
from allianceauth.utils.testing import NoSocketsTestCase
from django.test.testcases import TestCase
GOOGLE_ANALYTICS_DEBUG_URL = 'https://www.google-analytics.com/debug/collect'
@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)
class TestAnalyticsTasks(TestCase):
def test_analytics_event(self):
analytics_event(
category='allianceauth.analytics',
action='send_tests',
@@ -24,19 +14,15 @@ class TestAnalyticsTasks(NoSocketsTestCase):
value=1,
event_type='Stats')
def test_send_ga_tracking_web_view_sent(self, requests_mocker):
"""This test sends if the event SENDS to google.
Not if it was successful.
"""
# given
requests_mocker.register_uri('POST', GOOGLE_ANALYTICS_DEBUG_URL)
def test_send_ga_tracking_web_view_sent(self):
# This test sends if the event SENDS to google
# Not if it was successful
tracking_id = 'UA-186249766-2'
client_id = 'ab33e241fbf042b6aa77c7655a768af7'
page = '/index/'
title = 'Hello World'
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"
# when
response = send_ga_tracking_web_view(
tracking_id,
client_id,
@@ -44,23 +30,15 @@ class TestAnalyticsTasks(NoSocketsTestCase):
title,
locale,
useragent)
# then
self.assertEqual(response.status_code, 200)
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}]}
)
def test_send_ga_tracking_web_view_success(self):
tracking_id = 'UA-186249766-2'
client_id = 'ab33e241fbf042b6aa77c7655a768af7'
page = '/index/'
title = 'Hello World'
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"
# when
json_response = send_ga_tracking_web_view(
tracking_id,
client_id,
@@ -68,42 +46,15 @@ class TestAnalyticsTasks(NoSocketsTestCase):
title,
locale,
useragent).json()
# then
self.assertTrue(json_response["hitParsingResult"][0]["valid"])
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'
}
]
}
)
def test_send_ga_tracking_web_view_invalid_token(self):
tracking_id = 'UA-IntentionallyBadTrackingID-2'
client_id = 'ab33e241fbf042b6aa77c7655a768af7'
page = '/index/'
title = 'Hello World'
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"
# when
json_response = send_ga_tracking_web_view(
tracking_id,
client_id,
@@ -111,25 +62,18 @@ class TestAnalyticsTasks(NoSocketsTestCase):
title,
locale,
useragent).json()
# then
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'}]
def test_send_ga_tracking_celery_event_sent(self, requests_mocker):
# given
requests_mocker.register_uri('POST', GOOGLE_ANALYTICS_DEBUG_URL)
def test_send_ga_tracking_celery_event_sent(self):
tracking_id = 'UA-186249766-2'
client_id = 'ab33e241fbf042b6aa77c7655a768af7'
category = 'test'
action = 'test'
label = 'test'
value = '1'
# when
response = send_ga_tracking_celery_event(
tracking_id,
client_id,
@@ -137,23 +81,15 @@ class TestAnalyticsTasks(NoSocketsTestCase):
action,
label,
value)
# then
self.assertEqual(response.status_code, 200)
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}]}
)
def test_send_ga_tracking_celery_event_success(self):
tracking_id = 'UA-186249766-2'
client_id = 'ab33e241fbf042b6aa77c7655a768af7'
category = 'test'
action = 'test'
label = 'test'
value = '1'
# when
json_response = send_ga_tracking_celery_event(
tracking_id,
client_id,
@@ -161,42 +97,15 @@ class TestAnalyticsTasks(NoSocketsTestCase):
action,
label,
value).json()
# then
self.assertTrue(json_response["hitParsingResult"][0]["valid"])
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'
}
]
}
)
def test_send_ga_tracking_celery_event_invalid_token(self):
tracking_id = 'UA-IntentionallyBadTrackingID-2'
client_id = 'ab33e241fbf042b6aa77c7655a768af7'
category = 'test'
action = 'test'
label = 'test'
value = '1'
# when
json_response = send_ga_tracking_celery_event(
tracking_id,
client_id,
@@ -204,9 +113,7 @@ class TestAnalyticsTasks(NoSocketsTestCase):
action,
label,
value).json()
# then
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'}]

View File

@@ -1,44 +1,30 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.models import Group
from django.contrib.auth.models import Permission as BasePermission
from django.contrib.auth.models import User as BaseUser
from django.contrib.auth.models import User as BaseUser, \
Permission as BasePermission, Group
from django.db.models import Count, Q
from allianceauth.services.hooks import ServicesHook
from django.db.models.signals import pre_save, post_save, pre_delete, \
post_delete, m2m_changed
from django.db.models.functions import Lower
from django.db.models.signals import (
m2m_changed,
post_delete,
post_save,
pre_delete,
pre_save
)
from django.dispatch import receiver
from django.urls import reverse
from django.forms import ModelForm
from django.utils.html import format_html
from django.urls import reverse
from django.utils.text import slugify
from allianceauth.authentication.models import (
CharacterOwnership,
OwnershipRecord,
State,
get_guest_state,
CharacterOwnership,
UserProfile,
get_guest_state
)
from allianceauth.eveonline.models import (
EveAllianceInfo,
EveCharacter,
EveCorporationInfo,
EveFactionInfo
)
from allianceauth.eveonline.tasks import update_character
OwnershipRecord)
from allianceauth.hooks import get_hooks
from allianceauth.services.hooks import ServicesHook
from .app_settings import (
AUTHENTICATION_ADMIN_USERS_MAX_CHARS,
AUTHENTICATION_ADMIN_USERS_MAX_GROUPS
)
from .forms import UserChangeForm, UserProfileForm
from allianceauth.eveonline.models import EveCharacter, EveCorporationInfo,\
EveAllianceInfo, EveFactionInfo
from allianceauth.eveonline.tasks import update_character
from .app_settings import AUTHENTICATION_ADMIN_USERS_MAX_GROUPS, \
AUTHENTICATION_ADMIN_USERS_MAX_CHARS
def make_service_hooks_update_groups_action(service):
@@ -77,10 +63,19 @@ def make_service_hooks_sync_nickname_action(service):
return sync_nickname
class QuerysetModelForm(ModelForm):
# allows specifying FK querysets through kwarg
def __init__(self, querysets=None, *args, **kwargs):
querysets = querysets or {}
super().__init__(*args, **kwargs)
for field, qs in querysets.items():
self.fields[field].queryset = qs
class UserProfileInline(admin.StackedInline):
model = UserProfile
readonly_fields = ('state',)
form = UserProfileForm
form = QuerysetModelForm
verbose_name = ''
verbose_name_plural = 'Profile'
@@ -108,7 +103,6 @@ class UserProfileInline(admin.StackedInline):
return False
@admin.display(description="")
def user_profile_pic(obj):
"""profile pic column data for user objects
@@ -121,10 +115,13 @@ def user_profile_pic(obj):
'<img src="{}" class="img-circle">',
user_obj.profile.main_character.portrait_url(size=32)
)
return None
else:
return None
user_profile_pic.short_description = ''
@admin.display(description="user / main", ordering="username")
def user_username(obj):
"""user column data for user objects
@@ -146,17 +143,18 @@ def user_username(obj):
user_obj.username,
user_obj.profile.main_character.character_name
)
return format_html(
'<strong><a href="{}">{}</a></strong>',
link,
user_obj.username,
)
else:
return format_html(
'<strong><a href="{}">{}</a></strong>',
link,
user_obj.username,
)
user_username.short_description = 'user / main'
user_username.admin_order_field = 'username'
@admin.display(
description="Corporation / Alliance (Main)",
ordering="profile__main_character__corporation_name"
)
def user_main_organization(obj):
"""main organization column data for user objects
@@ -165,15 +163,21 @@ def user_main_organization(obj):
"""
user_obj = obj.user if hasattr(obj, 'user') else obj
if not user_obj.profile.main_character:
return ''
result = user_obj.profile.main_character.corporation_name
if user_obj.profile.main_character.alliance_id:
result += f'<br>{user_obj.profile.main_character.alliance_name}'
elif user_obj.profile.main_character.faction_name:
result += f'<br>{user_obj.profile.main_character.faction_name}'
result = ''
else:
result = user_obj.profile.main_character.corporation_name
if user_obj.profile.main_character.alliance_id:
result += f'<br>{user_obj.profile.main_character.alliance_name}'
elif user_obj.profile.main_character.faction_name:
result += f'<br>{user_obj.profile.main_character.faction_name}'
return format_html(result)
user_main_organization.short_description = 'Corporation / Alliance (Main)'
user_main_organization.admin_order_field = \
'profile__main_character__corporation_name'
class MainCorporationsFilter(admin.SimpleListFilter):
"""Custom filter to filter on corporations from mains only
@@ -196,13 +200,15 @@ class MainCorporationsFilter(admin.SimpleListFilter):
def queryset(self, request, qs):
if self.value() is None:
return qs.all()
if qs.model == User:
return qs.filter(
profile__main_character__corporation_id=self.value()
)
return qs.filter(
user__profile__main_character__corporation_id=self.value()
)
else:
if qs.model == User:
return qs.filter(
profile__main_character__corporation_id=self.value()
)
else:
return qs.filter(
user__profile__main_character__corporation_id=self.value()
)
class MainAllianceFilter(admin.SimpleListFilter):
@@ -215,14 +221,12 @@ class MainAllianceFilter(admin.SimpleListFilter):
parameter_name = 'main_alliance_id__exact'
def lookups(self, request, model_admin):
qs = (
EveCharacter.objects
.exclude(alliance_id=None)
.exclude(userprofile=None)
.values('alliance_id', 'alliance_name')
.distinct()
qs = EveCharacter.objects\
.exclude(alliance_id=None)\
.exclude(userprofile=None)\
.values('alliance_id', 'alliance_name')\
.distinct()\
.order_by(Lower('alliance_name'))
)
return tuple(
(x['alliance_id'], x['alliance_name']) for x in qs
)
@@ -230,11 +234,13 @@ class MainAllianceFilter(admin.SimpleListFilter):
def queryset(self, request, qs):
if self.value() is None:
return qs.all()
if qs.model == User:
return qs.filter(profile__main_character__alliance_id=self.value())
return qs.filter(
user__profile__main_character__alliance_id=self.value()
)
else:
if qs.model == User:
return qs.filter(profile__main_character__alliance_id=self.value())
else:
return qs.filter(
user__profile__main_character__alliance_id=self.value()
)
class MainFactionFilter(admin.SimpleListFilter):
@@ -247,14 +253,12 @@ class MainFactionFilter(admin.SimpleListFilter):
parameter_name = 'main_faction_id__exact'
def lookups(self, request, model_admin):
qs = (
EveCharacter.objects
.exclude(faction_id=None)
.exclude(userprofile=None)
.values('faction_id', 'faction_name')
.distinct()
qs = EveCharacter.objects\
.exclude(faction_id=None)\
.exclude(userprofile=None)\
.values('faction_id', 'faction_name')\
.distinct()\
.order_by(Lower('faction_name'))
)
return tuple(
(x['faction_id'], x['faction_name']) for x in qs
)
@@ -262,14 +266,15 @@ class MainFactionFilter(admin.SimpleListFilter):
def queryset(self, request, qs):
if self.value() is None:
return qs.all()
if qs.model == User:
return qs.filter(profile__main_character__faction_id=self.value())
return qs.filter(
user__profile__main_character__faction_id=self.value()
)
else:
if qs.model == User:
return qs.filter(profile__main_character__faction_id=self.value())
else:
return qs.filter(
user__profile__main_character__faction_id=self.value()
)
@admin.display(description="Update main character model from ESI")
def update_main_character_model(modeladmin, request, queryset):
tasks_count = 0
for obj in queryset:
@@ -278,48 +283,21 @@ def update_main_character_model(modeladmin, request, queryset):
tasks_count += 1
modeladmin.message_user(
request, f'Update from ESI started for {tasks_count} characters'
request,
f'Update from ESI started for {tasks_count} characters'
)
update_main_character_model.short_description = \
'Update main character model from ESI'
class UserAdmin(BaseUserAdmin):
"""Extending Django's UserAdmin model
Behavior of groups and characters columns can be configured via settings
"""
inlines = BaseUserAdmin.inlines + [UserProfileInline]
ordering = ('username', )
list_select_related = ('profile__state', 'profile__main_character')
show_full_result_count = True
list_display = (
user_profile_pic,
user_username,
'_state',
'_groups',
user_main_organization,
'_characters',
'is_active',
'date_joined',
'_role'
)
list_display_links = None
list_filter = (
'profile__state',
'groups',
MainCorporationsFilter,
MainAllianceFilter,
MainFactionFilter,
'is_active',
'date_joined',
'is_staff',
'is_superuser'
)
search_fields = ('username', 'character_ownerships__character__character_name')
readonly_fields = ('date_joined', 'last_login')
filter_horizontal = ('groups', 'user_permissions',)
form = UserChangeForm
class Media:
css = {
"all": ("authentication/css/admin.css",)
@@ -329,21 +307,9 @@ class UserAdmin(BaseUserAdmin):
qs = super().get_queryset(request)
return qs.prefetch_related("character_ownerships__character", "groups")
def get_form(self, request, obj=None, **kwargs):
"""Inject current request into change form object."""
MyForm = super().get_form(request, obj, **kwargs)
if obj:
class MyFormInjected(MyForm):
def __new__(cls, *args, **kwargs):
kwargs['request'] = request
return MyForm(*args, **kwargs)
return MyFormInjected
return MyForm
def get_actions(self, request):
actions = super().get_actions(request)
actions = super(BaseUserAdmin, self).get_actions(request)
actions[update_main_character_model.__name__] = (
update_main_character_model,
update_main_character_model.__name__,
@@ -387,6 +353,39 @@ class UserAdmin(BaseUserAdmin):
)
return result
inlines = BaseUserAdmin.inlines + [UserProfileInline]
ordering = ('username', )
list_select_related = ('profile__state', 'profile__main_character')
show_full_result_count = True
list_display = (
user_profile_pic,
user_username,
'_state',
'_groups',
user_main_organization,
'_characters',
'is_active',
'date_joined',
'_role'
)
list_display_links = None
list_filter = (
'profile__state',
'groups',
MainCorporationsFilter,
MainAllianceFilter,
MainFactionFilter,
'is_active',
'date_joined',
'is_staff',
'is_superuser'
)
search_fields = (
'username',
'character_ownerships__character__character_name'
)
readonly_fields = ('date_joined', 'last_login')
def _characters(self, obj):
character_ownerships = list(obj.character_ownerships.all())
characters = [obj.character.character_name for obj in character_ownerships]
@@ -395,16 +394,22 @@ class UserAdmin(BaseUserAdmin):
AUTHENTICATION_ADMIN_USERS_MAX_CHARS
)
@admin.display(ordering="profile__state")
_characters.short_description = 'characters'
def _state(self, obj):
return obj.profile.state.name
_state.short_description = 'state'
_state.admin_order_field = 'profile__state'
def _groups(self, obj):
my_groups = sorted(group.name for group in list(obj.groups.all()))
return self._list_2_html_w_tooltips(
my_groups, AUTHENTICATION_ADMIN_USERS_MAX_GROUPS
)
_groups.short_description = 'groups'
def _role(self, obj):
if obj.is_superuser:
role = 'Superuser'
@@ -414,6 +419,8 @@ class UserAdmin(BaseUserAdmin):
role = 'User'
return role
_role.short_description = 'role'
def has_change_permission(self, request, obj=None):
return request.user.has_perm('auth.change_user')
@@ -435,16 +442,9 @@ class UserAdmin(BaseUserAdmin):
if obj_state:
matching_groups_qs = Group.objects.filter(authgroup__states=obj_state)
groups_qs = groups_qs | matching_groups_qs
kwargs["queryset"] = groups_qs.order_by(Lower("name"))
kwargs["queryset"] = groups_qs.order_by(Lower('name'))
return super().formfield_for_manytomany(db_field, request, **kwargs)
def get_readonly_fields(self, request, obj=None):
if obj and not request.user.is_superuser:
return self.readonly_fields + (
"is_staff", "is_superuser", "user_permissions"
)
return self.readonly_fields
@admin.register(State)
class StateAdmin(admin.ModelAdmin):
@@ -455,9 +455,10 @@ class StateAdmin(admin.ModelAdmin):
qs = super().get_queryset(request)
return qs.annotate(user_count=Count("userprofile__id"))
@admin.display(description="Users", ordering="user_count")
def _user_count(self, obj):
return obj.user_count
_user_count.short_description = 'Users'
_user_count.admin_order_field = 'user_count'
fieldsets = (
(None, {
@@ -513,13 +514,13 @@ class StateAdmin(admin.ModelAdmin):
)
return super().get_fieldsets(request, obj=obj)
def get_readonly_fields(self, request, obj=None):
if not request.user.is_superuser:
return self.readonly_fields + ("permissions",)
return self.readonly_fields
class BaseOwnershipAdmin(admin.ModelAdmin):
class Media:
css = {
"all": ("authentication/css/admin.css",)
}
list_select_related = (
'user__profile__state', 'user__profile__main_character', 'character')
list_display = (
@@ -540,11 +541,6 @@ class BaseOwnershipAdmin(admin.ModelAdmin):
MainAllianceFilter,
)
class Media:
css = {
"all": ("authentication/css/admin.css",)
}
def get_readonly_fields(self, request, obj=None):
if obj and obj.pk:
return 'owner_hash', 'character'

View File

@@ -1,66 +1,8 @@
from django import forms
from django.contrib.auth.forms import UserChangeForm as BaseUserChangeForm
from django.contrib.auth.models import Group
from django.core.exceptions import ValidationError
from django.forms import ModelForm
from django.utils.translation import gettext_lazy as _
from allianceauth.authentication.models import User
class RegistrationForm(forms.Form):
email = forms.EmailField(label=_('Email'), max_length=254, required=True)
class _meta:
model = User
class UserProfileForm(ModelForm):
"""Allows specifying FK querysets through kwarg"""
def __init__(self, querysets=None, *args, **kwargs):
querysets = querysets or {}
super().__init__(*args, **kwargs)
for field, qs in querysets.items():
self.fields[field].queryset = qs
class UserChangeForm(BaseUserChangeForm):
"""Add custom cleaning to UserChangeForm"""
def __init__(self, *args, **kwargs):
self.request = kwargs.pop("request") # Inject current request into form object
super().__init__(*args, **kwargs)
def clean(self):
cleaned_data = super().clean()
if not self.request.user.is_superuser:
if self.instance:
current_restricted = set(
self.instance.groups.filter(
authgroup__restricted=True
).values_list("pk", flat=True)
)
else:
current_restricted = set()
new_restricted = set(
cleaned_data["groups"].filter(
authgroup__restricted=True
).values_list("pk", flat=True)
)
if current_restricted != new_restricted:
restricted_removed = current_restricted - new_restricted
restricted_added = new_restricted - current_restricted
restricted_changed = restricted_removed | restricted_added
restricted_names_qs = Group.objects.filter(
pk__in=restricted_changed
).values_list("name", flat=True)
restricted_names = ",".join(list(restricted_names_qs))
raise ValidationError(
{
"groups": _(
"You are not allowed to add or remove these "
"restricted groups: %s" % restricted_names
)
}
)

View File

@@ -1,14 +1,16 @@
from django.conf.urls import url, include
from django.conf.urls import include
from allianceauth.authentication import views
from django.urls import re_path
from django.urls import path
urlpatterns = [
url(r'^activate/complete/$', views.activation_complete, name='registration_activation_complete'),
path('activate/complete/', views.activation_complete, name='registration_activation_complete'),
# The activation key can make use of any character from the
# URL-safe base64 alphabet, plus the colon as a separator.
url(r'^activate/(?P<activation_key>[-:\w]+)/$', views.ActivationView.as_view(), name='registration_activate'),
url(r'^register/$', views.RegistrationView.as_view(), name='registration_register'),
url(r'^register/complete/$', views.registration_complete, name='registration_complete'),
url(r'^register/closed/$', views.registration_closed, name='registration_disallowed'),
url(r'', include('django.contrib.auth.urls')),
re_path(r'^activate/(?P<activation_key>[-:\w]+)/$', views.ActivationView.as_view(), name='registration_activate'),
path('register/', views.RegistrationView.as_view(), name='registration_register'),
path('register/complete/', views.registration_complete, name='registration_complete'),
path('register/closed/', views.registration_closed, name='registration_disallowed'),
path('', include('django.contrib.auth.urls')),
]

View File

@@ -0,0 +1,45 @@
from django.conf import settings
from django.utils.deprecation import MiddlewareMixin
import logging
logger = logging.getLogger(__name__)
class UserSettingsMiddleware(MiddlewareMixin):
def process_response(self, request, response):
"""Django Middleware: User Settings."""
# Intercept the built in django /setlang/ view and also save it to Database.
# Note the annoymous user check, only logged in users will ever hit the DB here
if request.path == '/i18n/setlang/' and not request.user.is_anonymous:
try:
request.user.profile.language = request.POST['language']
request.user.profile.save()
except Exception as e:
logger.exception(e)
# Only act during the login flow, _after_ user is activated (step 2: post-sso)
elif request.path == '/sso/login' and not request.user.is_anonymous:
# Set the Language Cookie, if it doesnt match the DB
# Null = hasnt been set by the user ever, dont act.
try:
if request.user.profile.language != request.LANGUAGE_CODE and request.user.profile.language is not None:
response.set_cookie(key=settings.LANGUAGE_COOKIE_NAME,
value=request.user.profile.language,
max_age=settings.LANGUAGE_COOKIE_AGE)
except Exception as e:
logger.exception(e)
# Set our Night mode flag from the DB
# Null = hasnt been set by the user ever, dont act.
#
# Night mode intercept is not needed in this middleware.
# is saved direct to DB in NightModeRedirectView
try:
if request.user.profile.night_mode is not None:
request.session["NIGHT_MODE"] = request.user.profile.night_mode
except Exception as e:
logger.exception(e)
return response

View File

@@ -0,0 +1,23 @@
# Generated by Django 4.0.2 on 2022-02-26 03:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('authentication', '0019_merge_20211026_0919'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='language',
field=models.CharField(blank=True, choices=[('en', 'English'), ('de', 'German'), ('es', 'Spanish'), ('zh-hans', 'Chinese Simplified'), ('ru', 'Russian'), ('ko', 'Korean'), ('fr', 'French'), ('ja', 'Japanese'), ('it', 'Italian')], default='', max_length=10, verbose_name='Language'),
),
migrations.AddField(
model_name='userprofile',
name='night_mode',
field=models.BooleanField(blank=True, null=True, verbose_name='Night Mode'),
),
]

View File

@@ -2,9 +2,10 @@ import logging
from django.contrib.auth.models import User, Permission
from django.db import models, transaction
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import gettext_lazy as _
from allianceauth.eveonline.models import EveCharacter, EveCorporationInfo, EveAllianceInfo, EveFactionInfo
from allianceauth.notifications import notify
from django.conf import settings
from .managers import CharacterOwnershipManager, StateManager
@@ -62,9 +63,39 @@ class UserProfile(models.Model):
class Meta:
default_permissions = ('change',)
user = models.OneToOneField(User, related_name='profile', on_delete=models.CASCADE)
main_character = models.OneToOneField(EveCharacter, blank=True, null=True, on_delete=models.SET_NULL)
state = models.ForeignKey(State, on_delete=models.SET_DEFAULT, default=get_guest_state_pk)
user = models.OneToOneField(
User,
related_name='profile',
on_delete=models.CASCADE)
main_character = models.OneToOneField(
EveCharacter,
blank=True,
null=True,
on_delete=models.SET_NULL)
state = models.ForeignKey(
State,
on_delete=models.SET_DEFAULT,
default=get_guest_state_pk)
LANGUAGE_CHOICES = [
('en', _('English')),
('de', _('German')),
('es', _('Spanish')),
('zh-hans', _('Chinese Simplified')),
('ru', _('Russian')),
('ko', _('Korean')),
('fr', _('French')),
('ja', _('Japanese')),
('it', _('Italian')),
]
language = models.CharField(
_("Language"), max_length=10,
choices=LANGUAGE_CHOICES,
blank=True,
default='')
night_mode = models.BooleanField(
_("Night Mode"),
blank=True,
null=True)
def assign_state(self, state=None, commit=True):
if not state:
@@ -93,8 +124,6 @@ class UserProfile(models.Model):
def __str__(self):
return str(self.user)
class CharacterOwnership(models.Model):
class Meta:
default_permissions = ('change', 'delete')

View File

@@ -1,6 +1,11 @@
import logging
from .models import CharacterOwnership, UserProfile, get_guest_state, State, OwnershipRecord
from .models import (
CharacterOwnership,
UserProfile,
get_guest_state,
State,
OwnershipRecord)
from django.contrib.auth.models import User
from django.db.models import Q
from django.db.models.signals import pre_save, post_save, pre_delete, post_delete, m2m_changed
@@ -11,7 +16,7 @@ from allianceauth.eveonline.models import EveCharacter
logger = logging.getLogger(__name__)
state_changed = Signal(providing_args=['user', 'state'])
state_changed = Signal()
def trigger_state_check(state):
@@ -71,7 +76,7 @@ def reassess_on_profile_save(sender, instance, created, *args, **kwargs):
@receiver(post_save, sender=User)
def create_required_models(sender, instance, created, *args, **kwargs):
# ensure all users have a model
# ensure all users have our Sub-Models
if created:
logger.debug(f'User {instance} created. Creating default UserProfile.')
UserProfile.objects.get_or_create(user=instance)

View File

@@ -1,40 +0,0 @@
from collections import namedtuple
import datetime as dt
from .event_series import EventSeries
"""Global series for counting task events."""
succeeded_tasks = EventSeries("SUCCEEDED_TASKS")
retried_tasks = EventSeries("RETRIED_TASKS")
failed_tasks = EventSeries("FAILED_TASKS")
_TaskCounts = namedtuple(
"_TaskCounts", ["succeeded", "retried", "failed", "total", "earliest_task", "hours"]
)
def dashboard_results(hours: int) -> _TaskCounts:
"""Counts of all task events within the given timeframe."""
def earliest_if_exists(events: EventSeries, earliest: dt.datetime) -> list:
my_earliest = events.first_event(earliest=earliest)
return [my_earliest] if my_earliest else []
earliest = dt.datetime.utcnow() - dt.timedelta(hours=hours)
earliest_events = list()
succeeded_count = succeeded_tasks.count(earliest=earliest)
earliest_events += earliest_if_exists(succeeded_tasks, earliest)
retried_count = retried_tasks.count(earliest=earliest)
earliest_events += earliest_if_exists(retried_tasks, earliest)
failed_count = failed_tasks.count(earliest=earliest)
earliest_events += earliest_if_exists(failed_tasks, earliest)
return _TaskCounts(
succeeded=succeeded_count,
retried=retried_count,
failed=failed_count,
total=succeeded_count + retried_count + failed_count,
earliest_task=min(earliest_events) if earliest_events else None,
hours=hours,
)

View File

@@ -1,69 +1,74 @@
import datetime as dt
import logging
from typing import List, Optional
from collections import namedtuple
from typing import Optional, List
from redis import Redis
from pytz import utc
from redis import Redis, RedisError
from allianceauth.utils.cache import get_redis_client
from django_redis import get_redis_connection
logger = logging.getLogger(__name__)
_TaskCounts = namedtuple(
"_TaskCounts", ["succeeded", "retried", "failed", "total", "earliest_task", "hours"]
)
class _RedisStub:
"""Stub of a Redis client.
def dashboard_results(hours: int) -> _TaskCounts:
"""Counts of all task events within the given timeframe."""
def earliest_if_exists(events: EventSeries, earliest: dt.datetime) -> list:
my_earliest = events.first_event(earliest=earliest)
return [my_earliest] if my_earliest else []
It's purpose is to prevent EventSeries objects from trying to access Redis
when it is not available. e.g. when the Sphinx docs are rendered by readthedocs.org.
"""
def delete(self, *args, **kwargs):
pass
def incr(self, *args, **kwargs):
return 0
def zadd(self, *args, **kwargs):
pass
def zcount(self, *args, **kwargs):
pass
def zrangebyscore(self, *args, **kwargs):
pass
earliest = dt.datetime.utcnow() - dt.timedelta(hours=hours)
earliest_events = list()
succeeded = SucceededTaskSeries()
succeeded_count = succeeded.count(earliest=earliest)
earliest_events += earliest_if_exists(succeeded, earliest)
retried = RetriedTaskSeries()
retried_count = retried.count(earliest=earliest)
earliest_events += earliest_if_exists(retried, earliest)
failed = FailedTaskSeries()
failed_count = failed.count(earliest=earliest)
earliest_events += earliest_if_exists(failed, earliest)
return _TaskCounts(
succeeded=succeeded_count,
retried=retried_count,
failed=failed_count,
total=succeeded_count + retried_count + failed_count,
earliest_task=min(earliest_events) if earliest_events else None,
hours=hours,
)
class EventSeries:
"""API for recording and analyzing a series of events."""
"""Base class for recording and analysing a series of events.
_ROOT_KEY = "ALLIANCEAUTH_EVENT_SERIES"
This class must be inherited from and the child class must define KEY_ID.
"""
def __init__(self, key_id: str, redis: Redis = None) -> None:
self._redis = get_redis_client() if not redis else redis
try:
if not self._redis.ping():
raise RuntimeError()
except (AttributeError, RedisError, RuntimeError):
logger.exception(
"Failed to establish a connection with Redis. "
"This EventSeries object is disabled.",
_ROOT_KEY = "ALLIANCEAUTH_TASK_SERIES"
def __init__(
self,
redis: Redis = None,
) -> None:
if type(self) == EventSeries:
raise TypeError("Can not instantiate base class.")
if not hasattr(self, "KEY_ID"):
raise ValueError("KEY_ID not defined")
self._redis = get_redis_connection("default") if not redis else redis
if not isinstance(self._redis, Redis):
raise TypeError(
"This class requires a Redis client, but none was provided "
"and the default Django cache backend is not Redis either."
)
self._redis = _RedisStub()
self._key_id = str(key_id)
self.clear()
@property
def is_disabled(self):
"""True when this object is disabled, e.g. Redis was not available at startup."""
return isinstance(self._redis, _RedisStub)
@property
def _key_counter(self):
return f"{self._ROOT_KEY}_{self._key_id}_COUNTER"
return f"{self._ROOT_KEY}_{self.KEY_ID}_COUNTER"
@property
def _key_sorted_set(self):
return f"{self._ROOT_KEY}_{self._key_id}_SORTED_SET"
return f"{self._ROOT_KEY}_{self.KEY_ID}_SORTED_SET"
def add(self, event_time: dt.datetime = None) -> None:
"""Add event.
@@ -128,3 +133,21 @@ class EventSeries:
@staticmethod
def _cast_scores_to_dt(score) -> dt.datetime:
return dt.datetime.fromtimestamp(float(score), tz=utc)
class SucceededTaskSeries(EventSeries):
"""A task has succeeded."""
KEY_ID = "SUCCEEDED"
class RetriedTaskSeries(EventSeries):
"""A task has been retried."""
KEY_ID = "RETRIED"
class FailedTaskSeries(EventSeries):
"""A task has failed."""
KEY_ID = "FAILED"

View File

@@ -1,21 +1,15 @@
from celery.signals import (
task_failure,
task_internal_error,
task_retry,
task_success,
worker_ready
)
from celery.signals import task_failure, task_retry, task_success, worker_ready
from django.conf import settings
from .counters import failed_tasks, retried_tasks, succeeded_tasks
from .event_series import FailedTaskSeries, RetriedTaskSeries, SucceededTaskSeries
def reset_counters():
"""Reset all counters for the celery status."""
succeeded_tasks.clear()
failed_tasks.clear()
retried_tasks.clear()
SucceededTaskSeries().clear()
FailedTaskSeries().clear()
RetriedTaskSeries().clear()
def is_enabled() -> bool:
@@ -33,22 +27,16 @@ def reset_counters_when_celery_restarted(*args, **kwargs):
@task_success.connect
def record_task_succeeded(*args, **kwargs):
if is_enabled():
succeeded_tasks.add()
SucceededTaskSeries().add()
@task_retry.connect
def record_task_retried(*args, **kwargs):
if is_enabled():
retried_tasks.add()
RetriedTaskSeries().add()
@task_failure.connect
def record_task_failed(*args, **kwargs):
if is_enabled():
failed_tasks.add()
@task_internal_error.connect
def record_task_internal_error(*args, **kwargs):
if is_enabled():
failed_tasks.add()
FailedTaskSeries().add()

View File

@@ -1,51 +0,0 @@
import datetime as dt
from django.test import TestCase
from django.utils.timezone import now
from allianceauth.authentication.task_statistics.counters import (
dashboard_results,
succeeded_tasks,
retried_tasks,
failed_tasks,
)
class TestDashboardResults(TestCase):
def test_should_return_counts_for_given_timeframe_only(self):
# given
earliest_task = now() - dt.timedelta(minutes=15)
succeeded_tasks.clear()
succeeded_tasks.add(now() - dt.timedelta(hours=1, seconds=1))
succeeded_tasks.add(earliest_task)
succeeded_tasks.add()
succeeded_tasks.add()
retried_tasks.clear()
retried_tasks.add(now() - dt.timedelta(hours=1, seconds=1))
retried_tasks.add(now() - dt.timedelta(seconds=30))
retried_tasks.add()
failed_tasks.clear()
failed_tasks.add(now() - dt.timedelta(hours=1, seconds=1))
failed_tasks.add()
# when
results = dashboard_results(hours=1)
# then
self.assertEqual(results.succeeded, 3)
self.assertEqual(results.retried, 2)
self.assertEqual(results.failed, 1)
self.assertEqual(results.total, 6)
self.assertEqual(results.earliest_task, earliest_task)
def test_should_work_with_no_data(self):
# given
succeeded_tasks.clear()
retried_tasks.clear()
failed_tasks.clear()
# when
results = dashboard_results(hours=1)
# then
self.assertEqual(results.succeeded, 0)
self.assertEqual(results.retried, 0)
self.assertEqual(results.failed, 0)
self.assertEqual(results.total, 0)
self.assertIsNone(results.earliest_task)

View File

@@ -1,51 +1,49 @@
import datetime as dt
from unittest.mock import patch
from pytz import utc
from redis import RedisError
from django.test import TestCase
from django.utils.timezone import now
from allianceauth.authentication.task_statistics.event_series import (
EventSeries,
_RedisStub,
FailedTaskSeries,
RetriedTaskSeries,
SucceededTaskSeries,
dashboard_results,
)
MODULE_PATH = "allianceauth.authentication.task_statistics.event_series"
class TestEventSeries(TestCase):
def test_should_abort_without_redis_client(self):
# when
with patch(MODULE_PATH + ".get_redis_client") as mock:
mock.return_value = None
events = EventSeries("dummy")
# then
self.assertTrue(events._redis, _RedisStub)
self.assertTrue(events.is_disabled)
"""Testing EventSeries class."""
def test_should_disable_itself_if_redis_not_available_1(self):
# when
with patch(MODULE_PATH + ".get_redis_client") as mock_get_master_client:
mock_get_master_client.return_value.ping.side_effect = RedisError
events = EventSeries("dummy")
# then
self.assertIsInstance(events._redis, _RedisStub)
self.assertTrue(events.is_disabled)
class IncompleteEvents(EventSeries):
"""Child class without KEY ID"""
def test_should_disable_itself_if_redis_not_available_2(self):
class MyEventSeries(EventSeries):
KEY_ID = "TEST"
def test_should_create_object(self):
# when
with patch(MODULE_PATH + ".get_redis_client") as mock_get_master_client:
mock_get_master_client.return_value.ping.return_value = False
events = EventSeries("dummy")
events = self.MyEventSeries()
# then
self.assertIsInstance(events._redis, _RedisStub)
self.assertTrue(events.is_disabled)
self.assertIsInstance(events, self.MyEventSeries)
def test_should_abort_when_redis_client_invalid(self):
with self.assertRaises(TypeError):
self.MyEventSeries(redis="invalid")
def test_should_not_allow_instantiation_of_base_class(self):
with self.assertRaises(TypeError):
EventSeries()
def test_should_not_allow_creating_child_class_without_key_id(self):
with self.assertRaises(ValueError):
self.IncompleteEvents()
def test_should_add_event(self):
# given
events = EventSeries("dummy")
events = self.MyEventSeries()
events.clear()
# when
events.add()
# then
@@ -55,7 +53,8 @@ class TestEventSeries(TestCase):
def test_should_add_event_with_specified_time(self):
# given
events = EventSeries("dummy")
events = self.MyEventSeries()
events.clear()
my_time = dt.datetime(2021, 11, 1, 12, 15, tzinfo=utc)
# when
events.add(my_time)
@@ -66,7 +65,8 @@ class TestEventSeries(TestCase):
def test_should_count_events(self):
# given
events = EventSeries("dummy")
events = self.MyEventSeries()
events.clear()
events.add()
events.add()
# when
@@ -76,7 +76,8 @@ class TestEventSeries(TestCase):
def test_should_count_zero(self):
# given
events = EventSeries("dummy")
events = self.MyEventSeries()
events.clear()
# when
result = events.count()
# then
@@ -84,7 +85,8 @@ class TestEventSeries(TestCase):
def test_should_count_events_within_timeframe_1(self):
# given
events = EventSeries("dummy")
events = self.MyEventSeries()
events.clear()
events.add(dt.datetime(2021, 12, 1, 12, 0, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 10, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 15, tzinfo=utc))
@@ -99,7 +101,8 @@ class TestEventSeries(TestCase):
def test_should_count_events_within_timeframe_2(self):
# given
events = EventSeries("dummy")
events = self.MyEventSeries()
events.clear()
events.add(dt.datetime(2021, 12, 1, 12, 0, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 10, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 15, tzinfo=utc))
@@ -111,7 +114,8 @@ class TestEventSeries(TestCase):
def test_should_count_events_within_timeframe_3(self):
# given
events = EventSeries("dummy")
events = self.MyEventSeries()
events.clear()
events.add(dt.datetime(2021, 12, 1, 12, 0, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 10, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 15, tzinfo=utc))
@@ -123,7 +127,8 @@ class TestEventSeries(TestCase):
def test_should_clear_events(self):
# given
events = EventSeries("dummy")
events = self.MyEventSeries()
events.clear()
events.add()
events.add()
# when
@@ -133,7 +138,8 @@ class TestEventSeries(TestCase):
def test_should_return_date_of_first_event(self):
# given
events = EventSeries("dummy")
events = self.MyEventSeries()
events.clear()
events.add(dt.datetime(2021, 12, 1, 12, 0, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 10, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 15, tzinfo=utc))
@@ -145,7 +151,8 @@ class TestEventSeries(TestCase):
def test_should_return_date_of_first_event_with_range(self):
# given
events = EventSeries("dummy")
events = self.MyEventSeries()
events.clear()
events.add(dt.datetime(2021, 12, 1, 12, 0, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 10, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 15, tzinfo=utc))
@@ -159,10 +166,57 @@ class TestEventSeries(TestCase):
def test_should_return_all_events(self):
# given
events = EventSeries("dummy")
events = self.MyEventSeries()
events.clear()
events.add()
events.add()
# when
results = events.all()
# then
self.assertEqual(len(results), 2)
class TestDashboardResults(TestCase):
def test_should_return_counts_for_given_timeframe_only(self):
# given
earliest_task = now() - dt.timedelta(minutes=15)
succeeded = SucceededTaskSeries()
succeeded.clear()
succeeded.add(now() - dt.timedelta(hours=1, seconds=1))
succeeded.add(earliest_task)
succeeded.add()
succeeded.add()
retried = RetriedTaskSeries()
retried.clear()
retried.add(now() - dt.timedelta(hours=1, seconds=1))
retried.add(now() - dt.timedelta(seconds=30))
retried.add()
failed = FailedTaskSeries()
failed.clear()
failed.add(now() - dt.timedelta(hours=1, seconds=1))
failed.add()
# when
results = dashboard_results(hours=1)
# then
self.assertEqual(results.succeeded, 3)
self.assertEqual(results.retried, 2)
self.assertEqual(results.failed, 1)
self.assertEqual(results.total, 6)
self.assertEqual(results.earliest_task, earliest_task)
def test_should_work_with_no_data(self):
# given
succeeded = SucceededTaskSeries()
succeeded.clear()
retried = RetriedTaskSeries()
retried.clear()
failed = FailedTaskSeries()
failed.clear()
# when
results = dashboard_results(hours=1)
# then
self.assertEqual(results.succeeded, 0)
self.assertEqual(results.retried, 0)
self.assertEqual(results.failed, 0)
self.assertEqual(results.total, 0)
self.assertIsNone(results.earliest_task)

View File

@@ -4,10 +4,10 @@ from celery.exceptions import Retry
from django.test import TestCase, override_settings
from allianceauth.authentication.task_statistics.counters import (
failed_tasks,
retried_tasks,
succeeded_tasks,
from allianceauth.authentication.task_statistics.event_series import (
FailedTaskSeries,
RetriedTaskSeries,
SucceededTaskSeries,
)
from allianceauth.authentication.task_statistics.signals import (
reset_counters,
@@ -17,16 +17,15 @@ from allianceauth.eveonline.tasks import update_character
@override_settings(
CELERY_ALWAYS_EAGER=True,ALLIANCEAUTH_DASHBOARD_TASK_STATISTICS_DISABLED=False
CELERY_ALWAYS_EAGER=True, ALLIANCEAUTH_DASHBOARD_TASK_STATISTICS_DISABLED=False
)
class TestTaskSignals(TestCase):
fixtures = ["disable_analytics"]
def test_should_record_successful_task(self):
# given
succeeded_tasks.clear()
retried_tasks.clear()
failed_tasks.clear()
events = SucceededTaskSeries()
events.clear()
# when
with patch(
"allianceauth.eveonline.tasks.EveCharacter.objects.update_character"
@@ -34,15 +33,12 @@ class TestTaskSignals(TestCase):
mock_update.return_value = None
update_character.delay(1)
# then
self.assertEqual(succeeded_tasks.count(), 1)
self.assertEqual(retried_tasks.count(), 0)
self.assertEqual(failed_tasks.count(), 0)
self.assertEqual(events.count(), 1)
def test_should_record_retried_task(self):
# given
succeeded_tasks.clear()
retried_tasks.clear()
failed_tasks.clear()
events = RetriedTaskSeries()
events.clear()
# when
with patch(
"allianceauth.eveonline.tasks.EveCharacter.objects.update_character"
@@ -50,15 +46,12 @@ class TestTaskSignals(TestCase):
mock_update.side_effect = Retry
update_character.delay(1)
# then
self.assertEqual(succeeded_tasks.count(), 0)
self.assertEqual(failed_tasks.count(), 0)
self.assertEqual(retried_tasks.count(), 1)
self.assertEqual(events.count(), 1)
def test_should_record_failed_task(self):
# given
succeeded_tasks.clear()
retried_tasks.clear()
failed_tasks.clear()
events = FailedTaskSeries()
events.clear()
# when
with patch(
"allianceauth.eveonline.tasks.EveCharacter.objects.update_character"
@@ -66,21 +59,28 @@ class TestTaskSignals(TestCase):
mock_update.side_effect = RuntimeError
update_character.delay(1)
# then
self.assertEqual(succeeded_tasks.count(), 0)
self.assertEqual(retried_tasks.count(), 0)
self.assertEqual(failed_tasks.count(), 1)
self.assertEqual(events.count(), 1)
@override_settings(ALLIANCEAUTH_DASHBOARD_TASK_STATISTICS_DISABLED=False)
class TestResetCounters(TestCase):
def test_should_reset_counters(self):
# given
succeeded_tasks.add()
retried_tasks.add()
failed_tasks.add()
succeeded = SucceededTaskSeries()
succeeded.clear()
succeeded.add()
retried = RetriedTaskSeries()
retried.clear()
retried.add()
failed = FailedTaskSeries()
failed.clear()
failed.add()
# when
reset_counters()
# then
self.assertEqual(succeeded_tasks.count(), 0)
self.assertEqual(retried_tasks.count(), 0)
self.assertEqual(failed_tasks.count(), 0)
self.assertEqual(succeeded.count(), 0)
self.assertEqual(retried.count(), 0)
self.assertEqual(failed.count(), 0)
class TestIsEnabled(TestCase):

View File

@@ -7,8 +7,8 @@
<form class="form-signin" role="form" action="" method="POST">
{% csrf_token %}
{{ form|bootstrap }}
<br/>
<br>
<button class="btn btn-lg btn-primary btn-block" type="submit">{% translate "Submit" %}</button>
<br/>
<br>
</form>
{% endblock %}

View File

@@ -1,4 +1,17 @@
from django.db.models.signals import (
m2m_changed,
post_save,
pre_delete,
pre_save
)
from django.urls import reverse
from unittest import mock
MODULE_PATH = 'allianceauth.authentication'
def patch(target, *args, **kwargs):
return mock.patch(f'{MODULE_PATH}{target}', *args, **kwargs)
def get_admin_change_view_url(obj: object) -> str:

View File

@@ -2,8 +2,6 @@ from bs4 import BeautifulSoup
from urllib.parse import quote
from unittest.mock import patch, MagicMock
from django_webtest import WebTest
from django.contrib.admin.sites import AdminSite
from django.contrib.auth.models import Group
from django.test import TestCase, RequestFactory, Client
@@ -278,10 +276,10 @@ class TestOwnershipRecordAdmin(TestCaseWithTestData):
class TestStateAdmin(TestCaseWithTestData):
fixtures = ["disable_analytics"]
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
cls.modeladmin = StateAdmin(model=User, admin_site=AdminSite())
def setUp(self):
self.modeladmin = StateAdmin(
model=User, admin_site=AdminSite()
)
def test_change_view_loads_normally(self):
User.objects.create_superuser(
@@ -545,74 +543,7 @@ class TestUserAdmin(TestCaseWithTestData):
self.assertEqual(response.status_code, expected)
class TestStateAdminChangeFormSuperuserExclusiveEdits(WebTest):
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
cls.super_admin = User.objects.create_superuser("super_admin")
cls.staff_admin = User.objects.create_user("staff_admin")
cls.staff_admin.is_staff = True
cls.staff_admin.save()
cls.staff_admin = AuthUtils.add_permissions_to_user_by_name(
[
"authentication.add_state",
"authentication.change_state",
"authentication.view_state",
],
cls.staff_admin
)
cls.superuser_exclusive_fields = ["permissions",]
def test_should_show_all_fields_to_superuser_for_add(self):
# given
self.app.set_user(self.super_admin)
page = self.app.get("/admin/authentication/state/add/")
# when
form = page.forms["state_form"]
# then
for field in self.superuser_exclusive_fields:
with self.subTest(field=field):
self.assertIn(field, form.fields)
def test_should_not_show_all_fields_to_staff_admins_for_add(self):
# given
self.app.set_user(self.staff_admin)
page = self.app.get("/admin/authentication/state/add/")
# when
form = page.forms["state_form"]
# then
for field in self.superuser_exclusive_fields:
with self.subTest(field=field):
self.assertNotIn(field, form.fields)
def test_should_show_all_fields_to_superuser_for_change(self):
# given
self.app.set_user(self.super_admin)
state = AuthUtils.get_member_state()
page = self.app.get(f"/admin/authentication/state/{state.pk}/change/")
# when
form = page.forms["state_form"]
# then
for field in self.superuser_exclusive_fields:
with self.subTest(field=field):
self.assertIn(field, form.fields)
def test_should_not_show_all_fields_to_staff_admin_for_change(self):
# given
self.app.set_user(self.staff_admin)
state = AuthUtils.get_member_state()
page = self.app.get(f"/admin/authentication/state/{state.pk}/change/")
# when
form = page.forms["state_form"]
# then
for field in self.superuser_exclusive_fields:
with self.subTest(field=field):
self.assertNotIn(field, form.fields)
class TestUserAdminChangeForm(TestCase):
fixtures = ["disable_analytics"]
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
@@ -621,7 +552,7 @@ class TestUserAdminChangeForm(TestCase):
def test_should_show_groups_available_to_user_with_blue_state_only(self):
# given
superuser = User.objects.create_superuser("Super")
user = AuthUtils.create_user("bruce_wayne")
user = AuthUtils.create_user("Bruce Wayne")
character = AuthUtils.add_main_character_2(
user,
name="Bruce Wayne",
@@ -648,126 +579,6 @@ class TestUserAdminChangeForm(TestCase):
self.assertSetEqual(group_ids, {group_1.pk, group_2.pk})
class TestUserAdminChangeFormSuperuserExclusiveEdits(WebTest):
fixtures = ["disable_analytics"]
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
cls.super_admin = User.objects.create_superuser("super_admin")
cls.staff_admin = User.objects.create_user("staff_admin")
cls.staff_admin.is_staff = True
cls.staff_admin.save()
cls.staff_admin = AuthUtils.add_permissions_to_user_by_name(
[
"auth.change_user",
"auth.view_user",
"authentication.change_user",
"authentication.change_userprofile",
"authentication.view_user"
],
cls.staff_admin
)
cls.superuser_exclusive_fields = [
"is_staff", "is_superuser", "user_permissions"
]
def setUp(self) -> None:
self.user = AuthUtils.create_user("bruce_wayne")
def test_should_show_all_fields_to_superuser_for_change(self):
# given
self.app.set_user(self.super_admin)
page = self.app.get(f"/admin/authentication/user/{self.user.pk}/change/")
# when
form = page.forms["user_form"]
# then
for field in self.superuser_exclusive_fields:
with self.subTest(field=field):
self.assertIn(field, form.fields)
def test_should_not_show_all_fields_to_staff_admin_for_change(self):
# given
self.app.set_user(self.staff_admin)
page = self.app.get(f"/admin/authentication/user/{self.user.pk}/change/")
# when
form = page.forms["user_form"]
# then
for field in self.superuser_exclusive_fields:
with self.subTest(field=field):
self.assertNotIn(field, form.fields)
def test_should_allow_super_admin_to_add_restricted_group_to_user(self):
# given
self.app.set_user(self.super_admin)
group_restricted = Group.objects.create(name="restricted group")
group_restricted.authgroup.restricted = True
group_restricted.authgroup.save()
page = self.app.get(f"/admin/authentication/user/{self.user.pk}/change/")
form = page.forms["user_form"]
# when
form["groups"].select_multiple(texts=["restricted group"])
response = form.submit("save")
# then
self.assertEqual(response.status_code, 302)
self.user.refresh_from_db()
self.assertIn(
"restricted group", self.user.groups.values_list("name", flat=True)
)
def test_should_not_allow_staff_admin_to_add_restricted_group_to_user(self):
# given
self.app.set_user(self.staff_admin)
group_restricted = Group.objects.create(name="restricted group")
group_restricted.authgroup.restricted = True
group_restricted.authgroup.save()
page = self.app.get(f"/admin/authentication/user/{self.user.pk}/change/")
form = page.forms["user_form"]
# when
form["groups"].select_multiple(texts=["restricted group"])
response = form.submit("save")
# then
self.assertEqual(response.status_code, 200)
self.assertIn(
"You are not allowed to add or remove these restricted groups",
response.text
)
def test_should_not_allow_staff_admin_to_remove_restricted_group_from_user(self):
# given
self.app.set_user(self.staff_admin)
group_restricted = Group.objects.create(name="restricted group")
group_restricted.authgroup.restricted = True
group_restricted.authgroup.save()
self.user.groups.add(group_restricted)
page = self.app.get(f"/admin/authentication/user/{self.user.pk}/change/")
form = page.forms["user_form"]
# when
form["groups"].select_multiple(texts=[])
response = form.submit("save")
# then
self.assertEqual(response.status_code, 200)
self.assertIn(
"You are not allowed to add or remove these restricted groups",
response.text
)
def test_should_allow_staff_admin_to_add_normal_group_to_user(self):
# given
self.app.set_user(self.super_admin)
Group.objects.create(name="normal group")
page = self.app.get(f"/admin/authentication/user/{self.user.pk}/change/")
form = page.forms["user_form"]
# when
form["groups"].select_multiple(texts=["normal group"])
response = form.submit("save")
# then
self.assertEqual(response.status_code, 302)
self.user.refresh_from_db()
self.assertIn("normal group", self.user.groups.values_list("name", flat=True))
class TestMakeServicesHooksActions(TestCaseWithTestData):
class MyServicesHookTypeA(ServicesHook):

View File

@@ -0,0 +1,175 @@
from unittest import mock
from allianceauth.authentication.middleware import UserSettingsMiddleware
from unittest.mock import Mock
from django.http import HttpResponse
from django.test.testcases import TestCase
class TestUserSettingsMiddlewareSaveLang(TestCase):
def setUp(self):
self.middleware = UserSettingsMiddleware(HttpResponse)
self.request = Mock()
self.request.headers = {
"User-Agent": "AUTOMATED TEST"
}
self.request.path = '/i18n/setlang/'
self.request.POST = {
'language': 'fr'
}
self.request.user.profile.language = 'de'
self.request.user.is_anonymous = False
self.response = Mock()
self.response.content = 'hello world'
def test_middleware_passthrough(self):
"""
Simply tests the middleware runs cleanly
"""
response = self.middleware.process_response(
self.request,
self.response
)
self.assertEqual(self.response, response)
def test_middleware_save_language_false_anonymous(self):
"""
Ensures the middleware wont change the usersettings
of a non-existent (anonymous) user
"""
self.request.user.is_anonymous = True
response = self.middleware.process_response(
self.request,
self.response
)
self.assertEqual(self.request.user.profile.language, 'de')
self.assertFalse(self.request.user.profile.save.called)
self.assertEqual(self.request.user.profile.save.call_count, 0)
def test_middleware_save_language_new(self):
"""
does the middleware change a language not set in the DB
"""
self.request.user.profile.language = None
response = self.middleware.process_response(
self.request,
self.response
)
self.assertEqual(self.request.user.profile.language, 'fr')
self.assertTrue(self.request.user.profile.save.called)
self.assertEqual(self.request.user.profile.save.call_count, 1)
def test_middleware_save_language_changed(self):
"""
Tests the middleware will change a language setting
"""
response = self.middleware.process_response(
self.request,
self.response
)
self.assertEqual(self.request.user.profile.language, 'fr')
self.assertTrue(self.request.user.profile.save.called)
self.assertEqual(self.request.user.profile.save.call_count, 1)
class TestUserSettingsMiddlewareLoginFlow(TestCase):
def setUp(self):
self.middleware = UserSettingsMiddleware(HttpResponse)
self.request = Mock()
self.request.headers = {
"User-Agent": "AUTOMATED TEST"
}
self.request.path = '/sso/login'
self.request.session = {
'NIGHT_MODE': False
}
self.request.LANGUAGE_CODE = 'en'
self.request.user.profile.language = 'de'
self.request.user.profile.night_mode = True
self.request.user.is_anonymous = False
self.response = Mock()
self.response.content = 'hello world'
def test_middleware_passthrough(self):
"""
Simply tests the middleware runs cleanly
"""
middleware_response = self.middleware.process_response(
self.request,
self.response
)
self.assertEqual(self.response, middleware_response)
def test_middleware_sets_language_cookie_true_no_cookie(self):
"""
tests the middleware will set a cookie, while none is set
"""
self.request.LANGUAGE_CODE = None
middleware_response = self.middleware.process_response(
self.request,
self.response
)
self.assertTrue(middleware_response.set_cookie.called)
self.assertEqual(middleware_response.set_cookie.call_count, 1)
args, kwargs = middleware_response.set_cookie.call_args
self.assertEqual(kwargs['value'], 'de')
def test_middleware_sets_language_cookie_true_wrong_cookie(self):
"""
tests the middleware will set a cookie, while a different value is set
"""
middleware_response = self.middleware.process_response(
self.request,
self.response
)
self.assertTrue(middleware_response.set_cookie.called)
self.assertEqual(middleware_response.set_cookie.call_count, 1)
args, kwargs = middleware_response.set_cookie.call_args
self.assertEqual(kwargs['value'], 'de')
def test_middleware_sets_language_cookie_false_anonymous(self):
"""
ensures the middleware wont set a value for a non existent user (anonymous)
"""
self.request.user.is_anonymous = True
middleware_response = self.middleware.process_response(
self.request,
self.response
)
self.assertFalse = middleware_response.set_cookie.called
self.assertEqual(middleware_response.set_cookie.call_count, 0)
def test_middleware_sets_language_cookie_false_already_set(self):
"""
tests the middleware skips setting the cookie, if its already set correctly
"""
self.request.user.profile.language = 'en'
middleware_response = self.middleware.process_response(
self.request,
self.response
)
self.assertFalse = middleware_response.set_cookie.called
self.assertEqual(middleware_response.set_cookie.call_count, 0)
def test_middleware_sets_night_mode_not_set(self):
"""
tests the middleware will set night_mode if not set
"""
self.request.session = {}
response = self.middleware.process_response(
self.request,
self.response
)
self.assertEqual(self.request.session["NIGHT_MODE"], True)
def test_middleware_sets_night_mode_set(self):
"""
tests the middleware will set night_mode if set.
"""
response = self.middleware.process_response(
self.request,
self.response
)
self.assertEqual(self.request.session["NIGHT_MODE"], True)

View File

@@ -0,0 +1,94 @@
from allianceauth.authentication.models import User, UserProfile
from allianceauth.eveonline.models import (
EveCharacter,
EveCorporationInfo,
EveAllianceInfo
)
from django.db.models.signals import (
pre_save,
post_save,
pre_delete,
m2m_changed
)
from allianceauth.tests.auth_utils import AuthUtils
from django.test.testcases import TestCase
from unittest.mock import Mock
from . import patch
class TestUserProfileSignals(TestCase):
def setUp(self):
state = AuthUtils.get_member_state()
self.char = EveCharacter.objects.create(
character_id='1234',
character_name='test character',
corporation_id='2345',
corporation_name='test corp',
corporation_ticker='tickr',
alliance_id='3456',
alliance_name='alliance name',
)
self.alliance = EveAllianceInfo.objects.create(
alliance_id='3456',
alliance_name='alliance name',
alliance_ticker='TIKR',
executor_corp_id='2345',
)
self.corp = EveCorporationInfo.objects.create(
corporation_id='2345',
corporation_name='corp name',
corporation_ticker='TIKK',
member_count=10,
alliance=self.alliance,
)
state.member_alliances.add(self.alliance)
state.member_corporations.add(self.corp)
self.member = AuthUtils.create_user('test user')
self.member.profile.main_character = self.char
self.member.profile.save()
@patch('.signals.create_required_models')
def test_create_required_models_triggered_true(
self, create_required_models):
"""
Create a User object here,
to generate UserProfile models
"""
post_save.connect(create_required_models, sender=User)
AuthUtils.create_user('test_create_required_models_triggered')
self.assertTrue = create_required_models.called
self.assertEqual(create_required_models.call_count, 1)
user = User.objects.get(username='test_create_required_models_triggered')
self.assertIsNot(UserProfile.objects.get(user=user), False)
@patch('.signals.create_required_models')
def test_create_required_models_triggered_false(
self, create_required_models):
"""
Only call a User object Update here,
which does not need to generate UserProfile models
"""
post_save.connect(create_required_models, sender=User)
char = EveCharacter.objects.create(
character_id='1266',
character_name='test character2',
corporation_id='2345',
corporation_name='test corp',
corporation_ticker='tickr',
alliance_id='3456',
alliance_name='alliance name',
)
self.member.profile.main_character = char
self.member.profile.save()
self.assertTrue = create_required_models.called
self.assertEqual(create_required_models.call_count, 0)
self.assertIsNot(UserProfile.objects.get(user=self.member), False)

View File

@@ -1,5 +1,4 @@
from django.conf.urls import url
from django.contrib.auth.decorators import login_required
from django.urls import path
from django.views.generic.base import TemplateView
from . import views
@@ -7,21 +6,21 @@ from . import views
app_name = 'authentication'
urlpatterns = [
url(r'^$', views.index, name='index'),
url(
r'^account/login/$',
path('', views.index, name='index'),
path(
'account/login/',
TemplateView.as_view(template_name='public/login.html'),
name='login'
),
url(
r'^account/characters/main/$',
path(
'account/characters/main/',
views.main_character_change,
name='change_main_character'
),
url(
r'^account/characters/add/$',
path(
'account/characters/add/',
views.add_character,
name='add_character'
),
url(r'^dashboard/$', views.dashboard, name='dashboard'),
path('dashboard/', views.dashboard, name='dashboard'),
]

View File

@@ -1,5 +1,5 @@
from allianceauth.services.hooks import MenuItemHook, UrlHook
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import gettext_lazy as _
from allianceauth import hooks
from allianceauth.corputils import urls

View File

@@ -1,12 +1,11 @@
from django.conf.urls import url
from django.urls import path
from . import views
app_name = 'corputils'
urlpatterns = [
url(r'^$', views.corpstats_view, name='view'),
url(r'^add/$', views.corpstats_add, name='add'),
url(r'^(?P<corp_id>(\d)*)/$', views.corpstats_view, name='view_corp'),
url(r'^(?P<corp_id>(\d)+)/update/$', views.corpstats_update, name='update'),
url(r'^search/$', views.corpstats_search, name='search'),
]
path('', views.corpstats_view, name='view'),
path('add/', views.corpstats_add, name='add'),
path('<int:corp_id>/', views.corpstats_view, name='view_corp'),
path('<int:corp_id>/update/', views.corpstats_update, name='update'),
path('search/', views.corpstats_search, name='search'),
]

View File

@@ -6,7 +6,7 @@ from django.contrib.auth.decorators import login_required, permission_required,
from django.core.exceptions import PermissionDenied, ObjectDoesNotExist
from django.db import IntegrityError
from django.shortcuts import render, redirect, get_object_or_404
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import gettext_lazy as _
from esi.decorators import token_required
from allianceauth.eveonline.models import EveCharacter, EveCorporationInfo

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.2.10 on 2022-01-05 18:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('eveonline', '0015_factions'),
]
operations = [
migrations.AlterField(
model_name='evecharacter',
name='character_name',
field=models.CharField(db_index=True, max_length=254),
),
]

View File

@@ -25,6 +25,8 @@ DOOMHEIM_CORPORATION_ID = 1000001
class EveFactionInfo(models.Model):
"""A faction in Eve Online."""
faction_id = models.PositiveIntegerField(unique=True, db_index=True)
faction_name = models.CharField(max_length=254, unique=True)
@@ -66,6 +68,8 @@ class EveFactionInfo(models.Model):
class EveAllianceInfo(models.Model):
"""An alliance in Eve Online."""
alliance_id = models.PositiveIntegerField(unique=True)
alliance_name = models.CharField(max_length=254, unique=True)
alliance_ticker = models.CharField(max_length=254)
@@ -132,6 +136,8 @@ class EveAllianceInfo(models.Model):
class EveCorporationInfo(models.Model):
"""A corporation in Eve Online."""
corporation_id = models.PositiveIntegerField(unique=True)
corporation_name = models.CharField(max_length=254, unique=True)
corporation_ticker = models.CharField(max_length=254)
@@ -195,9 +201,10 @@ class EveCorporationInfo(models.Model):
class EveCharacter(models.Model):
"""Character in Eve Online"""
"""A character in Eve Online."""
character_id = models.PositiveIntegerField(unique=True)
character_name = models.CharField(max_length=254, unique=True)
character_name = models.CharField(max_length=254, db_index=True)
corporation_id = models.PositiveIntegerField()
corporation_name = models.CharField(max_length=254)
corporation_ticker = models.CharField(max_length=5)

View File

@@ -40,7 +40,7 @@ def update_character(character_id: int) -> None:
def run_model_update():
"""Update all alliances, corporations and characters from ESI"""
# update existing corp models
#update existing corp models
for corp in EveCorporationInfo.objects.all().values('corporation_id'):
update_corp.apply_async(args=[corp['corporation_id']], priority=TASK_PRIORITY)

View File

@@ -1,5 +1,5 @@
from . import urls
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import gettext_lazy as _
from allianceauth import hooks
from allianceauth.services.hooks import MenuItemHook, UrlHook

View File

@@ -1,5 +1,5 @@
from django import forms
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import gettext_lazy as _
class FatlinkForm(forms.Form):

View File

@@ -21,7 +21,7 @@
<form class="form-signin" role="form" action="" method="POST">
{% csrf_token %}
{{ form|bootstrap }}
<br/>
<br>
<button class="btn btn-lg btn-primary btn-block" type="submit" name="submit_fat">{% translate "Create fatlink" %}</button>
</form>
</div>

View File

@@ -1,30 +1,30 @@
from django.conf.urls import url
from django.urls import path
from . import views
app_name = 'fleetactivitytracking'
urlpatterns = [
# FleetActivityTracking (FAT)
url(r'^$', views.fatlink_view, name='view'),
url(r'^statistics/$', views.fatlink_statistics_view, name='statistics'),
url(r'^statistics/corp/(\w+)$', views.fatlink_statistics_corp_view,
path('', views.fatlink_view, name='view'),
path('statistics/', views.fatlink_statistics_view, name='statistics'),
path('statistics/corp/<int:corpid>/', views.fatlink_statistics_corp_view,
name='statistics_corp'),
url(r'^statistics/corp/(?P<corpid>\w+)/(?P<year>[0-9]+)/(?P<month>[0-9]+)/',
path('statistics/corp/<int:corpid>/<int:year>/<int:month>/',
views.fatlink_statistics_corp_view,
name='statistics_corp_month'),
url(r'^statistics/(?P<year>[0-9]+)/(?P<month>[0-9]+)/$', views.fatlink_statistics_view,
path('statistics/<int:year>/<int:month>/', views.fatlink_statistics_view,
name='statistics_month'),
url(r'^user/statistics/$', views.fatlink_personal_statistics_view,
path('user/statistics/', views.fatlink_personal_statistics_view,
name='personal_statistics'),
url(r'^user/statistics/(?P<year>[0-9]+)/$', views.fatlink_personal_statistics_view,
path('user/statistics/<int:year>/', views.fatlink_personal_statistics_view,
name='personal_statistics_year'),
url(r'^user/statistics/(?P<year>[0-9]+)/(?P<month>[0-9]+)/$',
path('user/statistics/<int:year>/<int:month>/',
views.fatlink_monthly_personal_statistics_view,
name='personal_statistics_month'),
url(r'^user/(?P<char_id>[0-9]+)/statistics/(?P<year>[0-9]+)/(?P<month>[0-9]+)/$',
path('user/<int:char_id>/statistics/<int:year>/<int:month>/',
views.fatlink_monthly_personal_statistics_view,
name='user_statistics_month'),
url(r'^create/$', views.create_fatlink_view, name='create'),
url(r'^modify/(?P<fat_hash>[a-zA-Z0-9_-]+)/$', views.modify_fatlink_view, name='modify'),
url(r'^link/(?P<fat_hash>[a-zA-Z0-9]+)/$', views.click_fatlink_view, name='click'),
path('create/', views.create_fatlink_view, name='create'),
path('modify/<str:fat_hash>/', views.modify_fatlink_view, name='modify'),
path('link/<str:fat_hash>/', views.click_fatlink_view, name='click'),
]

View File

@@ -10,7 +10,7 @@ from django.contrib.auth.models import User
from django.core.exceptions import ValidationError, ObjectDoesNotExist
from django.shortcuts import render, redirect, get_object_or_404, Http404
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import gettext_lazy as _
from esi.decorators import token_required
from allianceauth.eveonline.providers import provider
from .forms import FatlinkForm
@@ -212,14 +212,7 @@ def fatlink_monthly_personal_statistics_view(request, year, month, char_id=None)
start_of_previous_month = first_day_of_previous_month(year, month)
if request.user.has_perm('auth.fleetactivitytracking_statistics') and char_id:
try:
user = EveCharacter.objects.get(character_id=char_id).character_ownership.user
except EveCharacter.DoesNotExist:
messages.error(request, _('Character does not exist'))
return redirect('fatlink:view')
except AttributeError:
messages.error(request, _('User does not exist'))
return redirect('fatlink:view')
user = EveCharacter.objects.get(character_id=char_id).user
else:
user = request.user
logger.debug(f"Personal monthly statistics view for user {user} called by {request.user}")

View File

@@ -1,21 +1,19 @@
from django import forms
from django.apps import apps
from django.contrib.auth.models import Permission
from django.contrib import admin
from django.contrib.auth.models import Group as BaseGroup, Permission, User
from django.db.models import Count, Exists, OuterRef
from django.contrib.auth.models import Group as BaseGroup, User
from django.core.exceptions import ValidationError
from django.db.models import Count
from django.db.models.functions import Lower
from django.db.models.signals import (
m2m_changed,
post_delete,
post_save,
pre_delete,
pre_save
)
from django.db.models.signals import pre_save, post_save, pre_delete, \
post_delete, m2m_changed
from django.dispatch import receiver
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from .forms import GroupAdminForm, ReservedGroupNameAdminForm
from .models import AuthGroup, GroupRequest, ReservedGroupName
from .tasks import remove_users_not_matching_states_from_group
from .models import AuthGroup, ReservedGroupName
from .models import GroupRequest
if 'eve_autogroups' in apps.app_configs:
_has_auto_groups = True
@@ -30,12 +28,10 @@ class AuthGroupInlineAdmin(admin.StackedInline):
'description',
'group_leaders',
'group_leader_groups',
'states',
'internal',
'states', 'internal',
'hidden',
'open',
'public',
'restricted',
'public'
)
verbose_name_plural = 'Auth Settings'
verbose_name = ''
@@ -54,11 +50,6 @@ class AuthGroupInlineAdmin(admin.StackedInline):
def has_change_permission(self, request, obj=None):
return request.user.has_perm('auth.change_group')
def get_readonly_fields(self, request, obj=None):
if not request.user.is_superuser:
return self.readonly_fields + ("restricted",)
return self.readonly_fields
if _has_auto_groups:
class IsAutoGroupFilter(admin.SimpleListFilter):
@@ -105,15 +96,27 @@ class HasLeaderFilter(admin.SimpleListFilter):
return queryset
class GroupAdminForm(forms.ModelForm):
def clean_name(self):
my_name = self.cleaned_data['name']
if ReservedGroupName.objects.filter(name__iexact=my_name).exists():
raise ValidationError(
_("This name has been reserved and can not be used for groups."),
code='reserved_name'
)
return my_name
class GroupAdmin(admin.ModelAdmin):
form = GroupAdminForm
list_select_related = ('authgroup',)
ordering = ('name',)
list_display = (
'name',
'_description',
'_properties',
'_member_count',
'has_leader',
'has_leader'
)
list_filter = [
'authgroup__internal',
@@ -129,51 +132,34 @@ class GroupAdmin(admin.ModelAdmin):
def get_queryset(self, request):
qs = super().get_queryset(request)
has_leader_qs = (
AuthGroup.objects.filter(group=OuterRef('pk'), group_leaders__isnull=False)
)
has_leader_groups_qs = (
AuthGroup.objects.filter(
group=OuterRef('pk'), group_leader_groups__isnull=False
)
)
qs = (
qs.select_related('authgroup')
.annotate(member_count=Count('user', distinct=True))
.annotate(has_leader=Exists(has_leader_qs))
.annotate(has_leader_groups=Exists(has_leader_groups_qs))
)
if _has_auto_groups:
is_autogroup_corp = (
Group.objects.filter(
pk=OuterRef('pk'), managedcorpgroup__isnull=False
)
)
is_autogroup_alliance = (
Group.objects.filter(
pk=OuterRef('pk'), managedalliancegroup__isnull=False
)
)
qs = (
qs.annotate(is_autogroup_corp=Exists(is_autogroup_corp))
.annotate(is_autogroup_alliance=Exists(is_autogroup_alliance))
)
qs = qs.prefetch_related('managedalliancegroup_set', 'managedcorpgroup_set')
qs = qs.prefetch_related('authgroup__group_leaders').select_related('authgroup')
qs = qs.annotate(
member_count=Count('user', distinct=True),
)
return qs
def _description(self, obj):
return obj.authgroup.description
@admin.display(description='Members', ordering='member_count')
def _member_count(self, obj):
return obj.member_count
@admin.display(boolean=True)
_member_count.short_description = 'Members'
_member_count.admin_order_field = 'member_count'
def has_leader(self, obj):
return obj.has_leader or obj.has_leader_groups
return obj.authgroup.group_leaders.exists() or obj.authgroup.group_leader_groups.exists()
has_leader.boolean = True
def _properties(self, obj):
properties = list()
if _has_auto_groups and (obj.is_autogroup_corp or obj.is_autogroup_alliance):
if _has_auto_groups and (
obj.managedalliancegroup_set.exists()
or obj.managedcorpgroup_set.exists()
):
properties.append('Auto Group')
elif obj.authgroup.internal:
properties.append('Internal')
@@ -186,10 +172,11 @@ class GroupAdmin(admin.ModelAdmin):
properties.append('Public')
if not properties:
properties.append('Default')
if obj.authgroup.restricted:
properties.append('Restricted')
return properties
_properties.short_description = "properties"
filter_horizontal = ('permissions',)
inlines = (AuthGroupInlineAdmin,)
@@ -203,15 +190,8 @@ class GroupAdmin(admin.ModelAdmin):
ag_instance = inline_form.save(commit=False)
ag_instance.group = form.instance
ag_instance.save()
if ag_instance.states.exists():
remove_users_not_matching_states_from_group.delay(ag_instance.group.pk)
formset.save()
def get_readonly_fields(self, request, obj=None):
if not request.user.is_superuser:
return self.readonly_fields + ("permissions",)
return self.readonly_fields
class Group(BaseGroup):
class Meta:
@@ -236,10 +216,33 @@ class GroupRequestAdmin(admin.ModelAdmin):
'leave_request',
)
@admin.display(boolean=True, description="is leave request")
def _leave_request(self, obj) -> True:
return obj.leave_request
_leave_request.short_description = 'is leave request'
_leave_request.boolean = True
class ReservedGroupNameAdminForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['created_by'].initial = self.current_user.username
self.fields['created_at'].initial = _("(auto)")
created_by = forms.CharField(disabled=True)
created_at = forms.CharField(disabled=True)
def clean_name(self):
my_name = self.cleaned_data['name'].lower()
if Group.objects.filter(name__iexact=my_name).exists():
raise ValidationError(
_("There already exists a group with that name."), code='already_exists'
)
return my_name
def clean_created_at(self):
return now()
@admin.register(ReservedGroupName)
class ReservedGroupNameAdmin(admin.ModelAdmin):

View File

@@ -1,4 +1,4 @@
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import gettext_lazy as _
from allianceauth.services.hooks import MenuItemHook, UrlHook
from allianceauth import hooks

View File

@@ -1,39 +0,0 @@
from django import forms
from django.contrib.auth.models import Group
from django.core.exceptions import ValidationError
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from .models import ReservedGroupName
class GroupAdminForm(forms.ModelForm):
def clean_name(self):
my_name = self.cleaned_data['name']
if ReservedGroupName.objects.filter(name__iexact=my_name).exists():
raise ValidationError(
_("This name has been reserved and can not be used for groups."),
code='reserved_name'
)
return my_name
class ReservedGroupNameAdminForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['created_by'].initial = self.current_user.username
self.fields['created_at'].initial = _("(auto)")
created_by = forms.CharField(disabled=True)
created_at = forms.CharField(disabled=True)
def clean_name(self):
my_name = self.cleaned_data['name'].lower()
if Group.objects.filter(name__iexact=my_name).exists():
raise ValidationError(
_("There already exists a group with that name."), code='already_exists'
)
return my_name
def clean_created_at(self):
return now()

View File

@@ -1,18 +0,0 @@
# Generated by Django 3.2.10 on 2022-04-08 19:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('groupmanagement', '0018_reservedgroupname'),
]
operations = [
migrations.AddField(
model_name='authgroup',
name='restricted',
field=models.BooleanField(default=False, help_text='Group is restricted. This means that adding or removing users for this group requires a superuser admin.'),
),
]

View File

@@ -13,7 +13,6 @@ from allianceauth.notifications import notify
class GroupRequest(models.Model):
"""Request from a user for joining or leaving a group."""
leave_request = models.BooleanField(default=0)
user = models.ForeignKey(User, on_delete=models.CASCADE)
group = models.ForeignKey(Group, on_delete=models.CASCADE)
@@ -45,7 +44,6 @@ class GroupRequest(models.Model):
class RequestLog(models.Model):
"""Log entry about who joined and left a group and who approved it."""
request_type = models.BooleanField(null=True)
group = models.ForeignKey(Group, on_delete=models.CASCADE)
request_info = models.CharField(max_length=254)
@@ -97,7 +95,6 @@ class AuthGroup(models.Model):
Open - Users are automatically accepted into the group
Not Open - Users requests must be approved before they are added to the group
"""
group = models.OneToOneField(Group, on_delete=models.CASCADE, primary_key=True)
internal = models.BooleanField(
default=True,
@@ -129,13 +126,6 @@ class AuthGroup(models.Model):
"are no longer authenticated."
)
)
restricted = models.BooleanField(
default=False,
help_text=_(
"Group is restricted. This means that adding or removing users "
"for this group requires a superuser admin."
)
)
group_leaders = models.ManyToManyField(
User,
related_name='leads_groups',
@@ -189,22 +179,12 @@ class AuthGroup(models.Model):
| User.objects.filter(groups__in=list(self.group_leader_groups.all()))
)
def remove_users_not_matching_states(self):
"""Remove users not matching defined states from related group."""
states_qs = self.states.all()
if states_qs.exists():
states = list(states_qs)
non_compliant_users = self.group.user_set.exclude(profile__state__in=states)
for user in non_compliant_users:
self.group.user_set.remove(user)
class ReservedGroupName(models.Model):
"""Name that can not be used for groups.
This enables AA to ignore groups on other services (e.g. Discord) with that name.
"""
name = models.CharField(
_('name'),
max_length=150,

View File

@@ -1,10 +0,0 @@
from celery import shared_task
from django.contrib.auth.models import Group
@shared_task
def remove_users_not_matching_states_from_group(group_pk: int) -> None:
"""Remove users not matching defined states from related group."""
group = Group.objects.get(pk=group_pk)
group.authgroup.remove_users_not_matching_states()

View File

@@ -1,21 +1,17 @@
from unittest.mock import patch
from django_webtest import WebTest
from django.conf import settings
from django.contrib import admin
from django.contrib.admin.sites import AdminSite
from django.contrib.auth.models import User
from django.test import TestCase, RequestFactory, Client, override_settings
from django.test import TestCase, RequestFactory, Client
from allianceauth.authentication.models import CharacterOwnership, State
from allianceauth.eveonline.models import (
EveCharacter, EveCorporationInfo, EveAllianceInfo
)
from allianceauth.tests.auth_utils import AuthUtils
from . import get_admin_change_view_url
from ..admin import HasLeaderFilter, GroupAdmin, Group
from . import get_admin_change_view_url
from ..models import ReservedGroupName
@@ -37,6 +33,7 @@ class MockRequest:
class TestGroupAdmin(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
@@ -236,104 +233,60 @@ class TestGroupAdmin(TestCase):
self.assertEqual(result, expected)
def test_member_count(self):
# given
request = MockRequest(user=self.user_1)
obj = self.modeladmin.get_queryset(request).get(pk=self.group_1.pk)
# when
expected = 1
obj = self.modeladmin.get_queryset(MockRequest(user=self.user_1))\
.get(pk=self.group_1.pk)
result = self.modeladmin._member_count(obj)
# then
self.assertEqual(result, 1)
self.assertEqual(result, expected)
def test_has_leader_user(self):
# given
request = MockRequest(user=self.user_1)
obj = self.modeladmin.get_queryset(request).get(pk=self.group_1.pk)
# when
result = self.modeladmin.has_leader(obj)
# then
result = self.modeladmin.has_leader(self.group_1)
self.assertTrue(result)
def test_has_leader_group(self):
# given
request = MockRequest(user=self.user_1)
obj = self.modeladmin.get_queryset(request).get(pk=self.group_2.pk)
# when
result = self.modeladmin.has_leader(obj)
# then
result = self.modeladmin.has_leader(self.group_2)
self.assertTrue(result)
def test_properties_1(self):
# given
request = MockRequest(user=self.user_1)
obj = self.modeladmin.get_queryset(request).get(pk=self.group_1.pk)
# when
result = self.modeladmin._properties(obj)
self.assertListEqual(result, ['Default'])
expected = ['Default']
result = self.modeladmin._properties(self.group_1)
self.assertListEqual(result, expected)
def test_properties_2(self):
# given
request = MockRequest(user=self.user_1)
obj = self.modeladmin.get_queryset(request).get(pk=self.group_2.pk)
# when
result = self.modeladmin._properties(obj)
self.assertListEqual(result, ['Internal'])
expected = ['Internal']
result = self.modeladmin._properties(self.group_2)
self.assertListEqual(result, expected)
def test_properties_3(self):
# given
request = MockRequest(user=self.user_1)
obj = self.modeladmin.get_queryset(request).get(pk=self.group_3.pk)
# when
result = self.modeladmin._properties(obj)
self.assertListEqual(result, ['Hidden'])
expected = ['Hidden']
result = self.modeladmin._properties(self.group_3)
self.assertListEqual(result, expected)
def test_properties_4(self):
# given
request = MockRequest(user=self.user_1)
obj = self.modeladmin.get_queryset(request).get(pk=self.group_4.pk)
# when
result = self.modeladmin._properties(obj)
self.assertListEqual(result, ['Open'])
expected = ['Open']
result = self.modeladmin._properties(self.group_4)
self.assertListEqual(result, expected)
def test_properties_5(self):
# given
request = MockRequest(user=self.user_1)
obj = self.modeladmin.get_queryset(request).get(pk=self.group_5.pk)
# when
result = self.modeladmin._properties(obj)
self.assertListEqual(result, ['Public'])
expected = ['Public']
result = self.modeladmin._properties(self.group_5)
self.assertListEqual(result, expected)
def test_properties_6(self):
# given
request = MockRequest(user=self.user_1)
obj = self.modeladmin.get_queryset(request).get(pk=self.group_6.pk)
# when
result = self.modeladmin._properties(obj)
self.assertListEqual(result, ['Hidden', 'Open', 'Public'])
expected = ['Hidden', 'Open', 'Public']
result = self.modeladmin._properties(self.group_6)
self.assertListEqual(result, expected)
if _has_auto_groups:
@patch(MODULE_PATH + '._has_auto_groups', True)
def test_should_show_autogroup_for_corporation(self):
# given
def test_properties_7(self):
self._create_autogroups()
request = MockRequest(user=self.user_1)
queryset = self.modeladmin.get_queryset(request)
obj = queryset.filter(managedcorpgroup__isnull=False).first()
# when
result = self.modeladmin._properties(obj)
# then
self.assertListEqual(result, ['Auto Group'])
@patch(MODULE_PATH + '._has_auto_groups', True)
def test_should_show_autogroup_for_alliance(self):
# given
self._create_autogroups()
request = MockRequest(user=self.user_1)
queryset = self.modeladmin.get_queryset(request)
obj = queryset.filter(managedalliancegroup__isnull=False).first()
# when
result = self.modeladmin._properties(obj)
# then
self.assertListEqual(result, ['Auto Group'])
expected = ['Auto Group']
my_group = Group.objects\
.filter(managedcorpgroup__isnull=False)\
.first()
result = self.modeladmin._properties(my_group)
self.assertListEqual(result, expected)
# actions
@@ -515,136 +468,6 @@ class TestGroupAdmin(TestCase):
self.assertFalse(Group.objects.filter(name="new group").exists())
class TestGroupAdminChangeFormSuperuserExclusiveEdits(WebTest):
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
cls.super_admin = User.objects.create_superuser("super_admin")
cls.staff_admin = User.objects.create_user("staff_admin")
cls.staff_admin.is_staff = True
cls.staff_admin.save()
cls.staff_admin = AuthUtils.add_permissions_to_user_by_name(
[
"auth.add_group",
"auth.change_group",
"auth.view_group",
"groupmanagement.add_group",
"groupmanagement.change_group",
"groupmanagement.view_group",
],
cls.staff_admin
)
cls.superuser_exclusive_fields = ["permissions", "authgroup-0-restricted"]
def test_should_show_all_fields_to_superuser_for_add(self):
# given
self.app.set_user(self.super_admin)
page = self.app.get("/admin/groupmanagement/group/add/")
# when
form = page.forms["group_form"]
# then
for field in self.superuser_exclusive_fields:
with self.subTest(field=field):
self.assertIn(field, form.fields)
def test_should_not_show_all_fields_to_staff_admins_for_add(self):
# given
self.app.set_user(self.staff_admin)
page = self.app.get("/admin/groupmanagement/group/add/")
# when
form = page.forms["group_form"]
# then
for field in self.superuser_exclusive_fields:
with self.subTest(field=field):
self.assertNotIn(field, form.fields)
def test_should_show_all_fields_to_superuser_for_change(self):
# given
self.app.set_user(self.super_admin)
group = Group.objects.create(name="Dummy group")
page = self.app.get(f"/admin/groupmanagement/group/{group.pk}/change/")
# when
form = page.forms["group_form"]
# then
for field in self.superuser_exclusive_fields:
with self.subTest(field=field):
self.assertIn(field, form.fields)
def test_should_not_show_all_fields_to_staff_admin_for_change(self):
# given
self.app.set_user(self.staff_admin)
group = Group.objects.create(name="Dummy group")
page = self.app.get(f"/admin/groupmanagement/group/{group.pk}/change/")
# when
form = page.forms["group_form"]
# then
for field in self.superuser_exclusive_fields:
with self.subTest(field=field):
self.assertNotIn(field, form.fields)
@override_settings(CELERY_ALWAYS_EAGER=True, CELERY_EAGER_PROPAGATES_EXCEPTIONS=True)
class TestGroupAdmin2(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.superuser = User.objects.create_superuser("super")
def test_should_remove_users_from_state_groups(self):
# given
user_member = AuthUtils.create_user("Bruce Wayne")
character_member = AuthUtils.add_main_character_2(
user_member,
name="Bruce Wayne",
character_id=1001,
corp_id=2001,
corp_name="Wayne Technologies",
)
user_guest = AuthUtils.create_user("Lex Luthor")
AuthUtils.add_main_character_2(
user_guest,
name="Lex Luthor",
character_id=1011,
corp_id=2011,
corp_name="Luthor Corp",
)
member_state = AuthUtils.get_member_state()
member_state.member_characters.add(character_member)
user_member.refresh_from_db()
user_guest.refresh_from_db()
group = Group.objects.create(name="dummy")
user_member.groups.add(group)
user_guest.groups.add(group)
group.authgroup.states.add(member_state)
self.client.force_login(self.superuser)
# when
response = self.client.post(
f"/admin/groupmanagement/group/{group.pk}/change/",
data={
"name": f"{group.name}",
"authgroup-TOTAL_FORMS": "1",
"authgroup-INITIAL_FORMS": "1",
"authgroup-MIN_NUM_FORMS": "0",
"authgroup-MAX_NUM_FORMS": "1",
"authgroup-0-description": "",
"authgroup-0-states": f"{member_state.pk}",
"authgroup-0-internal": "on",
"authgroup-0-hidden": "on",
"authgroup-0-group": f"{group.pk}",
"authgroup-__prefix__-description": "",
"authgroup-__prefix__-internal": "on",
"authgroup-__prefix__-hidden": "on",
"authgroup-__prefix__-group": f"{group.pk}",
"_save": "Save"
}
)
# then
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, "/admin/groupmanagement/group/")
self.assertIn(group, user_member.groups.all())
self.assertNotIn(group, user_guest.groups.all())
class TestReservedGroupNameAdmin(TestCase):
@classmethod
def setUpClass(cls):

View File

@@ -232,38 +232,6 @@ class TestAuthGroup(TestCase):
expected = 'Superheros'
self.assertEqual(str(group.authgroup), expected)
def test_should_remove_guests_from_group_when_restricted_to_members_only(self):
# given
user_member = AuthUtils.create_user("Bruce Wayne")
character_member = AuthUtils.add_main_character_2(
user_member,
name="Bruce Wayne",
character_id=1001,
corp_id=2001,
corp_name="Wayne Technologies",
)
user_guest = AuthUtils.create_user("Lex Luthor")
AuthUtils.add_main_character_2(
user_guest,
name="Lex Luthor",
character_id=1011,
corp_id=2011,
corp_name="Luthor Corp",
)
member_state = AuthUtils.get_member_state()
member_state.member_characters.add(character_member)
user_member.refresh_from_db()
user_guest.refresh_from_db()
group = Group.objects.create(name="dummy")
user_member.groups.add(group)
user_guest.groups.add(group)
group.authgroup.states.add(member_state)
# when
group.authgroup.remove_users_not_matching_states()
# then
self.assertIn(group, user_member.groups.all())
self.assertNotIn(group, user_guest.groups.all())
class TestAuthGroupRequestApprovers(TestCase):
def setUp(self) -> None:

View File

@@ -1,51 +1,50 @@
from django.urls import path
from . import views
from django.conf.urls import url
app_name = "groupmanagement"
urlpatterns = [
# groups
url(r"^groups/$", views.groups_view, name="groups"),
url(r"^group/request/join/(\w+)/$", views.group_request_add, name="request_add"),
url(
r"^group/request/leave/(\w+)/$", views.group_request_leave, name="request_leave"
path("groups", views.groups_view, name="groups"),
path("group/request/join/<int:group_id>/", views.group_request_add, name="request_add"),
path(
"group/request/leave/<int:group_id>/", views.group_request_leave, name="request_leave"
),
# group management
url(r"^groupmanagement/requests/$", views.group_management, name="management"),
url(r"^groupmanagement/membership/$", views.group_membership, name="membership"),
url(
r"^groupmanagement/membership/(\w+)/$",
path("groupmanagement/requests/", views.group_management, name="management"),
path("groupmanagement/membership/", views.group_membership, name="membership"),
path(
"groupmanagement/membership/<int:group_id>/",
views.group_membership_list,
name="membership",
),
url(
r"^groupmanagement/membership/(\w+)/audit-log/$",
path(
"groupmanagement/membership/<int:group_id>/audit-log/",
views.group_membership_audit,
name="audit_log",
),
url(
r"^groupmanagement/membership/(\w+)/remove/(\w+)/$",
path(
"groupmanagement/membership/<int:group_id>/remove/<int:user_id>/",
views.group_membership_remove,
name="membership_remove",
),
url(
r"^groupmanagement/request/join/accept/(\w+)/$",
path(
"groupmanagement/request/join/accept/<int:group_request_id>/",
views.group_accept_request,
name="accept_request",
),
url(
r"^groupmanagement/request/join/reject/(\w+)/$",
path(
"groupmanagement/request/join/reject/<int:group_request_id>/",
views.group_reject_request,
name="reject_request",
),
url(
r"^groupmanagement/request/leave/accept/(\w+)/$",
path(
"groupmanagement/request/leave/accept/<int:group_request_id>/",
views.group_leave_accept_request,
name="leave_accept_request",
),
url(
r"^groupmanagement/request/leave/reject/(\w+)/$",
path(
"groupmanagement/request/leave/reject/<int:group_request_id>/",
views.group_leave_reject_request,
name="leave_reject_request",
),

View File

@@ -9,7 +9,7 @@ from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db.models import Count
from django.http import Http404
from django.shortcuts import render, redirect, get_object_or_404
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import gettext_lazy as _
from allianceauth.notifications import notify

View File

@@ -1,4 +1,4 @@
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import gettext_lazy as _
from allianceauth import hooks
from allianceauth.services.hooks import MenuItemHook, UrlHook

View File

@@ -1,5 +1,5 @@
from django import forms
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import gettext_lazy as _
class HRApplicationCommentForm(forms.Form):

View File

@@ -19,8 +19,8 @@
<div cass="text-center">{{ question.help_text }}</div>
{% endif %}
{% 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 }}" />
<label for="choice{{ forloop.counter }}">{{ choice.choice_text }}</label><br />
<input type={% if question.multi_select == False %}"radio"{% else %}"checkbox"{% endif %} name="{{ question.pk }}" id="id_{{ question.pk }}" value="{{ choice.choice_text }}">
<label for="choice{{ forloop.counter }}">{{ choice.choice_text }}</label><br>
{% empty %}
<textarea class="form-control" cols="30" id="id_{{ question.pk }}" name="{{ question.pk }}" rows="4"></textarea>
{% endfor %}

View File

@@ -181,7 +181,7 @@
<form class="form-signin" role="form" action={% url 'hrapplications:search' %} method="POST">
{% csrf_token %}
{{ search_form|bootstrap }}
<br/>
<br>
<button class="btn btn-lg btn-primary btn-block" type="submit">{% translate "Search" %}</button>
</form>
</div>

View File

@@ -67,7 +67,7 @@
<form class="form-signin" role="form" action={% url 'hrapplications:search' %} method="POST">
{% csrf_token %}
{{ search_form|bootstrap }}
<br/>
<br>
<button class="btn btn-lg btn-primary btn-block" type="submit">{% translate "Search" %}</button>
</form>
</div>

View File

@@ -140,7 +140,7 @@
<form class="form-signin" role="form" action="" method="POST">
{% csrf_token %}
{{ comment_form|bootstrap }}
<br/>
<br>
<button class="btn btn-lg btn-primary btn-block" type="submit">{% translate "Add Comment" %}</button>
</form>
</div>

View File

@@ -1,31 +1,31 @@
from django.conf.urls import url
from django.urls import path
from . import views
app_name = 'hrapplications'
urlpatterns = [
url(r'^$', views.hr_application_management_view,
path('', views.hr_application_management_view,
name="index"),
url(r'^create/$', views.hr_application_create_view,
path('create/', views.hr_application_create_view,
name="create_view"),
url(r'^create/(\d+)', views.hr_application_create_view,
path('create/<int:form_id>/', views.hr_application_create_view,
name="create_view"),
url(r'^remove/(\w+)', views.hr_application_remove,
path('remove/<int:app_id>/', views.hr_application_remove,
name="remove"),
url(r'^view/(\w+)', views.hr_application_view,
path('view/<int:app_id>/', views.hr_application_view,
name="view"),
url(r'^personal/view/(\w+)', views.hr_application_personal_view,
path('personal/view/<int:app_id>/', views.hr_application_personal_view,
name="personal_view"),
url(r'^personal/removal/(\w+)',
path('personal/removal/<int:app_id>/',
views.hr_application_personal_removal,
name="personal_removal"),
url(r'^approve/(\w+)', views.hr_application_approve,
path('approve/<int:app_id>/', views.hr_application_approve,
name="approve"),
url(r'^reject/(\w+)', views.hr_application_reject,
path('reject/<int:app_id>/', views.hr_application_reject,
name="reject"),
url(r'^search/', views.hr_application_search,
path('search/', views.hr_application_search,
name="search"),
url(r'^mark_in_progress/(\w+)', views.hr_application_mark_in_progress,
path('mark_in_progress/<int:app_id>/', views.hr_application_mark_in_progress,
name="mark_in_progress"),
]
]

View File

@@ -1,3 +1,9 @@
from .core import notify # noqa: F401
default_app_config = 'allianceauth.notifications.apps.NotificationsConfig'
def notify(
user: object, title: str, message: str = None, level: str = 'info'
) -> None:
"""Sends a new notification to user. Convenience function to manager pendant."""
from .models import Notification
Notification.objects.notify_user(user, title, message, level)

View File

@@ -1,33 +0,0 @@
class NotifyApiWrapper:
"""Wrapper to create notify API."""
def __call__(self, *args, **kwargs): # provide old API for backwards compatibility
return self._add_notification(*args, **kwargs)
def danger(self, user: object, title: str, message: str = None) -> None:
"""Add danger notification for user."""
self._add_notification(user, title, message, level="danger")
def info(self, user: object, title: str, message: str = None) -> None:
"""Add info notification for user."""
self._add_notification(user=user, title=title, message=message, level="info")
def success(self, user: object, title: str, message: str = None) -> None:
"""Add success notification for user."""
self._add_notification(user, title, message, level="success")
def warning(self, user: object, title: str, message: str = None) -> None:
"""Add warning notification for user."""
self._add_notification(user, title, message, level="warning")
def _add_notification(
self, user: object, title: str, message: str = None, level: str = "info"
) -> None:
from .models import Notification
Notification.objects.notify_user(
user=user, title=title, message=message, level=level
)
notify = NotifyApiWrapper()

View File

@@ -5,34 +5,91 @@
{% block page_title %}{% translate "Notifications" %}{% endblock %}
{% block content %}
<h1 class="page-header text-center">{% translate "Notifications" %}</h1>
<div class="panel panel-default">
<div class="panel-heading">
<ul class="nav nav-pills">
<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>
<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 class="col-lg-12">
<h1 class="page-header text-center">{% translate "Notifications" %}</h1>
<div class="col-lg-12 container" id="example">
<div class="row">
<div class="col-lg-12">
<div class="panel panel-default">
<div class="panel-heading">
<ul class="nav nav-pills">
<li class="active"><a data-toggle="pill" href="#unread">{% translate "Unread" %}
<b>({{ unread|length }})</b></a></li>
<li><a data-toggle="pill" href="#read">{% translate "Read" %} <b>({{ read|length }})</b></a>
</li>
<div class="pull-right">
<a href="{% url 'notifications:mark_all_read' %}" class="btn btn-primary">{% translate "Mark All Read" %}</a>
<a href="{% url 'notifications:delete_all_read' %}" class="btn btn-danger">{% translate "Delete All Read" %}</a>
</div>
</ul>
</div>
<div class="panel-body">
<div class="tab-content">
<div id="unread" class="tab-pane fade in active">
<div class="table-responsive">
{% if unread %}
<table class="table table-condensed table-hover table-striped">
<tr>
<th class="text-center">{% translate "Timestamp" %}</th>
<th class="text-center">{% translate "Title" %}</th>
<th class="text-center">{% translate "Action" %}</th>
</tr>
{% for notif in unread %}
<tr class="{{ notif.level }}">
<td class="text-center">{{ notif.timestamp }}</td>
<td class="text-center">{{ notif.title }}</td>
<td class="text-center">
<a href="{% url 'notifications:view' notif.id %}" class="btn btn-success" title="View">
<span class="glyphicon glyphicon-eye-open"></span>
</a>
<a href="{% url 'notifications:remove' notif.id %}" class="btn btn-danger" title="Remove">
<span class="glyphicon glyphicon-remove"></span>
</a>
</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="alert alert-warning text-center">{% translate "No unread notifications." %}</div>
{% endif %}
</div>
</div>
<div id="read" class="tab-pane fade">
<div class="panel-body">
<div class="table-responsive">
{% if read %}
<table class="table table-condensed table-hover table-striped">
<tr>
<th class="text-center">{% translate "Timestamp" %}</th>
<th class="text-center">{% translate "Title" %}</th>
<th class="text-center">{% translate "Action" %}</th>
</tr>
{% for notif in read %}
<tr class="{{ notif.level }}">
<td class="text-center">{{ notif.timestamp }}</td>
<td class="text-center">{{ notif.title }}</td>
<td class="text-center">
<a href="{% url 'notifications:view' notif.id %}" class="btn btn-success" title="View">
<span class="glyphicon glyphicon-eye-open"></span>
</a>
<a href="{% url 'notifications:remove' notif.id %}" class="btn btn-danger" title="remove">
<span class="glyphicon glyphicon-remove"></span>
</a>
</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="alert alert-warning text-center">{% translate "No read notifications." %}</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</ul>
</div>
<div class="panel-body">
<div class="tab-content">
<div id="unread" class="tab-pane fade in active">
{% include "notifications/list_partial.html" with notifications=unread %}
</div>
<div id="read" class="tab-pane fade">
{% include "notifications/list_partial.html" with notifications=read %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,29 +0,0 @@
{% load i18n %}
{% if notifications %}
<div class="table-responsive">
<table class="table table-condensed table-hover table-striped">
<tr>
<th class="text-center">{% translate "Timestamp" %}</th>
<th class="text-center">{% translate "Title" %}</th>
<th class="text-center">{% translate "Action" %}</th>
</tr>
{% for notif in notifications %}
<tr class="{{ notif.level }}">
<td class="text-center">{{ notif.timestamp }}</td>
<td class="text-center">{{ notif.title }}</td>
<td class="text-center">
<a href="{% url 'notifications:view' notif.id %}" class="btn btn-primary" title="View">
<span class="glyphicon glyphicon-eye-open"></span>
</a>
<a href="{% url 'notifications:remove' notif.id %}" class="btn btn-danger" title="Remove">
<span class="glyphicon glyphicon-remove"></span>
</a>
</td>
</tr>
{% endfor %}
</table>
</div>
{% else %}
<div class="alert alert-default text-center">{% translate "No notifications." %}</div>
{% endif %}

View File

@@ -5,22 +5,25 @@
{% block page_title %}{% translate "View Notification" %}{% endblock page_title %}
{% block content %}
<h1 class="page-header text-center">
{% translate "View Notification" %}
<div class="text-right">
<a href="{% url 'notifications:list' %}" class="btn btn-primary btn-lg">
<span class="glyphicon glyphicon-arrow-left"></span>
</a>
</div>
</h1>
<div class="row">
<div class="col-lg-12">
<div class="panel panel-{{ notif.level }}">
<div class="panel-heading">{{ notif.timestamp }} {{ notif.title }}</div>
<div class="panel-body"><pre>{{ notif.message }}</pre></div>
<div class="col-lg-12">
<h1 class="page-header text-center">
{% translate "View Notification" %}
<div class="text-right">
<a href="{% url 'notifications:list' %}" class="btn btn-primary btn-lg">
<span class="glyphicon glyphicon-arrow-left"></span>
</a>
</div>
</h1>
<div class="col-lg-12 container">
<div class="row">
<div class="col-lg-12">
<div class="panel panel-{{ notif.level }}">
<div class="panel-heading">{{ notif.timestamp }} {{ notif.title }}</div>
<div class="panel-body"><pre>{{ notif.message }}</pre></div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,85 +0,0 @@
from django.test import TestCase
from allianceauth.tests.auth_utils import AuthUtils
from ..core import NotifyApiWrapper
from ..models import Notification
class TestUserNotificationCount(TestCase):
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
cls.user = AuthUtils.create_user("bruce_wayne")
def test_should_add_danger_notification(self):
# given
notify = NotifyApiWrapper()
# when
notify.danger(user=self.user, title="title", message="message")
# then
obj = Notification.objects.first()
self.assertEqual(obj.user, self.user)
self.assertEqual(obj.title, "title")
self.assertEqual(obj.message, "message")
self.assertEqual(obj.level, Notification.Level.DANGER)
def test_should_add_info_notification(self):
# given
notify = NotifyApiWrapper()
# when
notify.info(user=self.user, title="title", message="message")
# then
obj = Notification.objects.first()
self.assertEqual(obj.user, self.user)
self.assertEqual(obj.title, "title")
self.assertEqual(obj.message, "message")
self.assertEqual(obj.level, Notification.Level.INFO)
def test_should_add_success_notification(self):
# given
notify = NotifyApiWrapper()
# when
notify.success(user=self.user, title="title", message="message")
# then
obj = Notification.objects.first()
self.assertEqual(obj.user, self.user)
self.assertEqual(obj.title, "title")
self.assertEqual(obj.message, "message")
self.assertEqual(obj.level, Notification.Level.SUCCESS)
def test_should_add_warning_notification(self):
# given
notify = NotifyApiWrapper()
# when
notify.warning(user=self.user, title="title", message="message")
# then
obj = Notification.objects.first()
self.assertEqual(obj.user, self.user)
self.assertEqual(obj.title, "title")
self.assertEqual(obj.message, "message")
self.assertEqual(obj.level, Notification.Level.WARNING)
def test_should_add_info_notification_via_callable(self):
# given
notify = NotifyApiWrapper()
# when
notify(user=self.user, title="title", message="message")
# then
obj = Notification.objects.first()
self.assertEqual(obj.user, self.user)
self.assertEqual(obj.title, "title")
self.assertEqual(obj.message, "message")
self.assertEqual(obj.level, Notification.Level.INFO)
def test_should_add_danger_notification_via_callable(self):
# given
notify = NotifyApiWrapper()
# when
notify(user=self.user, title="title", message="message", level="danger")
# then
obj = Notification.objects.first()
self.assertEqual(obj.user, self.user)
self.assertEqual(obj.title, "title")
self.assertEqual(obj.message, "message")
self.assertEqual(obj.level, Notification.Level.DANGER)

View File

@@ -4,8 +4,11 @@ from allianceauth.tests.auth_utils import AuthUtils
from .. import notify
from ..models import Notification
MODULE_PATH = 'allianceauth.notifications'
class TestUserNotificationCount(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = AuthUtils.create_user('magic_mike')
@@ -20,18 +23,6 @@ class TestUserNotificationCount(TestCase):
alliance_name='RIDERS'
)
def test_can_notify_short(self):
# when
notify(self.user, "dummy")
# then
def test_can_notify(self):
notify(self.user, 'dummy')
self.assertEqual(Notification.objects.filter(user=self.user).count(), 1)
def test_can_notify_full(self):
# when
notify(user=self.user, title="title", message="message", level="danger")
# then
obj = Notification.objects.first()
self.assertEqual(obj.user, self.user)
self.assertEqual(obj.title, "title")
self.assertEqual(obj.message, "message")
self.assertEqual(obj.level, "danger")

View File

@@ -1,16 +1,16 @@
from django.conf.urls import url
from django.urls import path
from . import views
app_name = 'notifications'
# Notifications
urlpatterns = [
url(r'^remove_notifications/(\w+)/$', views.remove_notification, name='remove'),
url(r'^notifications/mark_all_read/$', views.mark_all_read, name='mark_all_read'),
url(r'^notifications/delete_all_read/$', views.delete_all_read, name='delete_all_read'),
url(r'^notifications/$', views.notification_list, name='list'),
url(r'^notifications/(\w+)/$', views.notification_view, name='view'),
url(
r'^user_notifications_count/(?P<user_pk>\d+)/$',
path('remove_notifications/<int:notif_id>/', views.remove_notification, name='remove'),
path('notifications/mark_all_read/', views.mark_all_read, name='mark_all_read'),
path('notifications/delete_all_read/', views.delete_all_read, name='delete_all_read'),
path('notifications/', views.notification_list, name='list'),
path('notifications/<int:notif_id>/', views.notification_view, name='view'),
path(
'user_notifications_count/<int:user_pk>/',
views.user_notifications_count,
name='user_notifications_count'
),

View File

@@ -1,5 +1,5 @@
from allianceauth.services.hooks import MenuItemHook, UrlHook
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import gettext_lazy as _
from allianceauth import hooks
from . import urls

View File

@@ -1,5 +1,5 @@
from django import forms
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import gettext_lazy as _
from allianceauth.optimer.form_widgets import DataListWidget

View File

@@ -19,7 +19,7 @@
<form class="form-signin" role="form" action="" method="POST">
{% csrf_token %}
{{ form|bootstrap }}
<br/>
<br>
<button class="btn btn-lg btn-primary btn-block" type="submit">{% translate "Create Fleet Operation" %}</button>
</form>
</div>

View File

@@ -21,7 +21,7 @@
<b>{% translate "Current Eve Time:" %} </b>
</div>
<strong class="label label-info text-left" id="current-time"></strong>
<br />
<br>
</div>
<h4><b>{% translate "Next Fleet Operations" %}</b></h4>

View File

@@ -24,7 +24,7 @@
<form class="form-signin" role="form" action="" method="POST">
{% csrf_token %}
{{ form|bootstrap }}
<br/>
<br>
<button class="btn btn-lg btn-primary btn-block" type="submit">{% translate "Update Fleet Operation" %}
</button>
</form>

View File

@@ -1,12 +1,12 @@
from django.conf.urls import url
from django.urls import path
from . import views
app_name = 'optimer'
urlpatterns = [
url(r'^$', views.optimer_view, name='view'),
url(r'^add$', views.add_optimer_view, name='add'),
url(r'^(\w+)/remove$', views.remove_optimer, name='remove'),
url(r'^(\w+)/edit$', views.edit_optimer, name='edit'),
]
path('', views.optimer_view, name='view'),
path('add/', views.add_optimer_view, name='add'),
path('<int:optimer_id>/remove/', views.remove_optimer, name='remove'),
path('<int:optimer_id>/edit/', views.edit_optimer, name='edit'),
]

View File

@@ -6,7 +6,7 @@ from django.contrib.auth.decorators import permission_required
from django.shortcuts import get_object_or_404
from django.shortcuts import render, redirect
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import gettext_lazy as _
from .form import OpForm
from .models import OpTimer, OpTimerType

View File

@@ -1,11 +1,12 @@
from django.conf.urls import url
from django.urls import re_path
from django.urls import path
from . import views
app_name = 'permissions_tool'
urlpatterns = [
url(r'^overview/$', views.permissions_overview, name='overview'),
url(r'^audit/(?P<app_label>[\w\-_]+)/(?P<model>[\w\-_]+)/(?P<codename>[\w\-_]+)/$', views.permissions_audit,
path('overview/', views.permissions_overview, name='overview'),
re_path(r'^audit/(?P<app_label>[\w\-_]+)/(?P<model>[\w\-_]+)/(?P<codename>[\w\-_]+)/$', views.permissions_audit,
name='audit'),
]

View File

@@ -68,6 +68,7 @@ BASE_DIR = os.path.dirname(PROJECT_DIR)
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'allianceauth.authentication.middleware.UserSettingsMiddleware',
'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
@@ -172,11 +173,8 @@ MESSAGE_TAGS = {
CACHES = {
"default": {
"BACKEND": "redis_cache.RedisCache",
"LOCATION": "localhost:6379",
"OPTIONS": {
"DB": 1,
}
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": "redis://127.0.0.1:6379/1" # change the 1 here to change the database used
}
}

View File

@@ -61,6 +61,13 @@ EMAIL_HOST_PASSWORD = ''
EMAIL_USE_TLS = True
DEFAULT_FROM_EMAIL = ''
# Cache compression can help on bigger auths where ram starts to become an issue.
# Uncomment the following 3 lines to enable.
#CACHES["default"]["OPTIONS"] = {
# "COMPRESSOR": "django_redis.compressors.lzma.LzmaCompressor",
#}
#######################################
# Add any custom settings below here. #
#######################################

View File

@@ -1,8 +1,9 @@
from django.conf.urls import include, url
from django.conf.urls import include
from allianceauth import urls
from django.urls import re_path
urlpatterns = [
url(r'', include(urls)),
re_path(r'', include(urls)),
]
handler500 = 'allianceauth.views.Generic500Redirect'

View File

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

View File

@@ -1,5 +1,5 @@
from django import forms
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import gettext_lazy as _
class FleetFormatterForm(forms.Form):

View File

@@ -1,4 +1,5 @@
from django.conf.urls import include, url
from django.conf.urls import include
from django.urls import re_path
from django.core.exceptions import ObjectDoesNotExist
from django.template.loader import render_to_string
from django.utils.functional import cached_property
@@ -9,7 +10,6 @@ from allianceauth.hooks import get_hooks
from .models import NameFormatConfig
def get_extension_logger(name):
"""
Takes the name of a plugin/extension and generates a child logger of the extensions logger
@@ -157,7 +157,7 @@ class MenuItemHook:
class UrlHook:
def __init__(self, urls, namespace, base_url):
self.include_pattern = url(base_url, include(urls, namespace=namespace))
self.include_pattern = re_path(base_url, include(urls, namespace=namespace))
class NameFormatter:

View File

@@ -2,11 +2,12 @@ import logging
from django.contrib import admin
from ...admin import ServicesUserAdmin
from . import __title__
from ...admin import ServicesUserAdmin
from .models import DiscordUser
from .utils import LoggerAddTag
logger = LoggerAddTag(logging.getLogger(__name__), __title__)
@@ -17,16 +18,21 @@ class DiscordUserAdmin(ServicesUserAdmin):
list_filter = ServicesUserAdmin.list_filter + ('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):
for user in queryset:
user.delete_user()
@admin.display(description='Discord ID (UID)', ordering='uid')
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 ''
_username.short_description = 'Discord Username'
_username.admin_order_field = 'username'

View File

@@ -1,37 +0,0 @@
"""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,25 +2,16 @@ from .utils import clean_setting
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', '')
"""App secret for the AA bot on Discord. Needs to be set."""
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', '')
"""Callback URL for OAuth with Discord. Needs to be set."""
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)
"""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)
"""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)
"""Automatically sync Discord users names to user's main character name when created."""

View File

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

View File

@@ -1,129 +0,0 @@
"""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,10 +1,3 @@
from .app_settings import DISCORD_OAUTH_BASE_URL, DISCORD_OAUTH_TOKEN_URL # noqa
from .client import DiscordClient # noqa
from .exceptions import ( # noqa
DiscordApiBackoff,
DiscordClientException,
DiscordRateLimitExhausted,
DiscordTooManyRequestsError,
)
from .helpers import RolesSet # noqa
from .models import Guild, GuildMember, Role, User # noqa
from .client import DiscordClient # noqa
from .exceptions import DiscordApiBackoff # noqa
from .helpers import DiscordRoles # noqa

View File

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

View File

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

@@ -1,125 +0,0 @@
"""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

@@ -0,0 +1,40 @@
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

@@ -1,155 +0,0 @@
{
"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

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

View File

@@ -1,156 +0,0 @@
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,33 +1,30 @@
import logging
from urllib.parse import urlencode
from requests.exceptions import HTTPError
from requests_oauthlib import OAuth2Session
from requests.exceptions import HTTPError
from django.contrib.auth.models import Group, User
from django.contrib.auth.models import User, Group
from django.db import models
from django.utils.timezone import now
from allianceauth.services.hooks import NameFormatter
from . import __title__
from .app_settings import (
DISCORD_APP_ID,
DISCORD_APP_SECRET,
DISCORD_BOT_TOKEN,
DISCORD_CALLBACK_URL,
DISCORD_GUILD_ID,
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,
DISCORD_SYNC_NAMES
)
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
logger = LoggerAddTag(logging.getLogger(__name__), __title__)
@@ -59,68 +56,79 @@ class DiscordUserManager(models.Manager):
Returns: True on success, else False or raises exception
"""
try:
nickname = user_formatted_nick(user) if DISCORD_SYNC_NAMES else None
nickname = self.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)
user_client = DiscordClient(access_token, is_rate_limited=is_rate_limited)
discord_user = user_client.current_user()
bot_client = create_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 changed is None:
# Handle new member
created = bot_client.add_guild_member(
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,
discord_user.id,
)
return False
else:
# Handle existing member
logger.debug(
"User %s with Discord ID %s is already a member. Forcing a Refresh",
user,
discord_user.id,
)
# 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
user_id = discord_user['id']
bot_client = self._bot_client(is_rate_limited=is_rate_limited)
self.update_or_create(
user=user,
defaults={
'uid': discord_user.id,
'username': discord_user.username[:32],
'discriminator': discord_user.discriminator[:4],
'activated': now()
}
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
)
logger.info(
"Added user %s with Discord ID %s to Discord server",
user,
discord_user.id
)
return True
if created is not False:
if created is None:
logger.debug(
"User %s with Discord ID %s is already a member. Forcing a Refresh",
user,
user_id,
)
# 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:
logger.warning(
"Failed to add user %s with Discord ID %s to Discord server",
user,
user_id,
)
return False
except (HTTPError, ConnectionError, DiscordApiBackoff) as ex:
logger.exception(
@@ -128,6 +136,31 @@ class DiscordUserManager(models.Manager):
)
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:
"""Returns True if the user has an Discord account, else False
@@ -145,41 +178,60 @@ class DiscordUserManager(models.Manager):
'permissions': str(cls.BOT_PERMISSIONS)
})
return f'{DISCORD_OAUTH_BASE_URL}?{params}'
return f'{DiscordClient.OAUTH_BASE_URL}?{params}'
@classmethod
def generate_oauth_redirect_url(cls) -> str:
oauth = OAuth2Session(
DISCORD_APP_ID, redirect_uri=DISCORD_CALLBACK_URL, scope=cls.SCOPES
)
url, _ = oauth.authorization_url(DISCORD_OAUTH_BASE_URL)
url, state = oauth.authorization_url(DiscordClient.OAUTH_BASE_URL)
return url
@staticmethod
def _exchange_auth_code_for_token(authorization_code: str) -> str:
oauth = OAuth2Session(DISCORD_APP_ID, redirect_uri=DISCORD_CALLBACK_URL)
token = oauth.fetch_token(
DISCORD_OAUTH_TOKEN_URL,
DiscordClient.OAUTH_TOKEN_URL,
client_secret=DISCORD_APP_SECRET,
code=authorization_code
)
logger.debug("Received token from OAuth")
return token['access_token']
@staticmethod
def group_to_role(group: Group) -> dict:
"""Fetch the Discord role matching the given Django group by name.
@classmethod
def server_name(cls, use_cache: bool = True) -> str:
"""returns the name of the current Discord server
or an empty string if the name could not be retrieved
Returns:
- Discord role as dict
- empty dict if no matching role found
Params:
- use_cache: When set False will force an API call to get the server name
"""
role = core_group_to_role(group)
return role.asdict() if role else dict()
try:
server_name = cls._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
@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
def server_name(use_cache: bool = True) -> str:
"""Fetches the name of the current Discord server.
This method is kept to ensure backwards compatibility of this API.
"""
return core_server_name(use_cache)
def _bot_client(is_rate_limited: bool = True) -> DiscordClient:
"""returns a bot client for access to the Discord API"""
return DiscordClient(DISCORD_BOT_TOKEN, is_rate_limited=is_rate_limited)

View File

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

View File

@@ -1,6 +1,19 @@
from django.contrib.auth.models import Group
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'
MODULE_PATH = 'allianceauth.services.modules.discord'

View File

@@ -1,31 +0,0 @@
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

@@ -35,17 +35,17 @@ import logging
from uuid import uuid1
import random
from django.core.cache import caches
from django.contrib.auth.models import User, Group
from allianceauth.services.modules.discord.models import DiscordUser
from allianceauth.utils.cache import get_redis_client
logger = logging.getLogger('allianceauth')
MAX_RUNS = 3
def clear_cache():
redis = get_redis_client()
default_cache = caches['default']
redis = default_cache.get_master_client()
redis.flushall()
logger.info('Cache flushed')

View File

@@ -1,32 +1,26 @@
from unittest.mock import patch
from django.test import TestCase, RequestFactory
from django.contrib.admin.sites import AdminSite
from django.contrib.auth.models import User
from django.test import RequestFactory
from django.utils.timezone import now
from allianceauth.authentication.models import CharacterOwnership
from allianceauth.eveonline.models import (
EveAllianceInfo,
EveCharacter,
EveCorporationInfo,
EveCharacter, EveCorporationInfo, EveAllianceInfo
)
from allianceauth.utils.testing import NoSocketsTestCase
from ....admin import (
MainAllianceFilter,
MainCorporationsFilter,
ServicesUserAdmin,
user_main_organization,
user_profile_pic,
user_username,
user_main_organization,
ServicesUserAdmin,
MainCorporationsFilter,
MainAllianceFilter
)
from ..admin import DiscordUserAdmin
from ..models import DiscordUser
from . import MODULE_PATH
class TestDataMixin(NoSocketsTestCase):
class TestDataMixin(TestCase):
@classmethod
def setUpClass(cls):
@@ -174,7 +168,7 @@ class TestDataMixin(NoSocketsTestCase):
)
class TestColumnRendering(TestDataMixin, NoSocketsTestCase):
class TestColumnRendering(TestDataMixin, TestCase):
def test_user_profile_pic_u1(self):
expected = (
@@ -235,7 +229,7 @@ class TestColumnRendering(TestDataMixin, NoSocketsTestCase):
# actions
class TestFilters(TestDataMixin, NoSocketsTestCase):
class TestFilters(TestDataMixin, TestCase):
def test_filter_main_corporations(self):
@@ -293,16 +287,3 @@ class TestFilters(TestDataMixin, NoSocketsTestCase):
queryset = changelist.get_queryset(request)
expected = [self.user_1.discord]
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

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

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