Compare commits

...

66 Commits

Author SHA1 Message Date
Ariel Rin
da93940e13 Just an empty Tag Commit, because 2.11.2 bump went wonky 2022-03-29 14:48:39 +10:00
Ariel Rin
f53b43d9dc Merge branch 'master' of https://gitlab.com/allianceauth/allianceauth into v2.11.x 2022-03-29 14:47:40 +10:00
Ariel Rin
497a167ca7 Version Bump v2.11.2 2022-03-29 14:46:59 +10:00
Ariel Rin
852c5a3037 Bump Django-ESI to 4.x, inc breaking CCP change in 4.0.1 2022-03-29 14:40:30 +10:00
Ariel Rin
90f6777a7a Version Bump 2.11.1 2022-03-20 14:42:39 +10:00
Ariel Rin
a8d890abaf Merge branch 'improve_task_statistics' into 'master'
Improve task statistics

See merge request allianceauth/allianceauth!1409
2022-03-09 10:04:14 +00:00
Erik Kalkoken
79379b444c Improve task statistics 2022-03-09 10:04:13 +00:00
Ariel Rin
ace1de5c68 Merge branch 'fix-docker-new-redis' into 'master'
Fix docker for new redis

See merge request allianceauth/allianceauth!1406
2022-03-09 10:02:01 +00:00
Kevin McKernan
5d6128e9ea remove collectstatic command from dockerfile 2022-03-01 13:23:49 -07:00
Ariel Rin
131cc5ed0a Version Bump 2.11.0 2022-02-26 17:26:55 +10:00
Ariel Rin
9297bed43f Version Bump 2.10.2 2022-02-26 16:37:20 +10:00
Ariel Rin
b2fddc683a Merge branch 'master' of https://gitlab.com/allianceauth/allianceauth into v2.10.x 2022-02-26 16:32:45 +10:00
Ariel Rin
9af634d16a Merge branch 'fix_show_available_groups_for_user_only' into 'master'
Fix: Users can be assigned to groups depite not matching state restrictions

See merge request allianceauth/allianceauth!1402
2022-02-26 05:19:45 +00:00
Erik Kalkoken
a68163caa3 Fix: Users can be assigned to groups depite not matching state restrictions 2022-02-26 05:19:45 +00:00
Ariel Rin
00770fd034 Merge branch 'improve_celery_info_on_dashboard' into 'master'
Improve celery infos on Dashboard

See merge request allianceauth/allianceauth!1384
2022-02-26 05:15:30 +00:00
Erik Kalkoken
01164777ed Improve celery infos on Dashboard 2022-02-26 05:15:30 +00:00
Ariel Rin
00f5e3e1e0 Version Bump 2.10.1 2022-02-21 00:02:12 +10:00
Ariel Rin
8b2527f408 Merge branch 'capsleekxmpp' into 'master'
Cap sleekxmpp to 1.3.2

See merge request allianceauth/allianceauth!1401
2022-02-20 13:44:27 +00:00
Ariel Rin
b7500e4e4e Cap sleekxmpp to 1.3.2 2022-02-20 13:44:27 +00:00
Kevin McKernan
4f4bd0c419 add note to docker README about Apple M1 support 2022-02-20 23:41:12 +10:00
Ariel Rin
8ae4e02012 Merge branch 'docker-bump-version' into 'v2.10.x'
Bump version for Docker deployment to v2.10.x.

See merge request allianceauth/allianceauth!1396
2022-02-02 13:26:33 +00:00
Weyland
cc9a07197d Bump version for Docker deployment to v2.10.x. 2022-02-02 13:30:05 +01:00
Ariel Rin
f18dd1029b Version Bump v2.10.0 2022-01-31 20:58:09 +10:00
Ariel Rin
fd8d43571a Merge branch 'analytics' into 'master'
Analytics - Extra Ignore Path

See merge request allianceauth/allianceauth!1347
2022-01-31 09:23:43 +00:00
Ariel Rin
13e88492f1 Analytics - Extra Ignore Path 2022-01-31 09:23:43 +00:00
Ariel Rin
38df580a56 Merge branch 'analytics_update' into 'master'
Add setting to disable analytics

See merge request allianceauth/allianceauth!1373
2022-01-27 05:14:12 +00:00
Erik Kalkoken
ba39318313 Add setting to disable analytics 2022-01-27 05:14:11 +00:00
Ariel Rin
d8c6035405 Merge branch 'ts3_reserved_groups' into 'master'
Implement reserved group names in Teamspeak3 service module.

See merge request allianceauth/allianceauth!1380
2022-01-27 05:10:22 +00:00
Ariel Rin
2ef3da916b Merge branch 'datatablessavestate' into 'master'
Add DataTables stateSave feature

See merge request allianceauth/allianceauth!1374
2022-01-27 05:05:37 +00:00
Ariel Rin
d32d8b26ce Merge branch 'delete_characters' into 'master'
Fix: Can not update biomassed characters

See merge request allianceauth/allianceauth!1381
2022-01-27 05:02:57 +00:00
Erik Kalkoken
f348b1a34c Fix: Can not update biomassed characters 2022-01-27 05:02:57 +00:00
Ariel Rin
86aaa3edda Merge branch 'fix-grafana-image-2' into 'master'
fix grafana image again, thanks grafana for not tagging your new images properly

See merge request allianceauth/allianceauth!1393
2022-01-27 04:57:40 +00:00
Ariel Rin
26017056c7 Merge branch 'evetime-js-update' into 'master'
Evetime js update

See merge request allianceauth/allianceauth!1395
2022-01-27 04:35:15 +00:00
Peter Pfeufer
e39a3c072b Evetime js update 2022-01-27 04:35:15 +00:00
Kevin McKernan
827291dda4 fix grafana image again, thanks grafana for not tagging your new images properly 2022-01-07 10:48:50 -07:00
Ariel Rin
ea8958ccc3 Version Bump v2.9.4 2021-12-28 21:56:46 +10:00
Ariel Rin
20554df857 Merge branch 'jqueryui' into 'master'
Missing jQuery-UI Images

See merge request allianceauth/allianceauth!1391
2021-12-28 11:55:37 +00:00
Ariel Rin
750f43eaf0 Missing jQuery-UI Images 2021-12-28 11:55:37 +00:00
Ariel Rin
09cf28ec9f Merge branch 'jqueryui' into 'master'
Removing Themeing from jQuery UI

See merge request allianceauth/allianceauth!1386
2021-12-28 11:30:05 +00:00
Ariel Rin
b61746b3cb Removing Themeing from jQuery UI 2021-12-28 11:30:05 +00:00
Ariel Rin
22c22fafeb Merge branch 'py3.11' into 'master'
Python 3.11 Testing

See merge request allianceauth/allianceauth!1388
2021-12-28 11:29:33 +00:00
Ariel Rin
577c4395c4 Python 3.11 Testing 2021-12-28 11:29:33 +00:00
Ariel Rin
d241f476f7 Merge branch 'fix-grafana-image' into 'master'
update grafana image

See merge request allianceauth/allianceauth!1389
2021-12-28 10:43:43 +00:00
Kevin McKernan
5832ed0c30 update grafana image 2021-12-28 10:43:43 +00:00
Ariel Rin
bd9ea225be Rogue comment annoying MRs 2021-12-24 05:02:46 +00:00
Ariel Rin
4a575dd70c Merge branch 'groupmanagement-auto-leave-improvements' into 'master'
Hide "Leave Requests" tab when GROUPMANAGEMENT_AUTO_LEAVE = True

See merge request allianceauth/allianceauth!1378
2021-12-24 04:54:55 +00:00
Ariel Rin
b80ee16a7c Merge branch 'fix_max_notifications_warning' into 'master'
Fix: NOTIFICATIONS_MAX_PER_USER warning when not set

See merge request allianceauth/allianceauth!1383
2021-12-24 04:53:40 +00:00
Ariel Rin
c888371e6c Merge branch 'fix_esi_spec' into 'master'
Add missing ESI operation to eveonline swagger spec file.

See merge request allianceauth/allianceauth!1382
2021-12-24 04:51:49 +00:00
Adarnof
8de2c3bfcb Update name of serverquery IP file changed in TS3 v3.13.0
Changelog indicates old filenames are still accepted, but newly installed servers come with the new file names.
Closes #1298
2021-12-16 22:23:15 -05:00
Adarnof
6688f73565 Use integer teamspeak group IDs when filtering. 2021-12-15 23:54:53 -05:00
ErikKalkoken
7d929cb6e2 Fix: NOTIFICATIONS_MAX_PER_USER warning when not set 2021-12-09 18:12:18 +01:00
Adarnof
72740b9e4d Prevent assignment of reserved groups to AuthTSgroup mappings.
Implemented in TS group updates to prevent their creation / delete once
reserved, and the admin site for when a reserved group name is created
but before the TS group sync occurs.
2021-12-08 23:41:10 -05:00
Adarnof
f7d279fa16 Add missing ESI operation to minimized spec file. My bad. 2021-12-07 23:42:02 -05:00
Ariel Rin
ff7c9c48f3 Merge branch 'v2.9.x' of https://gitlab.com/allianceauth/allianceauth 2021-12-02 02:26:33 +10:00
Adarnof
d11832913d Implement reserved group names in Teamspeak3 service module.
Closes #1302
2021-12-01 00:50:29 -05:00
Ariel Rin
724e0e83f2 Merge branch 'fix-docker-script' into 'v2.9.x'
fix download script

See merge request allianceauth/allianceauth!1379
2021-11-30 11:44:35 +00:00
Kevin McKernan
333f091f1a fix download script 2021-11-29 13:43:32 -07:00
Peter Pfeufer
cfbb0b993a Behavior added to documentation 2021-11-28 19:07:14 +01:00
Peter Pfeufer
582b6754a4 Hide "Leave Requests" tab when GROUPMANAGEMENT_AUTO_LEAVE = True 2021-11-28 18:12:02 +01:00
Ariel Rin
dfe62db8ee add datatables savestate feature 2021-11-27 23:02:33 +10:00
Ariel Rin
52ae05d057 Merge branch 'messages' into 'master'
Use danger and error tags to render messages correctly on admin site.

See merge request allianceauth/allianceauth!1362
2021-11-20 01:13:27 +00:00
Adarnof
f17ebbede6 Use danger and error message tags to render correctly on admin site.
Closes #1305
2021-11-03 21:02:03 -04:00
Ariel Rin
a19302babc Merge branch 'chunking_updates' into 'master'
Breakout Model updates into separate tasks

See merge request allianceauth/allianceauth!1343
2021-10-19 00:49:39 +00:00
Aaron Kable
18a627b01e Breakout Model updates into separate tasks 2021-10-19 00:49:39 +00:00
Ariel Rin
eddb5480e9 Merge branch 'v2.9.x' into 'master'
Bring up Master to 2.9.0

See merge request allianceauth/allianceauth!1344
2021-10-18 01:58:24 +00:00
Ariel Rin
5b26757662 Merge branch 'features/kick-from-discord-admin' into 'master'
Add override to delete the user from discord itself

See merge request allianceauth/allianceauth!1337
2021-10-17 07:34:22 +00:00
72 changed files with 1913 additions and 538 deletions

1
.gitignore vendored
View File

@@ -76,3 +76,4 @@ celerybeat-schedule
.flake8 .flake8
.pylintrc .pylintrc
Makefile Makefile
.isort.cfg

View File

@@ -86,6 +86,17 @@ test-3.10-core:
reports: reports:
cobertura: coverage.xml cobertura: coverage.xml
test-3.11-core:
<<: *only-default
image: python:3.11-rc-bullseye
script:
- tox -e py311-core
artifacts:
when: always
reports:
cobertura: coverage.xml
allow_failure: true
test-3.7-all: test-3.7-all:
<<: *only-default <<: *only-default
image: python:3.7-bullseye image: python:3.7-bullseye
@@ -126,6 +137,17 @@ test-3.10-all:
reports: reports:
cobertura: coverage.xml cobertura: coverage.xml
test-3.11-all:
<<: *only-default
image: python:3.11-rc-bullseye
script:
- tox -e py311-all
artifacts:
when: always
reports:
cobertura: coverage.xml
allow_failure: true
deploy_production: deploy_production:
stage: deploy stage: deploy
image: python:3.10-bullseye image: python:3.10-bullseye

View File

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

View File

@@ -1,5 +1,6 @@
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from django.conf import settings
from django.utils.deprecation import MiddlewareMixin from django.utils.deprecation import MiddlewareMixin
from .models import AnalyticsTokens, AnalyticsIdentifier from .models import AnalyticsTokens, AnalyticsIdentifier
from .tasks import send_ga_tracking_web_view from .tasks import send_ga_tracking_web_view
@@ -10,6 +11,8 @@ import re
class AnalyticsMiddleware(MiddlewareMixin): class AnalyticsMiddleware(MiddlewareMixin):
def process_response(self, request, response): def process_response(self, request, response):
"""Django Middleware: Process Page Views and creates Analytics Celery Tasks""" """Django Middleware: Process Page Views and creates Analytics Celery Tasks"""
if getattr(settings, "ANALYTICS_DISABLED", False):
return response
analyticstokens = AnalyticsTokens.objects.all() analyticstokens = AnalyticsTokens.objects.all()
client_id = AnalyticsIdentifier.objects.get(id=1).identifier.hex client_id = AnalyticsIdentifier.objects.get(id=1).identifier.hex
try: try:

View File

@@ -1,11 +1,11 @@
# Generated by Django 3.1.13 on 2021-10-15 05:02 # Generated by Django 3.1.13 on 2021-10-15 05:02
from django.core.exceptions import ObjectDoesNotExist
from django.db import migrations from django.db import migrations
def modify_aa_team_token_add_page_ignore_paths(apps, schema_editor): def modify_aa_team_token_add_page_ignore_paths(apps, schema_editor):
# We can't import the Person model directly as it may be a newer # Add /admin/ and /user_notifications_count/ path to ignore
# version than this migration expects. We use the historical version.
AnalyticsPath = apps.get_model('analytics', 'AnalyticsPath') AnalyticsPath = apps.get_model('analytics', 'AnalyticsPath')
admin = AnalyticsPath.objects.create(ignore_path=r"^\/admin\/.*") admin = AnalyticsPath.objects.create(ignore_path=r"^\/admin\/.*")
@@ -17,8 +17,19 @@ def modify_aa_team_token_add_page_ignore_paths(apps, schema_editor):
def undo_modify_aa_team_token_add_page_ignore_paths(apps, schema_editor): def undo_modify_aa_team_token_add_page_ignore_paths(apps, schema_editor):
# nothing should need to migrate away here? #
return True AnalyticsPath = apps.get_model('analytics', 'AnalyticsPath')
Tokens = apps.get_model('analytics', 'AnalyticsTokens')
token = Tokens.objects.get(token="UA-186249766-2")
try:
admin = AnalyticsPath.objects.get(ignore_path=r"^\/admin\/.*", analyticstokens=token)
user_notifications_count = AnalyticsPath.objects.get(ignore_path=r"^\/user_notifications_count\/.*", analyticstokens=token)
admin.delete()
user_notifications_count.delete()
except ObjectDoesNotExist:
# Its fine if it doesnt exist, we just dont want them building up when re-migrating
pass
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@@ -0,0 +1,40 @@
# Generated by Django 3.2.8 on 2021-10-19 01:47
from django.core.exceptions import ObjectDoesNotExist
from django.db import migrations
def modify_aa_team_token_add_page_ignore_paths(apps, schema_editor):
# Add the /account/activate path to ignore
AnalyticsPath = apps.get_model('analytics', 'AnalyticsPath')
account_activate = AnalyticsPath.objects.create(ignore_path=r"^\/account\/activate\/.*")
Tokens = apps.get_model('analytics', 'AnalyticsTokens')
token = Tokens.objects.get(token="UA-186249766-2")
token.ignore_paths.add(account_activate)
def undo_modify_aa_team_token_add_page_ignore_paths(apps, schema_editor):
#
AnalyticsPath = apps.get_model('analytics', 'AnalyticsPath')
Tokens = apps.get_model('analytics', 'AnalyticsTokens')
token = Tokens.objects.get(token="UA-186249766-2")
try:
account_activate = AnalyticsPath.objects.get(ignore_path=r"^\/account\/activate\/.*", analyticstokens=token)
account_activate.delete()
except ObjectDoesNotExist:
# Its fine if it doesnt exist, we just dont want them building up when re-migrating
pass
class Migration(migrations.Migration):
dependencies = [
('analytics', '0005_alter_analyticspath_ignore_path'),
]
operations = [
migrations.RunPython(modify_aa_team_token_add_page_ignore_paths, undo_modify_aa_team_token_add_page_ignore_paths)
]

View File

@@ -1,7 +1,8 @@
from allianceauth.analytics.tasks import analytics_event
from celery.signals import task_failure, task_success
import logging import logging
from celery.signals import task_failure, task_success
from django.conf import settings
from allianceauth.analytics.tasks import analytics_event
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -11,6 +12,8 @@ def process_failure_signal(
sender, task_id, signal, sender, task_id, signal,
args, kwargs, einfo, **kw): args, kwargs, einfo, **kw):
logger.debug("Celery task_failure signal %s" % sender.__class__.__name__) logger.debug("Celery task_failure signal %s" % sender.__class__.__name__)
if getattr(settings, "ANALYTICS_DISABLED", False):
return
category = sender.__module__ category = sender.__module__
@@ -30,6 +33,8 @@ def process_failure_signal(
@task_success.connect @task_success.connect
def celery_success_signal(sender, result=None, **kw): def celery_success_signal(sender, result=None, **kw):
logger.debug("Celery task_success signal %s" % sender.__class__.__name__) logger.debug("Celery task_success signal %s" % sender.__class__.__name__)
if getattr(settings, "ANALYTICS_DISABLED", False):
return
category = sender.__module__ category = sender.__module__

View File

@@ -21,8 +21,8 @@ if getattr(settings, "ANALYTICS_ENABLE_DEBUG", False) and settings.DEBUG:
# Force sending of analytics data during in a debug/test environemt # Force sending of analytics data during in a debug/test environemt
# Usefull for developers working on this feature. # Usefull for developers working on this feature.
logger.warning( logger.warning(
"You have 'ANALYTICS_ENABLE_DEBUG' Enabled! " "You have 'ANALYTICS_ENABLE_DEBUG' Enabled! "
"This debug instance will send analytics data!") "This debug instance will send analytics data!")
DEBUG_URL = COLLECTION_URL DEBUG_URL = COLLECTION_URL
ANALYTICS_URL = COLLECTION_URL ANALYTICS_URL = COLLECTION_URL
@@ -40,13 +40,12 @@ def analytics_event(category: str,
Send a Google Analytics Event for each token stored Send a Google Analytics Event for each token stored
Includes check for if its enabled/disabled Includes check for if its enabled/disabled
Parameters Args:
------- `category` (str): Celery Namespace
`category` (str): Celery Namespace `action` (str): Task Name
`action` (str): Task Name `label` (str): Optional, Task Success/Exception
`label` (str): Optional, Task Success/Exception `value` (int): Optional, If bulk, Query size, can be a binary True/False
`value` (int): Optional, If bulk, Query size, can be a binary True/False `event_type` (str): Optional, Celery or Stats only, Default to Celery
`event_type` (str): Optional, Celery or Stats only, Default to Celery
""" """
analyticstokens = AnalyticsTokens.objects.all() analyticstokens = AnalyticsTokens.objects.all()
client_id = AnalyticsIdentifier.objects.get(id=1).identifier.hex client_id = AnalyticsIdentifier.objects.get(id=1).identifier.hex
@@ -60,20 +59,21 @@ def analytics_event(category: str,
if allowed is True: if allowed is True:
tracking_id = token.token tracking_id = token.token
send_ga_tracking_celery_event.s(tracking_id=tracking_id, send_ga_tracking_celery_event.s(
client_id=client_id, tracking_id=tracking_id,
category=category, client_id=client_id,
action=action, category=category,
label=label, action=action,
value=value).\ label=label,
apply_async(priority=9) value=value).apply_async(priority=9)
@shared_task() @shared_task()
def analytics_daily_stats(): def analytics_daily_stats():
"""Celery Task: Do not call directly """Celery Task: Do not call directly
Gathers a series of daily statistics and sends analytics events containing them""" Gathers a series of daily statistics and sends analytics events containing them
"""
users = install_stat_users() users = install_stat_users()
tokens = install_stat_tokens() tokens = install_stat_tokens()
addons = install_stat_addons() addons = install_stat_addons()

View File

@@ -0,0 +1,108 @@
from unittest.mock import patch
from urllib.parse import parse_qs
import requests_mock
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
@override_settings(CELERY_ALWAYS_EAGER=True)
@requests_mock.mock()
class TestAnalyticsForViews(TestCase):
@override_settings(ANALYTICS_DISABLED=False)
def test_should_run_analytics(self, requests_mocker):
# given
requests_mocker.post(ANALYTICS_URL)
user = AuthUtils.create_user("Bruce Wayne")
self.client.force_login(user)
# when
response = self.client.get("/dashboard/")
# then
self.assertEqual(response.status_code, 200)
self.assertTrue(requests_mocker.called)
@override_settings(ANALYTICS_DISABLED=True)
def test_should_not_run_analytics(self, requests_mocker):
# given
requests_mocker.post(ANALYTICS_URL)
user = AuthUtils.create_user("Bruce Wayne")
self.client.force_login(user)
# when
response = self.client.get("/dashboard/")
# then
self.assertEqual(response.status_code, 200)
self.assertFalse(requests_mocker.called)
@override_settings(CELERY_ALWAYS_EAGER=True)
@requests_mock.mock()
class TestAnalyticsForTasks(TestCase):
@override_settings(ANALYTICS_DISABLED=False)
@patch("allianceauth.eveonline.models.EveCharacter.objects.update_character")
def test_should_run_analytics_for_successful_task(
self, requests_mocker, mock_update_character
):
# given
requests_mocker.post(ANALYTICS_URL)
user = AuthUtils.create_user("Bruce Wayne")
character = AuthUtils.add_main_character_2(user, "Bruce Wayne", 1001)
# when
update_character.delay(character.character_id)
# then
self.assertTrue(mock_update_character.called)
self.assertTrue(requests_mocker.called)
payload = parse_qs(requests_mocker.last_request.text)
self.assertListEqual(payload["el"], ["Success"])
@override_settings(ANALYTICS_DISABLED=True)
@patch("allianceauth.eveonline.models.EveCharacter.objects.update_character")
def test_should_not_run_analytics_for_successful_task(
self, requests_mocker, mock_update_character
):
# given
requests_mocker.post(ANALYTICS_URL)
user = AuthUtils.create_user("Bruce Wayne")
character = AuthUtils.add_main_character_2(user, "Bruce Wayne", 1001)
# when
update_character.delay(character.character_id)
# then
self.assertTrue(mock_update_character.called)
self.assertFalse(requests_mocker.called)
@override_settings(ANALYTICS_DISABLED=False)
@patch("allianceauth.eveonline.models.EveCharacter.objects.update_character")
def test_should_run_analytics_for_failed_task(
self, requests_mocker, mock_update_character
):
# given
requests_mocker.post(ANALYTICS_URL)
mock_update_character.side_effect = RuntimeError
user = AuthUtils.create_user("Bruce Wayne")
character = AuthUtils.add_main_character_2(user, "Bruce Wayne", 1001)
# when
update_character.delay(character.character_id)
# then
self.assertTrue(mock_update_character.called)
self.assertTrue(requests_mocker.called)
payload = parse_qs(requests_mocker.last_request.text)
self.assertNotEqual(payload["el"], ["Success"])
@override_settings(ANALYTICS_DISABLED=True)
@patch("allianceauth.eveonline.models.EveCharacter.objects.update_character")
def test_should_not_run_analytics_for_failed_task(
self, requests_mocker, mock_update_character
):
# given
requests_mocker.post(ANALYTICS_URL)
mock_update_character.side_effect = RuntimeError
user = AuthUtils.create_user("Bruce Wayne")
character = AuthUtils.add_main_character_2(user, "Bruce Wayne", 1001)
# when
update_character.delay(character.character_id)
# then
self.assertTrue(mock_update_character.called)
self.assertFalse(requests_mocker.called)

View File

@@ -380,6 +380,7 @@ class UserAdmin(BaseUserAdmin):
'username', 'username',
'character_ownerships__character__character_name' 'character_ownerships__character__character_name'
) )
readonly_fields = ('date_joined', 'last_login')
def _characters(self, obj): def _characters(self, obj):
character_ownerships = list(obj.character_ownerships.all()) character_ownerships = list(obj.character_ownerships.all())
@@ -425,10 +426,19 @@ class UserAdmin(BaseUserAdmin):
def has_delete_permission(self, request, obj=None): def has_delete_permission(self, request, obj=None):
return request.user.has_perm('auth.delete_user') return request.user.has_perm('auth.delete_user')
def get_object(self, *args , **kwargs):
obj = super().get_object(*args , **kwargs)
self.obj = obj # storing current object for use in formfield_for_manytomany
return obj
def formfield_for_manytomany(self, db_field, request, **kwargs): def formfield_for_manytomany(self, db_field, request, **kwargs):
"""overriding this formfield to have sorted lists in the form"""
if db_field.name == "groups": if db_field.name == "groups":
kwargs["queryset"] = Group.objects.all().order_by(Lower('name')) groups_qs = Group.objects.filter(authgroup__states__isnull=True)
obj_state = self.obj.profile.state
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'))
return super().formfield_for_manytomany(db_field, request, **kwargs) return super().formfield_for_manytomany(db_field, request, **kwargs)

View File

@@ -3,10 +3,14 @@ from django.core.checks import register, Tags
class AuthenticationConfig(AppConfig): class AuthenticationConfig(AppConfig):
name = 'allianceauth.authentication' name = "allianceauth.authentication"
label = 'authentication' label = "authentication"
def ready(self): def ready(self):
super().ready() from allianceauth.authentication import checks, signals # noqa: F401
from allianceauth.authentication import checks, signals from allianceauth.authentication.task_statistics import (
signals as celery_signals,
)
register(Tags.security)(checks.check_login_scopes_setting) register(Tags.security)(checks.check_login_scopes_setting)
celery_signals.reset_counters()

View File

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

@@ -0,0 +1,95 @@
import datetime as dt
from typing import Optional, List
from redis import Redis
from pytz import utc
from django.core.cache import cache
class EventSeries:
"""API for recording and analysing a series of events."""
_ROOT_KEY = "ALLIANCEAUTH_EVENT_SERIES"
def __init__(self, key_id: str, redis: Redis = None) -> None:
self._redis = cache.get_master_client() 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._key_id = str(key_id)
self.clear()
@property
def _key_counter(self):
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"
def add(self, event_time: dt.datetime = None) -> None:
"""Add event.
Args:
- event_time: timestamp of event. Will use current time if not specified.
"""
if not event_time:
event_time = dt.datetime.utcnow()
id = self._redis.incr(self._key_counter)
self._redis.zadd(self._key_sorted_set, {id: event_time.timestamp()})
def all(self) -> List[dt.datetime]:
"""List of all known events."""
return [
event[1]
for event in self._redis.zrangebyscore(
self._key_sorted_set,
"-inf",
"+inf",
withscores=True,
score_cast_func=self._cast_scores_to_dt,
)
]
def clear(self) -> None:
"""Clear all events."""
self._redis.delete(self._key_sorted_set)
self._redis.delete(self._key_counter)
def count(self, earliest: dt.datetime = None, latest: dt.datetime = None) -> int:
"""Count of events, can be restricted to given timeframe.
Args:
- earliest: Date of first events to count(inclusive), or -infinite if not specified
- latest: Date of last events to count(inclusive), or +infinite if not specified
"""
min = "-inf" if not earliest else earliest.timestamp()
max = "+inf" if not latest else latest.timestamp()
return self._redis.zcount(self._key_sorted_set, min=min, max=max)
def first_event(self, earliest: dt.datetime = None) -> Optional[dt.datetime]:
"""Date/Time of first event. Returns `None` if series has no events.
Args:
- earliest: Date of first events to count(inclusive), or any if not specified
"""
min = "-inf" if not earliest else earliest.timestamp()
event = self._redis.zrangebyscore(
self._key_sorted_set,
min,
"+inf",
withscores=True,
start=0,
num=1,
score_cast_func=self._cast_scores_to_dt,
)
if not event:
return None
return event[0][1]
@staticmethod
def _cast_scores_to_dt(score) -> dt.datetime:
return dt.datetime.fromtimestamp(float(score), tz=utc)

View File

@@ -0,0 +1,54 @@
from celery.signals import (
task_failure,
task_internal_error,
task_retry,
task_success,
worker_ready
)
from django.conf import settings
from .counters import failed_tasks, retried_tasks, succeeded_tasks
def reset_counters():
"""Reset all counters for the celery status."""
succeeded_tasks.clear()
failed_tasks.clear()
retried_tasks.clear()
def is_enabled() -> bool:
return not bool(
getattr(settings, "ALLIANCEAUTH_DASHBOARD_TASK_STATISTICS_DISABLED", False)
)
@worker_ready.connect
def reset_counters_when_celery_restarted(*args, **kwargs):
if is_enabled():
reset_counters()
@task_success.connect
def record_task_succeeded(*args, **kwargs):
if is_enabled():
succeeded_tasks.add()
@task_retry.connect
def record_task_retried(*args, **kwargs):
if is_enabled():
retried_tasks.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()

View File

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

@@ -0,0 +1,133 @@
import datetime as dt
from pytz import utc
from django.test import TestCase
from django.utils.timezone import now
from allianceauth.authentication.task_statistics.event_series import EventSeries
class TestEventSeries(TestCase):
def test_should_add_event(self):
# given
events = EventSeries("dummy")
# when
events.add()
# then
result = events.all()
self.assertEqual(len(result), 1)
self.assertAlmostEqual(result[0], now(), delta=dt.timedelta(seconds=30))
def test_should_add_event_with_specified_time(self):
# given
events = EventSeries("dummy")
my_time = dt.datetime(2021, 11, 1, 12, 15, tzinfo=utc)
# when
events.add(my_time)
# then
result = events.all()
self.assertEqual(len(result), 1)
self.assertAlmostEqual(result[0], my_time, delta=dt.timedelta(seconds=30))
def test_should_count_events(self):
# given
events = EventSeries("dummy")
events.add()
events.add()
# when
result = events.count()
# then
self.assertEqual(result, 2)
def test_should_count_zero(self):
# given
events = EventSeries("dummy")
# when
result = events.count()
# then
self.assertEqual(result, 0)
def test_should_count_events_within_timeframe_1(self):
# given
events = EventSeries("dummy")
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))
events.add(dt.datetime(2021, 12, 1, 12, 30, tzinfo=utc))
# when
result = events.count(
earliest=dt.datetime(2021, 12, 1, 12, 8, tzinfo=utc),
latest=dt.datetime(2021, 12, 1, 12, 17, tzinfo=utc),
)
# then
self.assertEqual(result, 2)
def test_should_count_events_within_timeframe_2(self):
# given
events = EventSeries("dummy")
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))
events.add(dt.datetime(2021, 12, 1, 12, 30, tzinfo=utc))
# when
result = events.count(earliest=dt.datetime(2021, 12, 1, 12, 8))
# then
self.assertEqual(result, 3)
def test_should_count_events_within_timeframe_3(self):
# given
events = EventSeries("dummy")
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))
events.add(dt.datetime(2021, 12, 1, 12, 30, tzinfo=utc))
# when
result = events.count(latest=dt.datetime(2021, 12, 1, 12, 12))
# then
self.assertEqual(result, 2)
def test_should_clear_events(self):
# given
events = EventSeries("dummy")
events.add()
events.add()
# when
events.clear()
# then
self.assertEqual(events.count(), 0)
def test_should_return_date_of_first_event(self):
# given
events = EventSeries("dummy")
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))
events.add(dt.datetime(2021, 12, 1, 12, 30, tzinfo=utc))
# when
result = events.first_event()
# then
self.assertEqual(result, dt.datetime(2021, 12, 1, 12, 0, tzinfo=utc))
def test_should_return_date_of_first_event_with_range(self):
# given
events = EventSeries("dummy")
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))
events.add(dt.datetime(2021, 12, 1, 12, 30, tzinfo=utc))
# when
result = events.first_event(
earliest=dt.datetime(2021, 12, 1, 12, 8, tzinfo=utc)
)
# then
self.assertEqual(result, dt.datetime(2021, 12, 1, 12, 10, tzinfo=utc))
def test_should_return_all_events(self):
# given
events = EventSeries("dummy")
events.add()
events.add()
# when
results = events.all()
# then
self.assertEqual(len(results), 2)

View File

@@ -0,0 +1,93 @@
from unittest.mock import patch
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.signals import (
reset_counters,
is_enabled,
)
from allianceauth.eveonline.tasks import update_character
@override_settings(
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()
# when
with patch(
"allianceauth.eveonline.tasks.EveCharacter.objects.update_character"
) as mock_update:
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)
def test_should_record_retried_task(self):
# given
succeeded_tasks.clear()
retried_tasks.clear()
failed_tasks.clear()
# when
with patch(
"allianceauth.eveonline.tasks.EveCharacter.objects.update_character"
) as mock_update:
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)
def test_should_record_failed_task(self):
# given
succeeded_tasks.clear()
retried_tasks.clear()
failed_tasks.clear()
# when
with patch(
"allianceauth.eveonline.tasks.EveCharacter.objects.update_character"
) as mock_update:
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)
def test_should_reset_counters(self):
# given
succeeded_tasks.add()
retried_tasks.add()
failed_tasks.add()
# when
reset_counters()
# then
self.assertEqual(succeeded_tasks.count(), 0)
self.assertEqual(retried_tasks.count(), 0)
self.assertEqual(failed_tasks.count(), 0)
class TestIsEnabled(TestCase):
@override_settings(ALLIANCEAUTH_DASHBOARD_TASK_STATISTICS_DISABLED=False)
def test_enabled(self):
self.assertTrue(is_enabled())
@override_settings(ALLIANCEAUTH_DASHBOARD_TASK_STATISTICS_DISABLED=True)
def test_disabled(self):
self.assertFalse(is_enabled())

View File

@@ -1,3 +1,4 @@
from bs4 import BeautifulSoup
from urllib.parse import quote from urllib.parse import quote
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
@@ -188,7 +189,7 @@ class TestCaseWithTestData(TestCase):
corporation_id=5432, corporation_id=5432,
corporation_name="Xavier's School for Gifted Youngsters", corporation_name="Xavier's School for Gifted Youngsters",
corporation_ticker='MUTNT', corporation_ticker='MUTNT',
alliance_id = None, alliance_id=None,
faction_id=999, faction_id=999,
faction_name='The X-Men', faction_name='The X-Men',
) )
@@ -206,6 +207,7 @@ class TestCaseWithTestData(TestCase):
cls.user_4.profile.save() cls.user_4.profile.save()
EveFactionInfo.objects.create(faction_id=999, faction_name='The X-Men') EveFactionInfo.objects.create(faction_id=999, faction_name='The X-Men')
def make_generic_search_request(ModelClass: type, search_term: str): def make_generic_search_request(ModelClass: type, search_term: str):
User.objects.create_superuser( User.objects.create_superuser(
username='superuser', password='secret', email='admin@example.com' username='superuser', password='secret', email='admin@example.com'
@@ -218,6 +220,7 @@ def make_generic_search_request(ModelClass: type, search_term: str):
class TestCharacterOwnershipAdmin(TestCaseWithTestData): class TestCharacterOwnershipAdmin(TestCaseWithTestData):
fixtures = ["disable_analytics"]
def setUp(self): def setUp(self):
self.modeladmin = CharacterOwnershipAdmin( self.modeladmin = CharacterOwnershipAdmin(
@@ -244,6 +247,7 @@ class TestCharacterOwnershipAdmin(TestCaseWithTestData):
class TestOwnershipRecordAdmin(TestCaseWithTestData): class TestOwnershipRecordAdmin(TestCaseWithTestData):
fixtures = ["disable_analytics"]
def setUp(self): def setUp(self):
self.modeladmin = OwnershipRecordAdmin( self.modeladmin = OwnershipRecordAdmin(
@@ -270,6 +274,7 @@ class TestOwnershipRecordAdmin(TestCaseWithTestData):
class TestStateAdmin(TestCaseWithTestData): class TestStateAdmin(TestCaseWithTestData):
fixtures = ["disable_analytics"]
def setUp(self): def setUp(self):
self.modeladmin = StateAdmin( self.modeladmin = StateAdmin(
@@ -299,6 +304,7 @@ class TestStateAdmin(TestCaseWithTestData):
class TestUserAdmin(TestCaseWithTestData): class TestUserAdmin(TestCaseWithTestData):
fixtures = ["disable_analytics"]
def setUp(self): def setUp(self):
self.factory = RequestFactory() self.factory = RequestFactory()
@@ -344,7 +350,7 @@ class TestUserAdmin(TestCaseWithTestData):
self.assertEqual(user_main_organization(self.user_3), expected) self.assertEqual(user_main_organization(self.user_3), expected)
def test_user_main_organization_u4(self): def test_user_main_organization_u4(self):
expected="Xavier's School for Gifted Youngsters<br>The X-Men" expected = "Xavier's School for Gifted Youngsters<br>The X-Men"
self.assertEqual(user_main_organization(self.user_4), expected) self.assertEqual(user_main_organization(self.user_4), expected)
def test_characters_u1(self): def test_characters_u1(self):
@@ -537,6 +543,42 @@ class TestUserAdmin(TestCaseWithTestData):
self.assertEqual(response.status_code, expected) self.assertEqual(response.status_code, expected)
class TestUserAdminChangeForm(TestCase):
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
cls.modeladmin = UserAdmin(model=User, admin_site=AdminSite())
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")
character = AuthUtils.add_main_character_2(
user,
name="Bruce Wayne",
character_id=1001,
corp_id=2001,
corp_name="Wayne Technologies"
)
blue_state = State.objects.get(name="Blue")
blue_state.member_characters.add(character)
member_state = AuthUtils.get_member_state()
group_1 = Group.objects.create(name="Group 1")
group_2 = Group.objects.create(name="Group 2")
group_2.authgroup.states.add(blue_state)
group_3 = Group.objects.create(name="Group 3")
group_3.authgroup.states.add(member_state)
self.client.force_login(superuser)
# when
response = self.client.get(f"/admin/authentication/user/{user.pk}/change/")
# then
self.assertEqual(response.status_code, 200)
soup = BeautifulSoup(response.rendered_content, features="html.parser")
groups_select = soup.find("select", {"id": "id_groups"}).find_all('option')
group_ids = {int(option["value"]) for option in groups_select}
self.assertSetEqual(group_ids, {group_1.pk, group_2.pk})
class TestMakeServicesHooksActions(TestCaseWithTestData): class TestMakeServicesHooksActions(TestCaseWithTestData):
class MyServicesHookTypeA(ServicesHook): class MyServicesHookTypeA(ServicesHook):

View File

@@ -55,7 +55,6 @@ TEST_VERSION = '2.6.5'
class TestStatusOverviewTag(TestCase): class TestStatusOverviewTag(TestCase):
@patch(MODULE_PATH + '.admin_status.__version__', TEST_VERSION) @patch(MODULE_PATH + '.admin_status.__version__', TEST_VERSION)
@patch(MODULE_PATH + '.admin_status._fetch_celery_queue_length') @patch(MODULE_PATH + '.admin_status._fetch_celery_queue_length')
@patch(MODULE_PATH + '.admin_status._current_version_summary') @patch(MODULE_PATH + '.admin_status._current_version_summary')
@@ -66,6 +65,7 @@ class TestStatusOverviewTag(TestCase):
mock_current_version_info, mock_current_version_info,
mock_fetch_celery_queue_length mock_fetch_celery_queue_length
): ):
# given
notifications = { notifications = {
'notifications': GITHUB_NOTIFICATION_ISSUES[:5] 'notifications': GITHUB_NOTIFICATION_ISSUES[:5]
} }
@@ -83,22 +83,20 @@ class TestStatusOverviewTag(TestCase):
} }
mock_current_version_info.return_value = version_info mock_current_version_info.return_value = version_info
mock_fetch_celery_queue_length.return_value = 3 mock_fetch_celery_queue_length.return_value = 3
# when
result = status_overview() result = status_overview()
expected = { # then
'notifications': GITHUB_NOTIFICATION_ISSUES[:5], self.assertEqual(result["notifications"], GITHUB_NOTIFICATION_ISSUES[:5])
'latest_major': True, self.assertTrue(result["latest_major"])
'latest_minor': True, self.assertTrue(result["latest_minor"])
'latest_patch': True, self.assertTrue(result["latest_patch"])
'latest_beta': False, self.assertFalse(result["latest_beta"])
'current_version': TEST_VERSION, self.assertEqual(result["current_version"], TEST_VERSION)
'latest_major_version': '2.4.5', self.assertEqual(result["latest_major_version"], '2.4.5')
'latest_minor_version': '2.4.0', self.assertEqual(result["latest_minor_version"], '2.4.0')
'latest_patch_version': '2.4.5', self.assertEqual(result["latest_patch_version"], '2.4.5')
'latest_beta_version': '2.4.4a1', self.assertEqual(result["latest_beta_version"], '2.4.4a1')
'task_queue_length': 3, self.assertEqual(result["task_queue_length"], 3)
}
self.assertEqual(result, expected)
class TestNotifications(TestCase): class TestNotifications(TestCase):

View File

@@ -193,6 +193,8 @@
"columnDefs": [ "columnDefs": [
{ "sortable": false, "targets": [1] }, { "sortable": false, "targets": [1] },
], ],
"stateSave": true,
"stateDuration": 0
}); });
$('#table-members').DataTable({ $('#table-members').DataTable({
"columnDefs": [ "columnDefs": [
@@ -200,6 +202,8 @@
{ "sortable": false, "targets": [0, 2] }, { "sortable": false, "targets": [0, 2] },
], ],
"order": [[ 1, "asc" ]], "order": [[ 1, "asc" ]],
"stateSave": true,
"stateDuration": 0
}); });
$('#table-unregistered').DataTable({ $('#table-unregistered').DataTable({
"columnDefs": [ "columnDefs": [
@@ -207,6 +211,8 @@
{ "sortable": false, "targets": [0, 2] }, { "sortable": false, "targets": [0, 2] },
], ],
"order": [[ 1, "asc" ]], "order": [[ 1, "asc" ]],
"stateSave": true,
"stateDuration": 0
}); });
}); });

View File

@@ -43,6 +43,9 @@
{% endblock %} {% endblock %}
{% block extra_script %} {% block extra_script %}
$(document).ready(function(){ $(document).ready(function(){
$('#table-search').DataTable(); $('#table-search').DataTable({
"stateSave": true,
"stateDuration": 0
});
}); });
{% endblock %} {% endblock %}

View File

@@ -1,13 +1,27 @@
from django.db import models import logging
from typing import Union from typing import Union
from .managers import EveCharacterManager, EveCharacterProviderManager from django.core.exceptions import ObjectDoesNotExist
from .managers import EveCorporationManager, EveCorporationProviderManager from django.db import models
from .managers import EveAllianceManager, EveAllianceProviderManager from esi.models import Token
from allianceauth.notifications import notify
from . import providers from . import providers
from .evelinks import eveimageserver from .evelinks import eveimageserver
from .managers import (
EveAllianceManager,
EveAllianceProviderManager,
EveCharacterManager,
EveCharacterProviderManager,
EveCorporationManager,
EveCorporationProviderManager,
)
logger = logging.getLogger(__name__)
_DEFAULT_IMAGE_SIZE = 32 _DEFAULT_IMAGE_SIZE = 32
DOOMHEIM_CORPORATION_ID = 1000001
class EveFactionInfo(models.Model): class EveFactionInfo(models.Model):
@@ -68,13 +82,12 @@ class EveAllianceInfo(models.Model):
for corp_id in alliance.corp_ids: for corp_id in alliance.corp_ids:
if not EveCorporationInfo.objects.filter(corporation_id=corp_id).exists(): if not EveCorporationInfo.objects.filter(corporation_id=corp_id).exists():
EveCorporationInfo.objects.create_corporation(corp_id) EveCorporationInfo.objects.create_corporation(corp_id)
EveCorporationInfo.objects.filter( EveCorporationInfo.objects.filter(corporation_id__in=alliance.corp_ids).update(
corporation_id__in=alliance.corp_ids).update(alliance=self alliance=self
) )
EveCorporationInfo.objects\ EveCorporationInfo.objects.filter(alliance=self).exclude(
.filter(alliance=self)\ corporation_id__in=alliance.corp_ids
.exclude(corporation_id__in=alliance.corp_ids)\ ).update(alliance=None)
.update(alliance=None)
def update_alliance(self, alliance: providers.Alliance = None): def update_alliance(self, alliance: providers.Alliance = None):
if alliance is None: if alliance is None:
@@ -182,6 +195,7 @@ class EveCorporationInfo(models.Model):
class EveCharacter(models.Model): class EveCharacter(models.Model):
"""Character in Eve Online"""
character_id = models.PositiveIntegerField(unique=True) character_id = models.PositiveIntegerField(unique=True)
character_name = models.CharField(max_length=254, unique=True) character_name = models.CharField(max_length=254, unique=True)
corporation_id = models.PositiveIntegerField() corporation_id = models.PositiveIntegerField()
@@ -198,12 +212,20 @@ class EveCharacter(models.Model):
class Meta: class Meta:
indexes = [ indexes = [
models.Index(fields=['corporation_id',]), models.Index(fields=['corporation_id',]),
models.Index(fields=['alliance_id',]), models.Index(fields=['alliance_id',]),
models.Index(fields=['corporation_name',]), models.Index(fields=['corporation_name',]),
models.Index(fields=['alliance_name',]), models.Index(fields=['alliance_name',]),
models.Index(fields=['faction_id',]), models.Index(fields=['faction_id',]),
] ]
def __str__(self):
return self.character_name
@property
def is_biomassed(self) -> bool:
"""Whether this character is dead or not."""
return self.corporation_id == DOOMHEIM_CORPORATION_ID
@property @property
def alliance(self) -> Union[EveAllianceInfo, None]: def alliance(self) -> Union[EveAllianceInfo, None]:
@@ -249,10 +271,36 @@ class EveCharacter(models.Model):
self.faction_id = character.faction.id self.faction_id = character.faction.id
self.faction_name = character.faction.name self.faction_name = character.faction.name
self.save() self.save()
if self.is_biomassed:
self._remove_tokens_of_biomassed_character()
return self return self
def __str__(self): def _remove_tokens_of_biomassed_character(self) -> None:
return self.character_name """Remove tokens of this biomassed character."""
try:
user = self.character_ownership.user
except ObjectDoesNotExist:
return
tokens_to_delete = Token.objects.filter(character_id=self.character_id)
tokens_count = tokens_to_delete.count()
if not tokens_count:
return
tokens_to_delete.delete()
logger.info(
"%d tokens from user %s for biomassed character %s [id:%s] deleted.",
tokens_count,
user,
self,
self.character_id,
)
notify(
user=user,
title=f"Character {self} biomassed",
message=(
f"Your former character {self} has been biomassed "
"and has been removed from the list of your alts."
)
)
@staticmethod @staticmethod
def generic_portrait_url( def generic_portrait_url(
@@ -336,7 +384,6 @@ class EveCharacter(models.Model):
"""image URL for alliance of this character or empty string""" """image URL for alliance of this character or empty string"""
return self.alliance_logo_url(256) return self.alliance_logo_url(256)
def faction_logo_url(self, size=_DEFAULT_IMAGE_SIZE) -> str: def faction_logo_url(self, size=_DEFAULT_IMAGE_SIZE) -> str:
"""image URL for alliance of this character or empty string""" """image URL for alliance of this character or empty string"""
if self.faction_id: if self.faction_id:

View File

@@ -13,17 +13,18 @@ from allianceauth import __version__
SWAGGER_SPEC_PATH = os.path.join(os.path.dirname( SWAGGER_SPEC_PATH = os.path.join(os.path.dirname(
os.path.abspath(__file__)), 'swagger.json' os.path.abspath(__file__)), 'swagger.json'
) )
"""
Swagger spec operations:
get_alliances_alliance_id # for the love of Bob please add operations you use here. I'm tired of breaking undocumented things.
get_alliances_alliance_id_corporations ESI_OPERATIONS=[
get_corporations_corporation_id 'get_alliances_alliance_id',
get_characters_character_id 'get_alliances_alliance_id_corporations',
get_universe_types_type_id 'get_corporations_corporation_id',
post_character_affiliation 'get_characters_character_id',
get_universe_factions 'post_characters_affiliation',
""" 'get_universe_types_type_id',
'get_universe_factions',
'post_universe_names',
]
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -169,7 +170,7 @@ class EveProvider:
""" """
:return: an ItemType object for the given ID :return: an ItemType object for the given ID
""" """
raise NotImplemented() raise NotImplementedError()
class EveSwaggerProvider(EveProvider): class EveSwaggerProvider(EveProvider):
@@ -206,7 +207,8 @@ class EveSwaggerProvider(EveProvider):
def __str__(self): def __str__(self):
return 'esi' return 'esi'
def get_alliance(self, alliance_id): def get_alliance(self, alliance_id: int) -> Alliance:
"""Fetch alliance from ESI."""
try: try:
data = self.client.Alliance.get_alliances_alliance_id(alliance_id=alliance_id).result() data = self.client.Alliance.get_alliances_alliance_id(alliance_id=alliance_id).result()
corps = self.client.Alliance.get_alliances_alliance_id_corporations(alliance_id=alliance_id).result() corps = self.client.Alliance.get_alliances_alliance_id_corporations(alliance_id=alliance_id).result()
@@ -222,7 +224,8 @@ class EveSwaggerProvider(EveProvider):
except HTTPNotFound: except HTTPNotFound:
raise ObjectNotFound(alliance_id, 'alliance') raise ObjectNotFound(alliance_id, 'alliance')
def get_corp(self, corp_id): def get_corp(self, corp_id: int) -> Corporation:
"""Fetch corporation from ESI."""
try: try:
data = self.client.Corporation.get_corporations_corporation_id(corporation_id=corp_id).result() data = self.client.Corporation.get_corporations_corporation_id(corporation_id=corp_id).result()
model = Corporation( model = Corporation(
@@ -238,29 +241,43 @@ class EveSwaggerProvider(EveProvider):
except HTTPNotFound: except HTTPNotFound:
raise ObjectNotFound(corp_id, 'corporation') raise ObjectNotFound(corp_id, 'corporation')
def get_character(self, character_id): def get_character(self, character_id: int) -> Character:
"""Fetch character from ESI."""
try: try:
data = self.client.Character.get_characters_character_id(character_id=character_id).result() character_name = self._fetch_character_name(character_id)
affiliation = self.client.Character.post_characters_affiliation(characters=[character_id]).result()[0] affiliation = self.client.Character.post_characters_affiliation(characters=[character_id]).result()[0]
model = Character( model = Character(
id=character_id, id=character_id,
name=data['name'], name=character_name,
corp_id=affiliation['corporation_id'], corp_id=affiliation['corporation_id'],
alliance_id=affiliation['alliance_id'] if 'alliance_id' in affiliation else None, alliance_id=affiliation['alliance_id'] if 'alliance_id' in affiliation else None,
faction_id=affiliation['faction_id'] if 'faction_id' in affiliation else None, faction_id=affiliation['faction_id'] if 'faction_id' in affiliation else None,
) )
return model return model
except (HTTPNotFound, HTTPUnprocessableEntity): except (HTTPNotFound, HTTPUnprocessableEntity, ObjectNotFound):
raise ObjectNotFound(character_id, 'character') raise ObjectNotFound(character_id, 'character')
def _fetch_character_name(self, character_id: int) -> str:
"""Fetch character name from ESI."""
data = self.client.Universe.post_universe_names(ids=[character_id]).result()
character = data.pop() if data else None
if (
not character
or character["category"] != "character"
or character["id"] != character_id
):
raise ObjectNotFound(character_id, 'character')
return character["name"]
def get_all_factions(self): def get_all_factions(self):
"""Fetch all factions from ESI."""
if not self._faction_list: if not self._faction_list:
self._faction_list = self.client.Universe.get_universe_factions().result() self._faction_list = self.client.Universe.get_universe_factions().result()
return self._faction_list return self._faction_list
def get_faction(self, faction_id): def get_faction(self, faction_id: int):
faction_id=int(faction_id) """Fetch faction from ESI."""
faction_id = int(faction_id)
try: try:
if not self._faction_list: if not self._faction_list:
_ = self.get_all_factions() _ = self.get_all_factions()
@@ -272,7 +289,8 @@ class EveSwaggerProvider(EveProvider):
except (HTTPNotFound, HTTPUnprocessableEntity, KeyError): except (HTTPNotFound, HTTPUnprocessableEntity, KeyError):
raise ObjectNotFound(faction_id, 'faction') raise ObjectNotFound(faction_id, 'faction')
def get_itemtype(self, type_id): def get_itemtype(self, type_id: int) -> ItemType:
"""Fetch inventory item from ESI."""
try: try:
data = self.client.Universe.get_universe_types_type_id(type_id=type_id).result() data = self.client.Universe.get_universe_types_type_id(type_id=type_id).result()
return ItemType(id=type_id, name=data['name']) return ItemType(id=type_id, name=data['name'])

File diff suppressed because one or more lines are too long

View File

@@ -1,12 +1,11 @@
import logging import logging
from celery import shared_task from celery import shared_task
from .models import EveAllianceInfo
from .models import EveCharacter
from .models import EveCorporationInfo
from .models import EveAllianceInfo, EveCharacter, EveCorporationInfo
from . import providers from . import providers
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
TASK_PRIORITY = 7 TASK_PRIORITY = 7
@@ -32,8 +31,8 @@ def update_alliance(alliance_id):
@shared_task @shared_task
def update_character(character_id): def update_character(character_id: int) -> None:
"""Update given character from ESI""" """Update given character from ESI."""
EveCharacter.objects.update_character(character_id) EveCharacter.objects.update_character(character_id)
@@ -65,17 +64,17 @@ def update_character_chunk(character_ids_chunk: list):
.post_characters_affiliation(characters=character_ids_chunk).result() .post_characters_affiliation(characters=character_ids_chunk).result()
character_names = providers.provider.client.Universe\ character_names = providers.provider.client.Universe\
.post_universe_names(ids=character_ids_chunk).result() .post_universe_names(ids=character_ids_chunk).result()
except: except OSError:
logger.info("Failed to bulk update characters. Attempting single updates") logger.info("Failed to bulk update characters. Attempting single updates")
for character_id in character_ids_chunk: for character_id in character_ids_chunk:
update_character.apply_async( update_character.apply_async(
args=[character_id], priority=TASK_PRIORITY args=[character_id], priority=TASK_PRIORITY
) )
return return
affiliations = { affiliations = {
affiliation.get('character_id'): affiliation affiliation.get('character_id'): affiliation
for affiliation in affiliations_raw for affiliation in affiliations_raw
} }
# add character names to affiliations # add character names to affiliations
for character in character_names: for character in character_names:
@@ -108,5 +107,5 @@ def update_character_chunk(character_ids_chunk: list):
if corp_changed or alliance_changed or name_changed: if corp_changed or alliance_changed or name_changed:
update_character.apply_async( update_character.apply_async(
args=[character.get('character_id')], priority=TASK_PRIORITY args=[character.get('character_id')], priority=TASK_PRIORITY
) )

View File

@@ -0,0 +1,168 @@
from bravado.exception import HTTPNotFound
class BravadoResponseStub:
"""Stub for IncomingResponse in bravado, e.g. for HTTPError exceptions"""
def __init__(
self, status_code, reason="", text="", headers=None, raw_bytes=None
) -> None:
self.reason = reason
self.status_code = status_code
self.text = text
self.headers = headers if headers else dict()
self.raw_bytes = raw_bytes
def __str__(self):
return f"{self.status_code} {self.reason}"
class BravadoOperationStub:
"""Stub to simulate the operation object return from bravado via django-esi"""
class RequestConfig:
def __init__(self, also_return_response):
self.also_return_response = also_return_response
class ResponseStub:
def __init__(self, headers):
self.headers = headers
def __init__(self, data, headers: dict = None, also_return_response: bool = False):
self._data = data
self._headers = headers if headers else {"x-pages": 1}
self.request_config = BravadoOperationStub.RequestConfig(also_return_response)
def result(self, **kwargs):
if self.request_config.also_return_response:
return [self._data, self.ResponseStub(self._headers)]
else:
return self._data
def results(self, **kwargs):
return self.result(**kwargs)
class EsiClientStub:
"""Stub for an ESI client."""
class Alliance:
@staticmethod
def get_alliances_alliance_id(alliance_id):
data = {
3001: {
"name": "Wayne Enterprises",
"ticker": "WYE",
"executor_corporation_id": 2001
}
}
try:
return BravadoOperationStub(data[int(alliance_id)])
except KeyError:
response = BravadoResponseStub(
404, f"Alliance with ID {alliance_id} not found"
)
raise HTTPNotFound(response)
@staticmethod
def get_alliances_alliance_id_corporations(alliance_id):
data = [2001, 2002, 2003]
return BravadoOperationStub(data)
class Character:
@staticmethod
def get_characters_character_id(character_id):
data = {
1001: {
"corporation_id": 2001,
"name": "Bruce Wayne",
},
1002: {
"corporation_id": 2001,
"name": "Peter Parker",
},
1011: {
"corporation_id": 2011,
"name": "Lex Luthor",
}
}
try:
return BravadoOperationStub(data[int(character_id)])
except KeyError:
response = BravadoResponseStub(
404, f"Character with ID {character_id} not found"
)
raise HTTPNotFound(response)
@staticmethod
def post_characters_affiliation(characters: list):
data = [
{'character_id': 1001, 'corporation_id': 2001, 'alliance_id': 3001},
{'character_id': 1002, 'corporation_id': 2001, 'alliance_id': 3001},
{'character_id': 1011, 'corporation_id': 2011},
{'character_id': 1666, 'corporation_id': 1000001},
]
return BravadoOperationStub(
[x for x in data if x['character_id'] in characters]
)
class Corporation:
@staticmethod
def get_corporations_corporation_id(corporation_id):
data = {
2001: {
"ceo_id": 1091,
"member_count": 10,
"name": "Wayne Technologies",
"ticker": "WTE",
"alliance_id": 3001
},
2002: {
"ceo_id": 1092,
"member_count": 10,
"name": "Wayne Food",
"ticker": "WFO",
"alliance_id": 3001
},
2003: {
"ceo_id": 1093,
"member_count": 10,
"name": "Wayne Energy",
"ticker": "WEG",
"alliance_id": 3001
},
2011: {
"ceo_id": 1,
"member_count": 3,
"name": "LexCorp",
"ticker": "LC",
},
1000001: {
"ceo_id": 3000001,
"creator_id": 1,
"description": "The internal corporation used for characters in graveyard.",
"member_count": 6329026,
"name": "Doomheim",
"ticker": "666",
}
}
try:
return BravadoOperationStub(data[int(corporation_id)])
except KeyError:
response = BravadoResponseStub(
404, f"Corporation with ID {corporation_id} not found"
)
raise HTTPNotFound(response)
class Universe:
@staticmethod
def post_universe_names(ids: list):
data = [
{"category": "character", "id": 1001, "name": "Bruce Wayne"},
{"category": "character", "id": 1002, "name": "Peter Parker"},
{"category": "character", "id": 1011, "name": "Lex Luthor"},
{"category": "character", "id": 1666, "name": "Hal Jordan"},
{"category": "corporation", "id": 2001, "name": "Wayne Technologies"},
{"category": "corporation","id": 2002, "name": "Wayne Food"},
{"category": "corporation","id": 1000001, "name": "Doomheim"},
]
return BravadoOperationStub([x for x in data if x['id'] in ids])

View File

@@ -1,12 +1,15 @@
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from django.core.exceptions import ObjectDoesNotExist
from django.test import TestCase from django.test import TestCase
from esi.models import Token
from allianceauth.tests.auth_utils import AuthUtils
from ..models import (
EveCharacter, EveCorporationInfo, EveAllianceInfo, EveFactionInfo
)
from ..providers import Alliance, Corporation, Character
from ..evelinks import eveimageserver from ..evelinks import eveimageserver
from ..models import EveAllianceInfo, EveCharacter, EveCorporationInfo, EveFactionInfo
from ..providers import Alliance, Character, Corporation
from .esi_client_stub import EsiClientStub
class EveCharacterTestCase(TestCase): class EveCharacterTestCase(TestCase):
@@ -402,8 +405,8 @@ class EveAllianceTestCase(TestCase):
my_alliance.save() my_alliance.save()
my_alliance.populate_alliance() my_alliance.populate_alliance()
for corporation in EveCorporationInfo.objects\ for corporation in (
.filter(corporation_id__in=[2001, 2002] EveCorporationInfo.objects.filter(corporation_id__in=[2001, 2002])
): ):
self.assertEqual(corporation.alliance, my_alliance) self.assertEqual(corporation.alliance, my_alliance)
@@ -587,3 +590,98 @@ class EveCorporationTestCase(TestCase):
self.my_corp.logo_url_256, self.my_corp.logo_url_256,
'https://images.evetech.net/corporations/2001/logo?size=256' 'https://images.evetech.net/corporations/2001/logo?size=256'
) )
@patch('allianceauth.eveonline.providers.esi_client_factory')
@patch("allianceauth.eveonline.models.notify")
class TestCharacterUpdate(TestCase):
def test_should_update_normal_character(self, mock_notify, mock_esi_client_factory):
# given
mock_esi_client_factory.return_value = EsiClientStub()
my_character = EveCharacter.objects.create(
character_id=1001,
character_name="not my name",
corporation_id=2002,
corporation_name="Wayne Food",
corporation_ticker="WYF",
alliance_id=None
)
# when
my_character.update_character()
# then
my_character.refresh_from_db()
self.assertEqual(my_character.character_name, "Bruce Wayne")
self.assertEqual(my_character.corporation_id, 2001)
self.assertEqual(my_character.corporation_name, "Wayne Technologies")
self.assertEqual(my_character.corporation_ticker, "WTE")
self.assertEqual(my_character.alliance_id, 3001)
self.assertEqual(my_character.alliance_name, "Wayne Enterprises")
self.assertEqual(my_character.alliance_ticker, "WYE")
self.assertFalse(mock_notify.called)
def test_should_update_dead_character_with_owner(
self, mock_notify, mock_esi_client_factory
):
# given
mock_esi_client_factory.return_value = EsiClientStub()
character_1666 = EveCharacter.objects.create(
character_id=1666,
character_name="Hal Jordan",
corporation_id=2002,
corporation_name="Wayne Food",
corporation_ticker="WYF",
alliance_id=None
)
user = AuthUtils.create_user("Bruce Wayne")
token_1666 = Token.objects.create(
user=user,
character_id=character_1666.character_id,
character_name=character_1666.character_name,
character_owner_hash="ABC123-1666",
)
character_1001 = EveCharacter.objects.create(
character_id=1001,
character_name="Bruce Wayne",
corporation_id=2001,
corporation_name="Wayne Technologies",
corporation_ticker="WYT",
alliance_id=None
)
token_1001 = Token.objects.create(
user=user,
character_id=character_1001.character_id,
character_name=character_1001.character_name,
character_owner_hash="ABC123-1001",
)
# when
character_1666.update_character()
# then
character_1666.refresh_from_db()
self.assertTrue(character_1666.is_biomassed)
self.assertNotIn(token_1666, user.token_set.all())
self.assertIn(token_1001, user.token_set.all())
with self.assertRaises(ObjectDoesNotExist):
self.assertTrue(character_1666.character_ownership)
user.profile.refresh_from_db()
self.assertIsNone(user.profile.main_character)
self.assertTrue(mock_notify.called)
def test_should_handle_dead_character_without_owner(
self, mock_notify, mock_esi_client_factory
):
# given
mock_esi_client_factory.return_value = EsiClientStub()
character_1666 = EveCharacter.objects.create(
character_id=1666,
character_name="Hal Jordan",
corporation_id=1011,
corporation_name="LexCorp",
corporation_ticker='LC',
alliance_id=None
)
# when
character_1666.update_character()
# then
character_1666.refresh_from_db()
self.assertTrue(character_1666.is_biomassed)
self.assertFalse(mock_notify.called)

View File

@@ -7,6 +7,7 @@ from jsonschema.exceptions import RefResolutionError
from django.test import TestCase from django.test import TestCase
from . import set_logger from . import set_logger
from .esi_client_stub import EsiClientStub
from ..providers import ( from ..providers import (
ObjectNotFound, ObjectNotFound,
Entity, Entity,
@@ -632,13 +633,7 @@ class TestEveSwaggerProvider(TestCase):
@patch(MODULE_PATH + '.esi_client_factory') @patch(MODULE_PATH + '.esi_client_factory')
def test_get_character(self, mock_esi_client_factory): def test_get_character(self, mock_esi_client_factory):
mock_esi_client_factory.return_value \ mock_esi_client_factory.return_value = EsiClientStub()
.Character.get_characters_character_id \
= TestEveSwaggerProvider.esi_get_characters_character_id
mock_esi_client_factory.return_value \
.Character.post_characters_affiliation \
= TestEveSwaggerProvider.esi_post_characters_affiliation
my_provider = EveSwaggerProvider() my_provider = EveSwaggerProvider()
# character with alliance # character with alliance
@@ -649,8 +644,8 @@ class TestEveSwaggerProvider(TestCase):
self.assertEqual(my_character.alliance_id, 3001) self.assertEqual(my_character.alliance_id, 3001)
# character wo/ alliance # character wo/ alliance
my_character = my_provider.get_character(1002) my_character = my_provider.get_character(1011)
self.assertEqual(my_character.id, 1002) self.assertEqual(my_character.id, 1011)
self.assertEqual(my_character.alliance_id, None) self.assertEqual(my_character.alliance_id, None)
# character not found # character not found

View File

@@ -1,245 +1,271 @@
from unittest.mock import patch, Mock from unittest.mock import patch
from django.test import TestCase from django.test import TestCase, TransactionTestCase, override_settings
from ..models import EveCharacter, EveCorporationInfo, EveAllianceInfo from ..models import EveAllianceInfo, EveCharacter, EveCorporationInfo
from ..tasks import ( from ..tasks import (
run_model_update,
update_alliance, update_alliance,
update_corp,
update_character, update_character,
run_model_update update_character_chunk,
update_corp,
) )
from .esi_client_stub import EsiClientStub
class TestTasks(TestCase): @patch('allianceauth.eveonline.providers.esi_client_factory')
class TestUpdateTasks(TestCase):
@patch('allianceauth.eveonline.tasks.EveCorporationInfo') def test_should_update_alliance(self, mock_esi_client_factory):
def test_update_corp(self, mock_EveCorporationInfo): # given
update_corp(42) mock_esi_client_factory.return_value = EsiClientStub()
self.assertEqual( my_alliance = EveAllianceInfo.objects.create(
mock_EveCorporationInfo.objects.update_corporation.call_count, 1 alliance_id=3001,
) alliance_name="Wayne Enterprises",
self.assertEqual( alliance_ticker="WYE",
mock_EveCorporationInfo.objects.update_corporation.call_args[0][0], 42 executor_corp_id=2003
) )
# when
update_alliance(my_alliance.alliance_id)
# then
my_alliance.refresh_from_db()
self.assertEqual(my_alliance.executor_corp_id, 2001)
@patch('allianceauth.eveonline.tasks.EveAllianceInfo') def test_should_update_character(self, mock_esi_client_factory):
def test_update_alliance(self, mock_EveAllianceInfo): # given
update_alliance(42) mock_esi_client_factory.return_value = EsiClientStub()
self.assertEqual( my_character = EveCharacter.objects.create(
mock_EveAllianceInfo.objects.update_alliance.call_args[0][0], 42 character_id=1001,
) character_name="Bruce Wayne",
self.assertEqual( corporation_id=2002,
mock_EveAllianceInfo.objects corporation_name="Wayne Food",
.update_alliance.return_value.populate_alliance.call_count, 1 corporation_ticker="WYF",
alliance_id=None
) )
# when
update_character(my_character.character_id)
# then
my_character.refresh_from_db()
self.assertEqual(my_character.corporation_id, 2001)
@patch('allianceauth.eveonline.tasks.EveCharacter') def test_should_update_corp(self, mock_esi_client_factory):
def test_update_character(self, mock_EveCharacter): # given
update_character(42) mock_esi_client_factory.return_value = EsiClientStub()
self.assertEqual( EveAllianceInfo.objects.create(
mock_EveCharacter.objects.update_character.call_count, 1 alliance_id=3001,
alliance_name="Wayne Enterprises",
alliance_ticker="WYE",
executor_corp_id=2003
) )
self.assertEqual( my_corporation = EveCorporationInfo.objects.create(
mock_EveCharacter.objects.update_character.call_args[0][0], 42 corporation_id=2003,
corporation_name="Wayne Food",
corporation_ticker="WFO",
member_count=1,
alliance=None,
ceo_id=1999
) )
# when
update_corp(my_corporation.corporation_id)
# then
my_corporation.refresh_from_db()
self.assertEqual(my_corporation.alliance.alliance_id, 3001)
# @patch('allianceauth.eveonline.tasks.EveCharacter')
# def test_update_character(self, mock_EveCharacter):
# update_character(42)
# self.assertEqual(
# mock_EveCharacter.objects.update_character.call_count, 1
# )
# self.assertEqual(
# mock_EveCharacter.objects.update_character.call_args[0][0], 42
# )
@patch('allianceauth.eveonline.tasks.update_character') @override_settings(CELERY_ALWAYS_EAGER=True)
@patch('allianceauth.eveonline.tasks.update_alliance') @patch('allianceauth.eveonline.providers.esi_client_factory')
@patch('allianceauth.eveonline.tasks.update_corp') @patch('allianceauth.eveonline.tasks.providers')
@patch('allianceauth.eveonline.providers.provider')
@patch('allianceauth.eveonline.tasks.CHUNK_SIZE', 2) @patch('allianceauth.eveonline.tasks.CHUNK_SIZE', 2)
class TestRunModelUpdate(TestCase): class TestRunModelUpdate(TransactionTestCase):
def test_should_run_updates(self, mock_providers, mock_esi_client_factory):
@classmethod # given
def setUpClass(cls): mock_providers.provider.client = EsiClientStub()
super().setUpClass() mock_esi_client_factory.return_value = EsiClientStub()
EveCorporationInfo.objects.all().delete()
EveAllianceInfo.objects.all().delete()
EveCharacter.objects.all().delete()
EveCorporationInfo.objects.create( EveCorporationInfo.objects.create(
corporation_id=2345, corporation_id=2001,
corporation_name='corp.name', corporation_name="Wayne Technologies",
corporation_ticker='c.c.t', corporation_ticker="WTE",
member_count=10, member_count=10,
alliance=None, alliance=None,
) )
EveAllianceInfo.objects.create( alliance_3001 = EveAllianceInfo.objects.create(
alliance_id=3456, alliance_id=3001,
alliance_name='alliance.name', alliance_name="Wayne Enterprises",
alliance_ticker='a.t', alliance_ticker="WYE",
executor_corp_id=5, executor_corp_id=2003
) )
EveCharacter.objects.create( corporation_2003 = EveCorporationInfo.objects.create(
character_id=1, corporation_id=2003,
character_name='character.name1', corporation_name="Wayne Energy",
corporation_id=2345, corporation_ticker="WEG",
corporation_name='character.corp.name', member_count=99,
corporation_ticker='c.c.t', # max 5 chars alliance=None,
)
character_1001 = EveCharacter.objects.create(
character_id=1001,
character_name="Bruce Wayne",
corporation_id=2002,
corporation_name="Wayne Food",
corporation_ticker="WYF",
alliance_id=None alliance_id=None
) )
EveCharacter.objects.create( # when
character_id=2,
character_name='character.name2',
corporation_id=9876,
corporation_name='character.corp.name',
corporation_ticker='c.c.t', # max 5 chars
alliance_id=3456,
alliance_name='character.alliance.name',
)
EveCharacter.objects.create(
character_id=3,
character_name='character.name3',
corporation_id=9876,
corporation_name='character.corp.name',
corporation_ticker='c.c.t', # max 5 chars
alliance_id=3456,
alliance_name='character.alliance.name',
)
EveCharacter.objects.create(
character_id=4,
character_name='character.name4',
corporation_id=9876,
corporation_name='character.corp.name',
corporation_ticker='c.c.t', # max 5 chars
alliance_id=3456,
alliance_name='character.alliance.name',
)
"""
EveCharacter.objects.create(
character_id=5,
character_name='character.name5',
corporation_id=9876,
corporation_name='character.corp.name',
corporation_ticker='c.c.t', # max 5 chars
alliance_id=3456,
alliance_name='character.alliance.name',
)
"""
def setUp(self):
self.affiliations = [
{'character_id': 1, 'corporation_id': 5},
{'character_id': 2, 'corporation_id': 9876, 'alliance_id': 3456},
{'character_id': 3, 'corporation_id': 9876, 'alliance_id': 7456},
{'character_id': 4, 'corporation_id': 9876, 'alliance_id': 3456}
]
self.names = [
{'id': 1, 'name': 'character.name1'},
{'id': 2, 'name': 'character.name2'},
{'id': 3, 'name': 'character.name3'},
{'id': 4, 'name': 'character.name4_new'}
]
def test_normal_run(
self,
mock_provider,
mock_update_corp,
mock_update_alliance,
mock_update_character,
):
def get_affiliations(characters: list):
response = [x for x in self.affiliations if x['character_id'] in characters]
mock_operator = Mock(**{'result.return_value': response})
return mock_operator
def get_names(ids: list):
response = [x for x in self.names if x['id'] in ids]
mock_operator = Mock(**{'result.return_value': response})
return mock_operator
mock_provider.client.Character.post_characters_affiliation.side_effect \
= get_affiliations
mock_provider.client.Universe.post_universe_names.side_effect = get_names
run_model_update() run_model_update()
# then
character_1001.refresh_from_db()
self.assertEqual( self.assertEqual(
mock_provider.client.Character.post_characters_affiliation.call_count, 2 character_1001.corporation_id, 2001 # char has new corp
) )
corporation_2003.refresh_from_db()
self.assertEqual( self.assertEqual(
mock_provider.client.Universe.post_universe_names.call_count, 2 corporation_2003.alliance.alliance_id, 3001 # corp has new alliance
)
alliance_3001.refresh_from_db()
self.assertEqual(
alliance_3001.executor_corp_id, 2001 # alliance has been updated
) )
# character 1 has changed corp
# character 2 no change @override_settings(CELERY_ALWAYS_EAGER=True)
# character 3 has changed alliance @patch('allianceauth.eveonline.tasks.update_character', wraps=update_character)
# character 4 has changed name @patch('allianceauth.eveonline.providers.esi_client_factory')
self.assertEqual(mock_update_corp.apply_async.call_count, 1) @patch('allianceauth.eveonline.tasks.providers')
self.assertEqual( @patch('allianceauth.eveonline.tasks.CHUNK_SIZE', 2)
int(mock_update_corp.apply_async.call_args[1]['args'][0]), 2345 class TestUpdateCharacterChunk(TestCase):
) @staticmethod
self.assertEqual(mock_update_alliance.apply_async.call_count, 1) def _updated_character_ids(spy_update_character) -> set:
self.assertEqual( """Character IDs passed to update_character task for update."""
int(mock_update_alliance.apply_async.call_args[1]['args'][0]), 3456 return {
) x[1]["args"][0] for x in spy_update_character.apply_async.call_args_list
characters_updated = {
x[1]['args'][0] for x in mock_update_character.apply_async.call_args_list
} }
excepted = {1, 3, 4}
self.assertSetEqual(characters_updated, excepted)
def test_ignore_character_not_in_affiliations( def test_should_update_corp_change(
self, self, mock_providers, mock_esi_client_factory, spy_update_character
mock_provider,
mock_update_corp,
mock_update_alliance,
mock_update_character,
): ):
def get_affiliations(characters: list): # given
response = [x for x in self.affiliations if x['character_id'] in characters] mock_providers.provider.client = EsiClientStub()
mock_operator = Mock(**{'result.return_value': response}) mock_esi_client_factory.return_value = EsiClientStub()
return mock_operator character_1001 = EveCharacter.objects.create(
character_id=1001,
character_name="Bruce Wayne",
corporation_id=2003,
corporation_name="Wayne Energy",
corporation_ticker="WEG",
alliance_id=3001,
alliance_name="Wayne Enterprises",
alliance_ticker="WYE",
)
character_1002 = EveCharacter.objects.create(
character_id=1002,
character_name="Peter Parker",
corporation_id=2001,
corporation_name="Wayne Technologies",
corporation_ticker="WTE",
alliance_id=3001,
alliance_name="Wayne Enterprises",
alliance_ticker="WYE",
)
# when
update_character_chunk([
character_1001.character_id, character_1002.character_id
])
# then
character_1001.refresh_from_db()
self.assertEqual(character_1001.corporation_id, 2001)
self.assertSetEqual(self._updated_character_ids(spy_update_character), {1001})
def get_names(ids: list): def test_should_update_name_change(
response = [x for x in self.names if x['id'] in ids] self, mock_providers, mock_esi_client_factory, spy_update_character
mock_operator = Mock(**{'result.return_value': response})
return mock_operator
del self.affiliations[0]
mock_provider.client.Character.post_characters_affiliation.side_effect \
= get_affiliations
mock_provider.client.Universe.post_universe_names.side_effect = get_names
run_model_update()
characters_updated = {
x[1]['args'][0] for x in mock_update_character.apply_async.call_args_list
}
excepted = {3, 4}
self.assertSetEqual(characters_updated, excepted)
def test_ignore_character_not_in_names(
self,
mock_provider,
mock_update_corp,
mock_update_alliance,
mock_update_character,
): ):
def get_affiliations(characters: list): # given
response = [x for x in self.affiliations if x['character_id'] in characters] mock_providers.provider.client = EsiClientStub()
mock_operator = Mock(**{'result.return_value': response}) mock_esi_client_factory.return_value = EsiClientStub()
return mock_operator character_1001 = EveCharacter.objects.create(
character_id=1001,
character_name="Batman",
corporation_id=2001,
corporation_name="Wayne Technologies",
corporation_ticker="WTE",
alliance_id=3001,
alliance_name="Wayne Technologies",
alliance_ticker="WYT",
)
# when
update_character_chunk([character_1001.character_id])
# then
character_1001.refresh_from_db()
self.assertEqual(character_1001.character_name, "Bruce Wayne")
self.assertSetEqual(self._updated_character_ids(spy_update_character), {1001})
def get_names(ids: list): def test_should_update_alliance_change(
response = [x for x in self.names if x['id'] in ids] self, mock_providers, mock_esi_client_factory, spy_update_character
mock_operator = Mock(**{'result.return_value': response}) ):
return mock_operator # given
mock_providers.provider.client = EsiClientStub()
mock_esi_client_factory.return_value = EsiClientStub()
character_1001 = EveCharacter.objects.create(
character_id=1001,
character_name="Bruce Wayne",
corporation_id=2001,
corporation_name="Wayne Technologies",
corporation_ticker="WTE",
alliance_id=None,
)
# when
update_character_chunk([character_1001.character_id])
# then
character_1001.refresh_from_db()
self.assertEqual(character_1001.alliance_id, 3001)
self.assertSetEqual(self._updated_character_ids(spy_update_character), {1001})
del self.names[3] def test_should_not_update_when_not_changed(
self, mock_providers, mock_esi_client_factory, spy_update_character
):
# given
mock_providers.provider.client = EsiClientStub()
mock_esi_client_factory.return_value = EsiClientStub()
character_1001 = EveCharacter.objects.create(
character_id=1001,
character_name="Bruce Wayne",
corporation_id=2001,
corporation_name="Wayne Technologies",
corporation_ticker="WTE",
alliance_id=3001,
alliance_name="Wayne Technologies",
alliance_ticker="WYT",
)
# when
update_character_chunk([character_1001.character_id])
# then
self.assertSetEqual(self._updated_character_ids(spy_update_character), set())
mock_provider.client.Character.post_characters_affiliation.side_effect \ def test_should_fall_back_to_single_updates_when_bulk_update_failed(
= get_affiliations self, mock_providers, mock_esi_client_factory, spy_update_character
):
mock_provider.client.Universe.post_universe_names.side_effect = get_names # given
mock_providers.provider.client.Character.post_characters_affiliation\
run_model_update() .side_effect = OSError
characters_updated = { mock_esi_client_factory.return_value = EsiClientStub()
x[1]['args'][0] for x in mock_update_character.apply_async.call_args_list character_1001 = EveCharacter.objects.create(
} character_id=1001,
excepted = {1, 3} character_name="Bruce Wayne",
self.assertSetEqual(characters_updated, excepted) corporation_id=2001,
corporation_name="Wayne Technologies",
corporation_ticker="WTE",
alliance_id=3001,
alliance_name="Wayne Technologies",
alliance_ticker="WYT",
)
# when
update_character_chunk([character_1001.character_id])
# then
self.assertSetEqual(self._updated_character_ids(spy_update_character), {1001})

View File

@@ -127,6 +127,8 @@
], ],
bootstrap: true bootstrap: true
}, },
"stateSave": true,
"stateDuration": 0
}); });
}); });
{% endblock %} {% endblock %}

View File

@@ -104,7 +104,9 @@
"sortable": false, "sortable": false,
"targets": [2] "targets": [2]
}, },
] ],
"stateSave": true,
"stateDuration": 0
}); });
}); });
{% endblock %} {% endblock %}

View File

@@ -29,15 +29,18 @@
{% endif %} {% endif %}
</a> </a>
</li> </li>
<li>
<a data-toggle="tab" href="#leave">
{% translate "Leave Requests" %}
{% if leaverequests %} {% if not auto_leave %}
<span class="badge">{{ leaverequests|length }}</span> <li>
{% endif %} <a data-toggle="tab" href="#leave">
</a> {% translate "Leave Requests" %}
</li>
{% if leaverequests %}
<span class="badge">{{ leaverequests|length }}</span>
{% endif %}
</a>
</li>
{% endif %}
</ul> </ul>
<div class="panel panel-default panel-tabs-aa"> <div class="panel panel-default panel-tabs-aa">
@@ -100,61 +103,63 @@
{% endif %} {% endif %}
</div> </div>
<div id="leave" class="tab-pane"> {% if not auto_leave %}
{% if leaverequests %} <div id="leave" class="tab-pane">
<div class="table-responsive"> {% if leaverequests %}
<table class="table table-aa"> <div class="table-responsive">
<thead> <table class="table table-aa">
<tr> <thead>
<th>{% translate "Character" %}</th>
<th>{% translate "Organization" %}</th>
<th>{% translate "Group" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for leaverequest in leaverequests %}
<tr> <tr>
<td> <th>{% translate "Character" %}</th>
<img src="{{ leaverequest.main_char|character_portrait_url:32 }}" class="img-circle" style="margin-right: 1rem;"> <th>{% translate "Organization" %}</th>
{% if leaverequest.main_char %} <th>{% translate "Group" %}</th>
<a href="{{ leaverequest.main_char|evewho_character_url }}" target="_blank"> <th></th>
{{ leaverequest.main_char.character_name }}
</a>
{% else %}
{{ leaverequest.user.username }}
{% endif %}
</td>
<td>
{% if leaverequest.main_char %}
<a href="{{ leaverequest.main_char|dotlan_corporation_url }}" target="_blank">
{{ leaverequest.main_char.corporation_name }}
</a><br>
{{ leaverequest.main_char.alliance_name|default_if_none:"" }}
{% else %}
{% translate "(unknown)" %}
{% endif %}
</td>
<td>{{ leaverequest.group.name }}</td>
<td class="text-right">
<a href="{% url 'groupmanagement:leave_accept_request' leaverequest.id %}" class="btn btn-success">
{% translate "Accept" %}
</a>
<a href="{% url 'groupmanagement:leave_reject_request' leaverequest.id %}" class="btn btn-danger">
{% translate "Reject" %}
</a>
</td>
</tr> </tr>
{% endfor %} </thead>
</tbody>
</table> <tbody>
</div> {% for leaverequest in leaverequests %}
{% else %} <tr>
<div class="alert alert-warning text-center">{% translate "No group leave requests." %}</div> <td>
{% endif %} <img src="{{ leaverequest.main_char|character_portrait_url:32 }}" class="img-circle" style="margin-right: 1rem;">
</div> {% if leaverequest.main_char %}
<a href="{{ leaverequest.main_char|evewho_character_url }}" target="_blank">
{{ leaverequest.main_char.character_name }}
</a>
{% else %}
{{ leaverequest.user.username }}
{% endif %}
</td>
<td>
{% if leaverequest.main_char %}
<a href="{{ leaverequest.main_char|dotlan_corporation_url }}" target="_blank">
{{ leaverequest.main_char.corporation_name }}
</a><br>
{{ leaverequest.main_char.alliance_name|default_if_none:"" }}
{% else %}
{% translate "(unknown)" %}
{% endif %}
</td>
<td>{{ leaverequest.group.name }}</td>
<td class="text-right">
<a href="{% url 'groupmanagement:leave_accept_request' leaverequest.id %}" class="btn btn-success">
{% translate "Accept" %}
</a>
<a href="{% url 'groupmanagement:leave_reject_request' leaverequest.id %}" class="btn btn-danger">
{% translate "Reject" %}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-warning text-center">{% translate "No group leave requests." %}</div>
{% endif %}
</div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,4 @@
from django.test import RequestFactory, TestCase from django.test import RequestFactory, TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from allianceauth.tests.auth_utils import AuthUtils from allianceauth.tests.auth_utils import AuthUtils
@@ -6,14 +6,80 @@ from allianceauth.tests.auth_utils import AuthUtils
from .. import views from .. import views
def response_content_to_str(response) -> str:
return response.content.decode(response.charset)
class TestViews(TestCase): class TestViews(TestCase):
def setUp(self): def setUp(self):
self.factory = RequestFactory() self.factory = RequestFactory()
self.user = AuthUtils.create_user('Bruce Wayne') self.user = AuthUtils.create_user('Peter Parker')
self.user_with_manage_permission = AuthUtils.create_user('Bruce Wayne')
# set permissions
AuthUtils.add_permission_to_user_by_name(
'auth.group_management', self.user_with_manage_permission
)
def test_groups_view_can_load(self): def test_groups_view_can_load(self):
request = self.factory.get(reverse('groupmanagement:groups')) request = self.factory.get(reverse('groupmanagement:groups'))
request.user = self.user request.user = self.user
response = views.groups_view(request) response = views.groups_view(request)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_management_view_can_load_for_user_with_permissions(self):
"""
Test if user with management permissions can access the management view
:return:
"""
request = self.factory.get(reverse('groupmanagement:management'))
request.user = self.user_with_manage_permission
response = views.group_management(request)
self.assertEqual(response.status_code, 200)
def test_management_view_doesnt_load_for_user_without_permissions(self):
"""
Test if user without management permissions can't access the management view
:return:
"""
request = self.factory.get(reverse('groupmanagement:management'))
request.user = self.user
response = views.group_management(request)
self.assertEqual(response.status_code, 302)
@override_settings(GROUPMANAGEMENT_AUTO_LEAVE=False)
def test_leave_requests_tab_visible(self):
"""
Test if the leave requests tab is visible when GROUPMANAGEMENT_AUTO_LEAVE = False
:return:
"""
request = self.factory.get(reverse('groupmanagement:management'))
request.user = self.user_with_manage_permission
response = views.group_management(request)
content = response_content_to_str(response)
self.assertEqual(response.status_code, 200)
self.assertIn('<a data-toggle="tab" href="#leave">', content)
self.assertIn('<div id="leave" class="tab-pane">', content)
@override_settings(GROUPMANAGEMENT_AUTO_LEAVE=True)
def test_leave_requests_tab_hidden(self):
"""
Test if the leave requests tab is hidden when GROUPMANAGEMENT_AUTO_LEAVE = True
:return:
"""
request = self.factory.get(reverse('groupmanagement:management'))
request.user = self.user_with_manage_permission
response = views.group_management(request)
content = response_content_to_str(response)
self.assertEqual(response.status_code, 200)
self.assertNotIn('<a data-toggle="tab" href="#leave">', content)
self.assertNotIn('<div id="leave" class="tab-pane">', content)

View File

@@ -45,7 +45,11 @@ def group_management(request):
logger.debug("Providing user {} with {} acceptrequests and {} leaverequests.".format( logger.debug("Providing user {} with {} acceptrequests and {} leaverequests.".format(
request.user, len(acceptrequests), len(leaverequests))) request.user, len(acceptrequests), len(leaverequests)))
render_items = {'acceptrequests': acceptrequests, 'leaverequests': leaverequests} render_items = {
'acceptrequests': acceptrequests,
'leaverequests': leaverequests,
'auto_leave': getattr(settings, 'GROUPMANAGEMENT_AUTO_LEAVE', False),
}
return render(request, 'groupmanagement/index.html', context=render_items) return render(request, 'groupmanagement/index.html', context=render_items)

View File

@@ -49,19 +49,22 @@ class NotificationManager(models.Manager):
logger.info("Created notification %s", obj) logger.info("Created notification %s", obj)
return obj return obj
def _max_notifications_per_user(self): def _max_notifications_per_user(self) -> int:
"""return the maximum number of notifications allowed per user""" """Maximum number of notifications allowed per user."""
max_notifications = getattr(settings, 'NOTIFICATIONS_MAX_PER_USER', None) max_notifications = getattr(
if ( settings,
max_notifications is None "NOTIFICATIONS_MAX_PER_USER",
or not isinstance(max_notifications, int) self.model.NOTIFICATIONS_MAX_PER_USER_DEFAULT
or max_notifications < 0 )
): try:
max_notifications = int(max_notifications)
except ValueError:
max_notifications = None
if max_notifications is None or max_notifications < 0:
logger.warning( logger.warning(
'NOTIFICATIONS_MAX_PER_USER setting is invalid. Using default.' "NOTIFICATIONS_MAX_PER_USER setting is invalid. Using default."
) )
max_notifications = self.model.NOTIFICATIONS_MAX_PER_USER_DEFAULT max_notifications = self.model.NOTIFICATIONS_MAX_PER_USER_DEFAULT
return max_notifications return max_notifications
def user_unread_count(self, user_pk: int) -> int: def user_unread_count(self, user_pk: int) -> int:

View File

@@ -1,5 +1,6 @@
from unittest.mock import patch from unittest.mock import patch
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
@@ -113,29 +114,53 @@ class TestUserNotify(TestCase):
self.assertSetEqual(result, expected) self.assertSetEqual(result, expected)
@patch("allianceauth.notifications.managers.logger")
@patch( @patch(
MODULE_PATH + '.Notification.NOTIFICATIONS_MAX_PER_USER_DEFAULT', MODULE_PATH + ".Notification.NOTIFICATIONS_MAX_PER_USER_DEFAULT",
NOTIFICATIONS_MAX_PER_USER_DEFAULT NOTIFICATIONS_MAX_PER_USER_DEFAULT
) )
class TestMaxNotificationsPerUser(TestCase): class TestMaxNotificationsPerUser(TestCase):
@override_settings(NOTIFICATIONS_MAX_PER_USER=42)
@override_settings(NOTIFICATIONS_MAX_PER_USER=None) def test_should_use_custom_integer_setting(self, mock_logger):
def test_reset_to_default_if_not_defined(self): # when
result = Notification.objects._max_notifications_per_user() result = Notification.objects._max_notifications_per_user()
expected = NOTIFICATIONS_MAX_PER_USER_DEFAULT # then
self.assertEqual(result, expected) self.assertEqual(result, 42)
self.assertFalse(mock_logger.warning.called)
@override_settings(NOTIFICATIONS_MAX_PER_USER='11') @override_settings(NOTIFICATIONS_MAX_PER_USER="42")
def test_reset_to_default_if_not_int(self): def test_should_use_custom_string_setting(self, mock_logger):
# when
result = Notification.objects._max_notifications_per_user() result = Notification.objects._max_notifications_per_user()
expected = NOTIFICATIONS_MAX_PER_USER_DEFAULT # then
self.assertEqual(result, expected) self.assertEqual(result, 42)
self.assertFalse(mock_logger.warning.called)
@override_settings()
def test_should_use_default_if_not_defined(self, mock_logger):
# given
del settings.NOTIFICATIONS_MAX_PER_USER
# when
result = Notification.objects._max_notifications_per_user()
# then
self.assertEqual(result, NOTIFICATIONS_MAX_PER_USER_DEFAULT)
self.assertFalse(mock_logger.warning.called)
@override_settings(NOTIFICATIONS_MAX_PER_USER="abc")
def test_should_reset_to_default_if_not_int(self, mock_logger):
# when
result = Notification.objects._max_notifications_per_user()
# then
self.assertEqual(result, NOTIFICATIONS_MAX_PER_USER_DEFAULT)
self.assertTrue(mock_logger.warning.called)
@override_settings(NOTIFICATIONS_MAX_PER_USER=-1) @override_settings(NOTIFICATIONS_MAX_PER_USER=-1)
def test_reset_to_default_if_lt_zero(self): def test_should_reset_to_default_if_lt_zero(self, mock_logger):
# when
result = Notification.objects._max_notifications_per_user() result = Notification.objects._max_notifications_per_user()
expected = NOTIFICATIONS_MAX_PER_USER_DEFAULT # then
self.assertEqual(result, expected) self.assertEqual(result, NOTIFICATIONS_MAX_PER_USER_DEFAULT)
self.assertTrue(mock_logger.warning.called)
@patch('allianceauth.notifications.managers.cache') @patch('allianceauth.notifications.managers.cache')

View File

@@ -73,6 +73,8 @@
], ],
bootstrap: true bootstrap: true
}, },
"stateSave": true,
"stateDuration": 0,
drawCallback: function ( settings ) { drawCallback: function ( settings ) {
let api = this.api(); let api = this.api();
let rows = api.rows( {page:'current'} ).nodes(); let rows = api.rows( {page:'current'} ).nodes();

View File

@@ -106,8 +106,10 @@
idx: 1 idx: 1
} }
], ],
bootstrap: true bootstrap: true,
}, },
"stateSave": true,
"stateDuration": 0,
drawCallback: function ( settings ) { drawCallback: function ( settings ) {
let api = this.api(); let api = this.api();
let rows = api.rows( {page:'current'} ).nodes(); let rows = api.rows( {page:'current'} ).nodes();

View File

@@ -1,7 +1,8 @@
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.models import Group
from .models import AuthTS, Teamspeak3User, StateGroup from .models import AuthTS, Teamspeak3User, StateGroup, TSgroup
from ...admin import ServicesUserAdmin from ...admin import ServicesUserAdmin
from allianceauth.groupmanagement.models import ReservedGroupName
@admin.register(Teamspeak3User) @admin.register(Teamspeak3User)
@@ -25,6 +26,16 @@ class AuthTSgroupAdmin(admin.ModelAdmin):
fields = ('auth_group', 'ts_group') fields = ('auth_group', 'ts_group')
filter_horizontal = ('ts_group',) filter_horizontal = ('ts_group',)
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == 'auth_group':
kwargs['queryset'] = Group.objects.exclude(name__in=ReservedGroupName.objects.values_list('name', flat=True))
return super().formfield_for_foreignkey(db_field, request, **kwargs)
def formfield_for_manytomany(self, db_field, request, **kwargs):
if db_field.name == 'ts_group':
kwargs['queryset'] = TSgroup.objects.exclude(ts_group_name__in=ReservedGroupName.objects.values_list('name', flat=True))
return super().formfield_for_manytomany(db_field, request, **kwargs)
def _ts_group(self, obj): def _ts_group(self, obj):
return [x for x in obj.ts_group.all().order_by('ts_group_id')] return [x for x in obj.ts_group.all().order_by('ts_group_id')]

View File

@@ -4,6 +4,7 @@ from django.conf import settings
from .util.ts3 import TS3Server, TeamspeakError from .util.ts3 import TS3Server, TeamspeakError
from .models import TSgroup from .models import TSgroup
from allianceauth.groupmanagement.models import ReservedGroupName
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -156,32 +157,25 @@ class Teamspeak3Manager:
logger.info(f"Removed user id {uid} from group id {groupid} on TS3 server.") logger.info(f"Removed user id {uid} from group id {groupid} on TS3 server.")
def _sync_ts_group_db(self): def _sync_ts_group_db(self):
logger.debug("_sync_ts_group_db function called.")
try: try:
remote_groups = self._group_list() remote_groups = self._group_list()
local_groups = TSgroup.objects.all() managed_groups = {g:int(remote_groups[g]) for g in remote_groups if g in set(remote_groups.keys()) - set(ReservedGroupName.objects.values_list('name', flat=True))}
logger.debug("Comparing remote groups to TSgroup objects: %s" % local_groups) remove = TSgroup.objects.exclude(ts_group_id__in=managed_groups.values())
for key in remote_groups:
logger.debug(f"Typecasting remote_group value at position {key} to int: {remote_groups[key]}") if remove:
remote_groups[key] = int(remote_groups[key]) logger.debug(f"Deleting {remove.count()} TSgroup models: not found on server, or reserved name.")
remove.delete()
add = {g:managed_groups[g] for g in managed_groups if managed_groups[g] in set(managed_groups.values()) - set(TSgroup.objects.values_list("ts_group_id", flat=True))}
if add:
logger.debug(f"Adding {len(add)} new TSgroup models.")
models = [TSgroup(ts_group_name=name, ts_group_id=add[name]) for name in add]
TSgroup.objects.bulk_create(models)
for group in local_groups:
logger.debug("Checking local group %s" % group)
if group.ts_group_id not in remote_groups.values():
logger.debug(
f"Local group id {group.ts_group_id} not found on server. Deleting model {group}")
TSgroup.objects.filter(ts_group_id=group.ts_group_id).delete()
for key in remote_groups:
g = TSgroup(ts_group_id=remote_groups[key], ts_group_name=key)
q = TSgroup.objects.filter(ts_group_id=g.ts_group_id)
if not q:
logger.debug("Local group does not exist for TS group {}. Creating TSgroup model {}".format(
remote_groups[key], g))
g.save()
except TeamspeakError as e: except TeamspeakError as e:
logger.error("Error occured while syncing TS group db: %s" % str(e)) logger.error(f"Error occurred while syncing TS group db: {str(e)}")
except: except Exception:
logger.exception("An unhandled exception has occured while syncing TS groups.") logger.exception(f"An unhandled exception has occurred while syncing TS groups.")
def add_user(self, user, fmt_name): def add_user(self, user, fmt_name):
username_clean = self.__santatize_username(fmt_name[:30]) username_clean = self.__santatize_username(fmt_name[:30])
@@ -240,7 +234,7 @@ class Teamspeak3Manager:
logger.exception(f"Failed to delete user id {uid} from TS3 - received response {ret}") logger.exception(f"Failed to delete user id {uid} from TS3 - received response {ret}")
return False return False
else: else:
logger.warn("User with id %s not found on TS3 server. Assuming succesful deletion." % uid) logger.warning("User with id %s not found on TS3 server. Assuming succesful deletion." % uid)
return True return True
def check_user_exists(self, uid): def check_user_exists(self, uid):
@@ -270,7 +264,8 @@ class Teamspeak3Manager:
addgroups.append(ts_groups[ts_group_key]) addgroups.append(ts_groups[ts_group_key])
for user_ts_group_key in user_ts_groups: for user_ts_group_key in user_ts_groups:
if user_ts_groups[user_ts_group_key] not in ts_groups.values(): if user_ts_groups[user_ts_group_key] not in ts_groups.values():
remgroups.append(user_ts_groups[user_ts_group_key]) if not ReservedGroupName.objects.filter(name=user_ts_group_key).exists():
remgroups.append(user_ts_groups[user_ts_group_key])
for g in addgroups: for g in addgroups:
logger.info(f"Adding Teamspeak user {userid} into group {g}") logger.info(f"Adding Teamspeak user {userid} into group {g}")

View File

@@ -5,16 +5,18 @@ from django import urls
from django.contrib.auth.models import User, Group, Permission from django.contrib.auth.models import User, Group, Permission
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.db.models import signals from django.db.models import signals
from django.contrib.admin import AdminSite
from allianceauth.tests.auth_utils import AuthUtils from allianceauth.tests.auth_utils import AuthUtils
from .auth_hooks import Teamspeak3Service from .auth_hooks import Teamspeak3Service
from .models import Teamspeak3User, AuthTS, TSgroup, StateGroup from .models import Teamspeak3User, AuthTS, TSgroup, StateGroup
from .tasks import Teamspeak3Tasks from .tasks import Teamspeak3Tasks
from .signals import m2m_changed_authts_group, post_save_authts, post_delete_authts from .signals import m2m_changed_authts_group, post_save_authts, post_delete_authts
from .admin import AuthTSgroupAdmin
from .manager import Teamspeak3Manager from .manager import Teamspeak3Manager
from .util.ts3 import TeamspeakError from .util.ts3 import TeamspeakError
from allianceauth.authentication.models import State from allianceauth.groupmanagement.models import ReservedGroupName
MODULE_PATH = 'allianceauth.services.modules.teamspeak3' MODULE_PATH = 'allianceauth.services.modules.teamspeak3'
DEFAULT_AUTH_GROUP = 'Member' DEFAULT_AUTH_GROUP = 'Member'
@@ -315,6 +317,9 @@ class Teamspeak3SignalsTestCase(TestCase):
class Teamspeak3ManagerTestCase(TestCase): class Teamspeak3ManagerTestCase(TestCase):
@classmethod
def setUpTestData(cls):
cls.reserved = ReservedGroupName.objects.create(name='reserved', reason='tests', created_by='Bob, praise be!')
@staticmethod @staticmethod
def my_side_effect(*args, **kwargs): def my_side_effect(*args, **kwargs):
@@ -334,8 +339,135 @@ class Teamspeak3ManagerTestCase(TestCase):
manager._server = server manager._server = server
# create test data # create test data
user = User.objects.create_user("dummy") user = AuthUtils.create_user("dummy")
user.profile.state = State.objects.filter(name="Member").first() AuthUtils.assign_state(user, AuthUtils.get_member_state())
# perform test # perform test
manager.add_user(user, "Dummy User") manager.add_user(user, "Dummy User")
@mock.patch.object(Teamspeak3Manager, '_get_userid')
@mock.patch.object(Teamspeak3Manager, '_user_group_list')
@mock.patch.object(Teamspeak3Manager, '_add_user_to_group')
@mock.patch.object(Teamspeak3Manager, '_remove_user_from_group')
def test_update_groups_add(self, remove, add, groups, userid):
"""Add to one group"""
userid.return_value = 1
groups.return_value = {'test': 1}
Teamspeak3Manager().update_groups(1, {'test': 1, 'dummy': 2})
self.assertEqual(add.call_count, 1)
self.assertEqual(remove.call_count, 0)
self.assertEqual(add.call_args[0][1], 2)
@mock.patch.object(Teamspeak3Manager, '_get_userid')
@mock.patch.object(Teamspeak3Manager, '_user_group_list')
@mock.patch.object(Teamspeak3Manager, '_add_user_to_group')
@mock.patch.object(Teamspeak3Manager, '_remove_user_from_group')
def test_update_groups_remove(self, remove, add, groups, userid):
"""Remove from one group"""
userid.return_value = 1
groups.return_value = {'test': '1', 'dummy': '2'}
Teamspeak3Manager().update_groups(1, {'test': 1})
self.assertEqual(add.call_count, 0)
self.assertEqual(remove.call_count, 1)
self.assertEqual(remove.call_args[0][1], 2)
@mock.patch.object(Teamspeak3Manager, '_get_userid')
@mock.patch.object(Teamspeak3Manager, '_user_group_list')
@mock.patch.object(Teamspeak3Manager, '_add_user_to_group')
@mock.patch.object(Teamspeak3Manager, '_remove_user_from_group')
def test_update_groups_remove_reserved(self, remove, add, groups, userid):
"""Remove from one group, but do not touch reserved group"""
userid.return_value = 1
groups.return_value = {'test': 1, 'dummy': 2, self.reserved.name: 3}
Teamspeak3Manager().update_groups(1, {'test': 1})
self.assertEqual(add.call_count, 0)
self.assertEqual(remove.call_count, 1)
self.assertEqual(remove.call_args[0][1], 2)
@mock.patch.object(Teamspeak3Manager, '_group_list')
def test_sync_group_db_create(self, group_list):
"""Populate the list of all TSgroups"""
group_list.return_value = {'allowed':'1', 'also allowed':'2'}
Teamspeak3Manager()._sync_ts_group_db()
self.assertEqual(TSgroup.objects.all().count(), 2)
@mock.patch.object(Teamspeak3Manager, '_group_list')
def test_sync_group_db_delete(self, group_list):
"""Populate the list of all TSgroups, and delete one which no longer exists"""
TSgroup.objects.create(ts_group_name='deleted', ts_group_id=3)
group_list.return_value = {'allowed': '1'}
Teamspeak3Manager()._sync_ts_group_db()
self.assertEqual(TSgroup.objects.all().count(), 1)
self.assertFalse(TSgroup.objects.filter(ts_group_name='deleted').exists())
@mock.patch.object(Teamspeak3Manager, '_group_list')
def test_sync_group_db_dont_create_reserved(self, group_list):
"""Populate the list of all TSgroups, ignoring a reserved group name"""
group_list.return_value = {'allowed': '1', 'reserved': '4'}
Teamspeak3Manager()._sync_ts_group_db()
self.assertEqual(TSgroup.objects.all().count(), 1)
self.assertFalse(TSgroup.objects.filter(ts_group_name='reserved').exists())
@mock.patch.object(Teamspeak3Manager, '_group_list')
def test_sync_group_db_delete_reserved(self, group_list):
"""Populate the list of all TSgroups, deleting the TSgroup model for one which has become reserved"""
TSgroup.objects.create(ts_group_name='reserved', ts_group_id=4)
group_list.return_value = {'allowed': '1', 'reserved': '4'}
Teamspeak3Manager()._sync_ts_group_db()
self.assertEqual(TSgroup.objects.all().count(), 1)
self.assertFalse(TSgroup.objects.filter(ts_group_name='reserved').exists())
@mock.patch.object(Teamspeak3Manager, '_group_list')
def test_sync_group_db_partial_addition(self, group_list):
"""Some TSgroups already exist in database, add new ones"""
TSgroup.objects.create(ts_group_name='allowed', ts_group_id=1)
group_list.return_value = {'allowed': '1', 'also allowed': '2'}
Teamspeak3Manager()._sync_ts_group_db()
self.assertEqual(TSgroup.objects.all().count(), 2)
@mock.patch.object(Teamspeak3Manager, '_group_list')
def test_sync_group_db_partial_removal(self, group_list):
"""One TSgroup has been deleted on server, so remove its model"""
TSgroup.objects.create(ts_group_name='allowed', ts_group_id=1)
TSgroup.objects.create(ts_group_name='also allowed', ts_group_id=2)
group_list.return_value = {'allowed': '1'}
Teamspeak3Manager()._sync_ts_group_db()
self.assertEqual(TSgroup.objects.all().count(), 1)
class MockRequest:
pass
class MockSuperUser:
def has_perm(self, perm, obj=None):
return True
request = MockRequest()
request.user = MockSuperUser()
class Teamspeak3AdminTestCase(TestCase):
@classmethod
def setUpTestData(cls):
cls.site = AdminSite()
cls.admin = AuthTSgroupAdmin(AuthTS, cls.site)
cls.group = Group.objects.create(name='test')
cls.ts_group = TSgroup.objects.create(ts_group_name='test')
def test_field_queryset_no_reserved_names(self):
"""Ensure all groups are listed when no reserved names"""
form = self.admin.get_form(request)
self.assertQuerysetEqual(form.base_fields['auth_group']._get_queryset(), Group.objects.all())
self.assertQuerysetEqual(form.base_fields['ts_group']._get_queryset(), TSgroup.objects.all())
def test_field_queryset_reserved_names(self):
"""Ensure reserved group names are filtered out"""
ReservedGroupName.objects.bulk_create([ReservedGroupName(name='test', reason='tests', created_by='Bob')])
form = self.admin.get_form(request)
self.assertQuerysetEqual(form.base_fields['auth_group']._get_queryset(), Group.objects.none())
self.assertQuerysetEqual(form.base_fields['ts_group']._get_queryset(), TSgroup.objects.none())

View File

@@ -267,7 +267,9 @@ ESC to cancel{% endblocktrans %}"id="blah"></i></th>
"targets": [4, 5], "targets": [4, 5],
"type": "num" "type": "num"
} }
] ],
"stateSave": true,
"stateDuration": 0
}); });
// tooltip // tooltip

View File

@@ -95,6 +95,11 @@ ul.list-group.list-group-horizontal > li.list-group-item {
.table-aa > tbody > tr:last-child { .table-aa > tbody > tr:last-child {
border-bottom: none; border-bottom: none;
} }
.task-status-progress-bar {
font-size: 15px!important;
line-height: normal!important;
}
} }
/* highlight active menu items /* highlight active menu items

View File

@@ -1,58 +1,20 @@
$(document).ready(function () { $(document).ready(function () {
'use strict'; 'use strict';
/**
* check time
* @param i
* @returns {string}
*/
let checkTime = function (i) {
if (i < 10) {
i = '0' + i;
}
return i;
};
/** /**
* render a JS clock for Eve Time * render a JS clock for Eve Time
* @param element * @param element
* @param utcOffset
*/ */
let renderClock = function (element, utcOffset) { const renderClock = function (element) {
let today = new Date(); const datetimeNow = new Date();
let h = today.getUTCHours(); const h = String(datetimeNow.getUTCHours()).padStart(2, '0');
let m = today.getUTCMinutes(); const m = String(datetimeNow.getUTCMinutes()).padStart(2, '0');
h = h + utcOffset;
if (h > 24) {
h = h - 24;
}
if (h < 0) {
h = h + 24;
}
h = checkTime(h);
m = checkTime(m);
element.html(h + ':' + m); element.html(h + ':' + m);
setTimeout(function () {
renderClock(element, 0);
}, 500);
}; };
/** // Start the Eve time clock in the top menu bar
* functions that need to be executed on load setInterval(function () {
*/ renderClock($('.eve-time-wrapper .eve-time-clock'));
let init = function () { }, 500);
renderClock($('.eve-time-wrapper .eve-time-clock'), 0);
};
/**
* start the show
*/
init();
}); });

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,11 @@
{% load humanize %}
<div
class="progress-bar progress-bar-{{ level }} task-status-progress-bar"
role="progressbar"
aria-valuenow="{% widthratio tasks_count tasks_total 100 %}"
aria-valuemin="0"
aria-valuemax="100"
style="width: {% widthratio tasks_count tasks_total 100 %}%;">
{% widthratio tasks_count tasks_total 100 %}%
</div>

View File

@@ -1,4 +1,6 @@
{% load i18n %} {% load i18n %}
{% load humanize %}
<div class="col-sm-12"> <div class="col-sm-12">
<div class="row vertical-flexbox-row2"> <div class="row vertical-flexbox-row2">
<div class="col-sm-6"> <div class="col-sm-6">
@@ -75,29 +77,25 @@
<div class="panel panel-primary" style="height:50%;"> <div class="panel panel-primary" style="height:50%;">
<div class="panel-heading text-center"><h3 class="panel-title">{% translate "Task Queue" %}</h3></div> <div class="panel-heading text-center"><h3 class="panel-title">{% translate "Task Queue" %}</h3></div>
<div class="panel-body flex-center-horizontal"> <div class="panel-body flex-center-horizontal">
<div class="progress" style="height: 21px;"> <p>
<div class="progress-bar {% blocktranslate with total=tasks_total|intcomma latest=earliest_task|timesince|default:"?" %}
{% if task_queue_length > 500 %} Status of {{ total }} processed tasks • last {{ latest }}
progress-bar-danger {% endblocktranslate %}
{% elif task_queue_length > 100 %} </p>
progress-bar-warning <div
{% else %} class="progress"
progress-bar-success style="height: 21px;"
{% endif %} title="{{ tasks_succeeded|intcomma }} succeeded, {{ tasks_retried|intcomma }} retried, {{ tasks_failed|intcomma }} failed"
" role="progressbar" aria-valuenow="{% widthratio task_queue_length 500 100 %}" >
aria-valuemin="0" aria-valuemax="100" {% include "allianceauth/admin-status/celery_bar_partial.html" with label="suceeded" level="success" tasks_count=tasks_succeeded %}
style="width: {% widthratio task_queue_length 500 100 %}%;"> {% include "allianceauth/admin-status/celery_bar_partial.html" with label="retried" level="info" tasks_count=tasks_retried %}
</div> {% include "allianceauth/admin-status/celery_bar_partial.html" with label="failed" level="danger" tasks_count=tasks_failed %}
</div> </div>
{% if task_queue_length < 0 %} <p>
{% translate "Error retrieving task queue length" %} {% blocktranslate with queue_length=task_queue_length|default_if_none:"?"|intcomma %}
{% else %} {{ queue_length }} queued tasks
{% blocktrans trimmed count tasks=task_queue_length %} {% endblocktranslate %}
{{ tasks }} task </p>
{% plural %}
{{ tasks }} tasks
{% endblocktrans %}
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,3 +1,5 @@
<!-- Start jQuery UI CSS from cdnjs --> {% load static %}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.css" integrity="sha512-aOG0c6nPNzGk+5zjwyJaoRUgCdOrfSDhmMID2u4+OIslr0GjpLKo7Xm0Ao3xmpM4T8AmIouRkqwj1nrdVsLKEQ==" crossorigin="anonymous" referrerpolicy="no-referrer" /> <!-- Start jQuery UI CSS from Alliance Auth -->
<!-- End jQuery UI CSS from cdnjs --> <!-- CDNs all contain theme.css, which is not supposed to be in the base CSS, Which is why this is uniquely bundled in not using a CDN -->
<link rel="stylesheet" href="{% static 'js/jquery-ui/1.12.1/css/jquery-ui.min.css' %}" integrity="sha512-7smZe1765O+Mm1UZH46SzaFClbRX7dEs01lB9lqU91oocmugWWfQXVQNVr5tEwktYSqwJMErEfr4GvflXMgTPA==" crossorigin="anonymous" referrerpolicy="no-referrer"/>
<!-- End jQuery UI CSS from aa-gdpr -->

View File

@@ -1,9 +1,11 @@
import logging import logging
from typing import Optional
import requests
import amqp.exceptions import amqp.exceptions
from packaging.version import Version as Pep440Version, InvalidVersion import requests
from celery.app import app_or_default from celery.app import app_or_default
from packaging.version import InvalidVersion
from packaging.version import Version as Pep440Version
from django import template from django import template
from django.conf import settings from django.conf import settings
@@ -11,6 +13,7 @@ from django.core.cache import cache
from allianceauth import __version__ from allianceauth import __version__
from ..authentication.task_statistics.counters import dashboard_results
register = template.Library() register = template.Library()
@@ -36,30 +39,51 @@ logger = logging.getLogger(__name__)
@register.inclusion_tag('allianceauth/admin-status/overview.html') @register.inclusion_tag('allianceauth/admin-status/overview.html')
def status_overview() -> dict: def status_overview() -> dict:
response = { response = {
'notifications': list(), "notifications": list(),
'current_version': __version__, "current_version": __version__,
'task_queue_length': -1, "task_queue_length": None,
"tasks_succeeded": 0,
"tasks_retried": 0,
"tasks_failed": 0,
"tasks_total": 0,
"tasks_hours": 0,
"earliest_task": None
} }
response.update(_current_notifications()) response.update(_current_notifications())
response.update(_current_version_summary()) response.update(_current_version_summary())
response.update({'task_queue_length': _fetch_celery_queue_length()}) response.update({'task_queue_length': _fetch_celery_queue_length()})
response.update(_celery_stats())
return response return response
def _fetch_celery_queue_length() -> int: def _celery_stats() -> dict:
hours = getattr(settings, "ALLIANCEAUTH_DASHBOARD_TASKS_MAX_HOURS", 24)
results = dashboard_results(hours=hours)
return {
"tasks_succeeded": results.succeeded,
"tasks_retried": results.retried,
"tasks_failed": results.failed,
"tasks_total": results.total,
"tasks_hours": results.hours,
"earliest_task": results.earliest_task
}
def _fetch_celery_queue_length() -> Optional[int]:
try: try:
app = app_or_default(None) app = app_or_default(None)
with app.connection_or_acquire() as conn: with app.connection_or_acquire() as conn:
return conn.default_channel.queue_declare( result = conn.default_channel.queue_declare(
queue=getattr(settings, 'CELERY_DEFAULT_QUEUE', 'celery'), queue=getattr(settings, 'CELERY_DEFAULT_QUEUE', 'celery'),
passive=True passive=True
).message_count )
return result.message_count
except amqp.exceptions.ChannelError: except amqp.exceptions.ChannelError:
# Queue doesn't exist, probably empty # Queue doesn't exist, probably empty
return 0 return 0
except Exception: except Exception:
logger.exception("Failed to get celery queue length") logger.exception("Failed to get celery queue length")
return -1 return None
def _current_notifications() -> dict: def _current_notifications() -> dict:

View File

@@ -174,7 +174,7 @@ class AuthUtils:
alliance_id=None, alliance_id=None,
alliance_name='', alliance_name='',
disconnect_signals=False disconnect_signals=False
): ) -> EveCharacter:
"""new version that works in all cases""" """new version that works in all cases"""
if disconnect_signals: if disconnect_signals:
cls.disconnect_signals() cls.disconnect_signals()

View File

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

View File

@@ -1,5 +1,5 @@
FROM python:3.9-slim FROM python:3.9-slim
ARG AUTH_VERSION=2.9.0 ARG AUTH_VERSION=2.11.1
ARG AUTH_PACKAGE=allianceauth==${AUTH_VERSION} ARG AUTH_PACKAGE=allianceauth==${AUTH_VERSION}
ENV VIRTUAL_ENV=/opt/venv ENV VIRTUAL_ENV=/opt/venv
ENV AUTH_USER=allianceauth ENV AUTH_USER=allianceauth
@@ -39,7 +39,6 @@ RUN allianceauth start myauth
COPY /allianceauth/project_template/project_name/settings/local.py ${AUTH_HOME}/myauth/myauth/settings/local.py COPY /allianceauth/project_template/project_name/settings/local.py ${AUTH_HOME}/myauth/myauth/settings/local.py
RUN allianceauth update myauth RUN allianceauth update myauth
RUN mkdir -p ${STATIC_BASE}/myauth/static RUN mkdir -p ${STATIC_BASE}/myauth/static
RUN python ${AUTH_HOME}/myauth/manage.py collectstatic --noinput
COPY /docker/conf/supervisord.conf /etc/supervisor/conf.d/supervisord.conf COPY /docker/conf/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
RUN echo 'alias auth="python $AUTH_HOME/myauth/manage.py"' >> ~/.bashrc && \ RUN echo 'alias auth="python $AUTH_HOME/myauth/manage.py"' >> ~/.bashrc && \
echo 'alias supervisord="supervisord -c /etc/supervisor/conf.d/supervisord.conf"' >> ~/.bashrc && \ echo 'alias supervisord="supervisord -c /etc/supervisor/conf.d/supervisord.conf"' >> ~/.bashrc && \

View File

@@ -8,7 +8,7 @@ You should have the following available on the system you are using to set this
## Setup Guide ## Setup Guide
1. run `bash <(curl -s https://gitlab.com/allianceauth/allianceauth/-/raw/v2.9.x/docker/scripts/download.sh)`. This will download all the files you need to install auth and place them in a directory named `aa-docker`. Feel free to rename/move this folder. 1. run `bash <(curl -s https://gitlab.com/allianceauth/allianceauth/-/raw/v2.11.x/docker/scripts/download.sh)`. This will download all the files you need to install auth and place them in a directory named `aa-docker`. Feel free to rename/move this folder.
1. run `./scripts/prepare-env.sh` to set up your environment 1. run `./scripts/prepare-env.sh` to set up your environment
1. (optional) Change `PROTOCOL` to `http://` if not using SSL in `.env` 1. (optional) Change `PROTOCOL` to `http://` if not using SSL in `.env`
1. run `docker-compose --env-file=.env up -d` (NOTE: if this command hangs, follow the instructions [here](https://www.digitalocean.com/community/tutorials/how-to-setup-additional-entropy-for-cloud-servers-using-haveged)) 1. run `docker-compose --env-file=.env up -d` (NOTE: if this command hangs, follow the instructions [here](https://www.digitalocean.com/community/tutorials/how-to-setup-additional-entropy-for-cloud-servers-using-haveged))
@@ -67,3 +67,16 @@ _NOTE: If you specify a version of allianceauth in your `requirements.txt` in a
1. Update the versions in your `requirements.txt` file 1. Update the versions in your `requirements.txt` file
1. Run `docker-compose build` 1. Run `docker-compose build`
1. Run `docker-compose --env-file=.env up -d` 1. Run `docker-compose --env-file=.env up -d`
## Notes
### Apple M1 Support
If you want to run locally on an M1 powered Apple device, you'll need to add `platform: linux/x86_64` under each container in `docker-compose.yml` as the auth container is not compiled for ARM (other containers may work without this, but it's known to work if added to all containers).
Example:
```yaml
redis:
platform: linux/x86_64
image: redis:6.2
```

View File

@@ -52,7 +52,7 @@ services:
- auth_mysql - auth_mysql
grafana: grafana:
image: grafana/grafana:8.2 image: grafana/grafana-oss:8.3.2
restart: always restart: always
depends_on: depends_on:
- auth_mysql - auth_mysql

View File

@@ -1,4 +1,4 @@
#!/bin/bash #!/bin/bash
git clone -b build-docker-image https://gitlab.com/allianceauth/allianceauth.git aa-git git clone https://gitlab.com/allianceauth/allianceauth.git aa-git
cp -R aa-git/docker ./aa-docker cp -R aa-git/docker ./aa-docker
rm -rf aa-git rm -rf aa-git

View File

@@ -42,6 +42,7 @@ from recommonmark.transform import AutoStructify
extensions = [ extensions = [
'sphinx_rtd_theme', 'sphinx_rtd_theme',
'sphinx.ext.autodoc', 'sphinx.ext.autodoc',
'sphinx.ext.napoleon',
'recommonmark', 'recommonmark',
] ]

View File

@@ -10,6 +10,12 @@ To Opt-Out, modify our pre-loaded token using the Admin dashboard */admin/analyt
Each of the three features Daily Stats, Celery Events and Page Views can be enabled/Disabled independently. Each of the three features Daily Stats, Celery Events and Page Views can be enabled/Disabled independently.
Alternatively, you can fully opt out of analytics with the following optional setting:
```python
ANALYTICS_DISABLED = True
```
![Analytics Tokens](/_static/images/features/core/analytics/tokens.png) ![Analytics Tokens](/_static/images/features/core/analytics/tokens.png)
## What ## What
@@ -58,6 +64,8 @@ This data is stored in a Team Google Analytics Dashboard. The Maintainers all ha
### Analytics Event ### Analytics Event
```eval_rst
.. automodule:: allianceauth.analytics.tasks .. automodule:: allianceauth.analytics.tasks
:members: analytics_event :members: analytics_event
:undoc-members: :undoc-members:
```

View File

@@ -7,3 +7,18 @@ The content of the dashboard is specific to the logged in user. It has a sidebar
For admin users the dashboard shows additional technical information about the AA instance. For admin users the dashboard shows additional technical information about the AA instance.
![dashboard](/_static/images/features/core/dashboard/dashboard.png) ![dashboard](/_static/images/features/core/dashboard/dashboard.png)
## Settings
Here is a list of available settings for the dashboard. They can be configured by adding them to your AA settings file (``local.py``).
Note that all settings are optional and the app will use the documented default settings if they are not used.
```eval_rst
+-----------------------------------------------------+-------------------------------------------------------------------------+-----------+
| Name | Description | Default |
+=====================================================+=========================================================================+===========+
| ``ALLIANCEAUTH_DASHBOARD_TASKS_MAX_HOURS`` | Statistics will be calculated for task events not older than max hours. | ``24`` |
+-----------------------------------------------------+-------------------------------------------------------------------------+-----------+
| ``ALLIANCEAUTH_DASHBOARD_TASK_STATISTICS_DISABLED`` | Disables recording of task statistics. Used mainly in development. | ``False`` |
+-----------------------------------------------------+-------------------------------------------------------------------------+-----------+
```

View File

@@ -48,7 +48,7 @@ When using Alliance Auth to manage external services like Discord, Auth will aut
```eval_rst ```eval_rst
.. note:: .. note::
While this feature can help to avoid naming conflicts with groups on external services, the respective service component in Alliance Auth also needs to be build in such a way that it knows how to prevent these conflicts. Currently only the Discord service has this ability. While this feature can help to avoid naming conflicts with groups on external services, the respective service component in Alliance Auth also needs to be build in such a way that it knows how to prevent these conflicts. Currently only the Discord and Teamspeak3 services have this ability.
``` ```
## Managing groups ## Managing groups
@@ -90,11 +90,16 @@ This allows you to more finely control who has access to manage which groups.
### Auto Leave ### Auto Leave
By default in AA, Both requests and leaves for non-open groups must be approved by a group manager. If you wish to allow users to leave groups without requiring approvals, add the following lines to your `local.py` By default, in AA both requests and leaves for non-open groups must be approved by a group manager. If you wish to allow users to leave groups without requiring approvals, add the following lines to your `local.py`
```python ```python
## Allows users to freely leave groups without requiring approval. ## Allows users to freely leave groups without requiring approval.
AUTO_LEAVE = True GROUPMANAGEMENT_AUTO_LEAVE = True
```
```eval_rst
.. note::
Before you set `GROUPMANAGEMENT_AUTO_LEAVE = True`, make sure there are no pending leave requests, as this option will hide the "Leave Requests" tab.
``` ```
## Settings ## Settings

View File

@@ -160,7 +160,7 @@ This error generally means teamspeak returned an error message that went unhandl
This most commonly happens when your teamspeak server is externally hosted. You need to add the auth server IP to the teamspeak serverquery whitelist. This varies by provider. This most commonly happens when your teamspeak server is externally hosted. You need to add the auth server IP to the teamspeak serverquery whitelist. This varies by provider.
If you have SSH access to the server hosting it, you need to locate the teamspeak server folder and add the auth server IP on a new line in `server_query_whitelist.txt` If you have SSH access to the server hosting it, you need to locate the teamspeak server folder and add the auth server IP on a new line in `query_ip_allowlist.txt` (named `query_ip_whitelist.txt` on older teamspeak versions).
### `520 invalid loginname or password` ### `520 invalid loginname or password`

View File

@@ -30,10 +30,10 @@ install_requires = [
'django-celery-beat>=2.0.0', 'django-celery-beat>=2.0.0',
'openfire-restapi', 'openfire-restapi',
'sleekxmpp', 'sleekxmpp<=1.3.2',
'pydiscourse', 'pydiscourse',
'django-esi>=3.0.0,<4.0.0' 'django-esi>=4.0.1,<5.0.0'
] ]
testing_extras = [ testing_extras = [

View File

@@ -154,3 +154,5 @@ PASSWORD_HASHERS = [
] ]
LOGGING = None # Comment out to enable logging for debugging LOGGING = None # Comment out to enable logging for debugging
ALLIANCEAUTH_DASHBOARD_TASK_STATISTICS_DISABLED = True # disable for tests

View File

@@ -27,3 +27,5 @@ PASSWORD_HASHERS = [
] ]
LOGGING = None # Comment out to enable logging for debugging LOGGING = None # Comment out to enable logging for debugging
ALLIANCEAUTH_DASHBOARD_TASK_STATISTICS_DISABLED = True # disable for tests

View File

@@ -1,7 +1,7 @@
[tox] [tox]
skipsdist = true skipsdist = true
usedevelop = true usedevelop = true
envlist = py{37,38,39,310}-{all,core} envlist = py{37,38,39,310,311}-{all,core}
[testenv] [testenv]
setenv = setenv =
@@ -12,6 +12,7 @@ basepython =
py38: python3.8 py38: python3.8
py39: python3.9 py39: python3.9
py310: python3.10 py310: python3.10
py311: python3.11
deps= deps=
coverage coverage
install_command = pip install -e ".[testing]" -U {opts} {packages} install_command = pip install -e ".[testing]" -U {opts} {packages}