Compare commits

..

122 Commits

Author SHA1 Message Date
Ariel Rin
3874aa6fee Version Bump 2.8.0a1 2020-09-11 11:52:20 +00:00
Ariel Rin
103e9f3a11 Merge branch 'discourse_beta' into 'master'
Discourse API with external package

See merge request allianceauth/allianceauth!1251
2020-09-11 11:33:19 +00:00
Ariel Rin
d02c25f421 Merge branch 'feature_menu_item_badges' into 'master'
Add menu item badge feature and update group icons

See merge request allianceauth/allianceauth!1240
2020-09-11 11:33:14 +00:00
Erik Kalkoken
228af38a4a Add menu item badge feature and update group icons 2020-09-11 11:33:14 +00:00
AaronKable
051a48885c discourse API with external package 2020-09-11 17:19:54 +08:00
Ariel Rin
6bcdc6052f Merge branch 'local-delivery' into 'master'
JS/CSS/Font Refactoring for use with AA-GDPR Package

Closes #1217

See merge request allianceauth/allianceauth!1247
2020-09-11 04:13:01 +00:00
Ariel Rin
af3527e64f Revert "load bootswatch less locally #1217"
This reverts commit 3a9a7267ea8734ba0a5cf1d0fea632eb2276d45c.
2020-09-11 04:13:01 +00:00
Ariel Rin
17ef3dd07a Merge branch 'srp_fix' into 'master'
Use request.scheme to get the http/https for the site

See merge request allianceauth/allianceauth!1249
2020-09-11 03:05:06 +00:00
AaronKable
1f165ecd2a use request.scheme to get the http/https for the site 2020-09-07 19:10:58 +08:00
Ariel Rin
70d1d450a9 Add Korean and Russian as features 2020-09-03 12:24:23 +00:00
Ariel Rin
b667892698 Version Bump 2.7.5 2020-09-01 02:09:05 +00:00
Ariel Rin
dc11add0e9 Merge branch 'discordapp.com-deprecation' into 'master'
discord.com replaces discordapp.com

See merge request allianceauth/allianceauth!1248
2020-09-01 01:53:25 +00:00
Ariel Rin
cb429a0b88 discord.com replaces discordapp.com 2020-09-01 11:20:57 +10:00
Ariel Rin
b51039cfc0 Merge branch 'docs' into 'master'
Add Optimizing Mumble to Services docs

See merge request allianceauth/allianceauth!1243
2020-09-01 01:11:49 +00:00
Ariel Rin
eadd959d95 Merge branch 'fix_celery_once_backend' into 'master'
Fix celery once not working properly

See merge request allianceauth/allianceauth!1246
2020-08-25 06:13:59 +00:00
Erik Kalkoken
1856e03d88 Fix celery once not working properly 2020-08-25 06:13:59 +00:00
Ariel Rin
7dcfa622a3 Merge branch 'issue_1234_exiom' into 'master'
Month Ordering Fix for Group Management Audit Logs

See merge request allianceauth/allianceauth!1245
2020-08-25 06:07:45 +00:00
Exiom
64251b9b3c Fix Date/Time Month Ordering #1234 2020-08-21 03:57:24 +00:00
Exiom
6b073dd5fc Fix Date/Time Month Ordering #1234 2020-08-21 03:33:35 +00:00
Ariel Rin
0911fabfb2 Merge branch 'issue_1234' into 'master'
Correct Month Ordering in Group Management Audit Logs

Closes #1234

See merge request allianceauth/allianceauth!1244
2020-08-20 07:11:47 +00:00
Ariel Rin
050d3f5e63 use month numerical 2020-08-20 16:55:18 +10:00
Ariel Rin
bbe3f78ad1 Grammar and Spelling Corrections 2020-08-20 15:19:50 +10:00
Ariel Rin
8204c18895 Add Optimzing Mumble 2020-08-20 15:09:07 +10:00
Ariel Rin
b91c788897 Merge branch 'bulk-affiliations' into 'master'
Reduce run time for eve online model updates

See merge request allianceauth/allianceauth!1227
2020-08-20 03:14:40 +00:00
Erik Kalkoken
1d20a3029f Only update characters if they have changed corp or alliance by bulk calling affiliations before calling character tasks. 2020-08-20 03:14:40 +00:00
Ariel Rin
9cfebc9ae3 Version Bump to 2.7.4 2020-08-17 06:34:55 +00:00
Ariel Rin
ddabb4539b Merge branch 'fix_admin_status_tags_bug' into 'master'
Bugfix: Loading of dashboard fails with 'NoneType object is not iterable' from status_tags

See merge request allianceauth/allianceauth!1237
2020-08-14 02:26:12 +00:00
Ariel Rin
ada35e221b Merge branch 'transifex' into 'master'
Update from Transifex

See merge request allianceauth/allianceauth!1241
2020-08-14 02:24:07 +00:00
Ariel Rin
6fef9d904e Update from Transifex 2020-08-14 02:24:07 +00:00
Ariel Rin
67cf2b5904 Merge branch 'fontawesomev5' into 'master'
Remove FA v4 shims, bump FA to 5.14

Closes #1248

See merge request allianceauth/allianceauth!1242
2020-08-14 02:21:41 +00:00
Ariel Rin
3bebe792f6 Remove FA v4 shims, bump to fa 5.14 2020-08-14 12:01:08 +10:00
ErikKalkoken
00b4d89181 Fix status tags bug and remove unused context from status_overview tag 2020-07-27 14:53:25 +02:00
Ariel Rin
f729c6b650 Merge branch 'patch-3' into 'master'
Update Mumble documentation on setting a server password

Closes #1252

See merge request allianceauth/allianceauth!1236
2020-07-24 11:18:02 +00:00
colcrunch
df95f8c3f3 Merge branch 'discord_improvements' into 'master'
Fix error 500 on service page for Discord and add feature "group_to_role"

See merge request allianceauth/allianceauth!1235
2020-07-23 20:58:26 +00:00
Erik Kalkoken
fe36e57d72 Fix error 500 on service page for Discord and add feature "group_to_role" 2020-07-23 20:58:26 +00:00
Peter Pfeufer
31197812b6 Update Mumble documentation on setting a server password (#1252) 2020-07-22 12:18:52 +00:00
Ariel Rin
bd3fe01a12 correct task import for manual model population 2020-07-14 12:25:05 +00:00
col_crunch
39f7f32b7d Version bump. 2020-07-13 15:32:54 -04:00
colcrunch
b4522a1277 Merge branch 'enable_django_esi_20' into 'master'
Enable django-esi 2.0 dependency

See merge request allianceauth/allianceauth!1232
2020-07-13 18:54:21 +00:00
colcrunch
bb6a7e8327 Merge branch 'issue_1250' into 'master'
Fix Discord service issues and improve dashboard

Closes #1250

See merge request allianceauth/allianceauth!1229
2020-07-13 18:54:08 +00:00
colcrunch
9bd42a7579 Merge branch 'docu_update_contributions' into 'master'
Add contributing chapter to docs

See merge request allianceauth/allianceauth!1233
2020-07-13 18:52:30 +00:00
Erik Kalkoken
b41430e5a3 Add contributing chapter to docs 2020-07-13 18:52:29 +00:00
ErikKalkoken
595353e838 Enable using django-esi 2.0 2020-07-11 12:35:16 +02:00
ErikKalkoken
f1a21bb856 Add state info to dashboard, improve stat change notifications, improve auth tests 2020-07-03 14:58:45 +02:00
ErikKalkoken
e44c2935f9 Version bump for alpha testing 2020-07-03 13:29:00 +02:00
ErikKalkoken
4d546f948d Fix state role not always updated due to lazy properties 2020-07-02 21:26:40 +02:00
ErikKalkoken
3bab349d7b Fix tests 2020-06-30 00:15:48 +02:00
ErikKalkoken
eef6126ef8 Improve state backend fix, add tests for state backend and service signal changes 2020-06-30 00:01:40 +02:00
ErikKalkoken
5c7478fa39 Fix update_nickname runs on ever save of userprofile, fix nickname not updated after main's name changes 2020-06-28 21:38:25 +02:00
ErikKalkoken
64b72d0b06 Fix service signals for state change 2020-06-28 17:14:16 +02:00
ErikKalkoken
b266a98b25 Add more tests for state change 2020-06-28 14:56:01 +02:00
ErikKalkoken
8a27de5df8 Fix state change does not update groups 2020-06-28 01:45:51 +02:00
ErikKalkoken
f9b5310fce Fix user account not deleted when demoted to guest 2020-06-27 23:32:12 +02:00
ErikKalkoken
fdce173969 Add tests to fix new bugs 2020-06-27 16:18:06 +02:00
ErikKalkoken
7b9ddf90c1 Fix tests for tox 2020-06-25 23:38:20 +02:00
ErikKalkoken
580c8c19de Update request timeout default 2020-06-25 22:32:29 +02:00
ErikKalkoken
55cc77140e Fix bug blocking superuser from adding Discord bot 2020-06-25 22:19:48 +02:00
Ariel Rin
93c89dd7cc Merge branch 'wother-master-patch-74872' into 'master'
Updated discord.md

See merge request allianceauth/allianceauth!1228
2020-06-22 03:19:27 +00:00
Carter Foulger
c970cbbd2d Updated discord.md with additional troubleshooting
steps.
2020-06-19 19:45:22 +00:00
Ariel Rin
9ea55fa51f Version Bump 2.7.2 2020-06-11 03:49:06 +00:00
Ariel Rin
5775a11b4e Merge branch 'replace_context_manager_groups' into 'master'
Improve page load performance by replacing groups context manager

See merge request allianceauth/allianceauth!1219
2020-06-11 03:41:31 +00:00
Ariel Rin
1a666b6584 Merge branch 'fontawesomev5' into 'master'
Font Awesome V5 Update

Closes #1207

See merge request allianceauth/allianceauth!1224
2020-06-11 03:33:37 +00:00
Ariel Rin
35407a2108 Font Awesome V5 Update 2020-06-11 03:33:37 +00:00
Ariel Rin
71fb19aa22 Merge branch 'version_battle' into 'master'
Make version relevant to an admin

See merge request allianceauth/allianceauth!1220
2020-06-11 03:13:13 +00:00
AaronKable
b7d7f7b8ce latest stable 2020-06-11 10:47:05 +08:00
Ariel Rin
59b983edcc Merge branch 'future' into 'master'
Remove Future dependency

Closes #1242

See merge request allianceauth/allianceauth!1223
2020-06-11 01:01:23 +00:00
Ariel Rin
1734d034e1 Merge branch 'evemodel_integers' into 'master'
Change EveModels to Integer ID fields

See merge request allianceauth/allianceauth!1211
2020-06-09 13:10:10 +00:00
Aaron Kable
7f7500ff0c Change EveModels to Integer ID fields 2020-06-09 13:10:10 +00:00
Ariel Rin
ce77c24e5c Exclude Celery 4.4.4 2020-06-09 11:30:13 +10:00
Ariel Rin
5469a591c0 Remove Future dependency 2020-06-09 11:10:32 +10:00
Ariel Rin
a4befc5e59 Version Bump 2.7.1 2020-06-09 00:25:30 +00:00
Ariel Rin
1ee8065592 Merge branch 'issue_1244' into 'master'
Fix sleep length must be non-negative

Closes #1244

See merge request allianceauth/allianceauth!1222
2020-06-09 00:23:36 +00:00
ErikKalkoken
e4e3bd44fc Fix sleep length must be non-negative 2020-06-08 14:59:22 +02:00
AaronKable
c75de07c2e Only show Pre-Release when available 2020-06-08 20:47:05 +08:00
AaronKable
e928131809 make version relevant to an admin 2020-06-08 19:18:11 +08:00
Ariel Rin
4f802e82a9 Version Bump to 2.7.0 2020-06-07 06:53:52 +00:00
Ariel Rin
0c90bd462e Merge branch 'remove_test_logging' into 'master'
Remove Test Logging from discourse

See merge request allianceauth/allianceauth!1207
2020-06-07 06:52:08 +00:00
ErikKalkoken
bbb70c93d9 Initial 2020-06-06 17:59:23 +02:00
Ariel Rin
f6e6ba775c Merge branch 'revert-3a984e8a' into 'master'
Revert "Merge branch 'notifications_refresh' into 'master'"

See merge request allianceauth/allianceauth!1216
2020-06-04 14:22:11 +00:00
Ariel Rin
06646be907 Merge branch 'fix_celery_4.4.4_issue' into 'master'
Add future to dependencies to fix celery 4.4.4 issue

See merge request allianceauth/allianceauth!1217
2020-06-04 11:58:28 +00:00
ErikKalkoken
1b4c1a4b9e Add future to dependencies 2020-06-04 13:52:34 +02:00
Ariel Rin
ae3f5a0f62 Revert "Merge branch 'notifications_refresh' into 'master'"
This reverts merge request !1215
2020-06-04 11:21:50 +00:00
Ariel Rin
3a984e8a4d Merge branch 'notifications_refresh' into 'master'
Add notifications auto refresh

See merge request allianceauth/allianceauth!1215
2020-06-04 08:25:01 +00:00
Erik Kalkoken
7d711a54bc Add notifications auto refresh 2020-06-04 08:25:01 +00:00
Ariel Rin
d92d629c25 Merge branch 'orm_fix' into 'master'
Fix Group Managment ORM Queries

See merge request allianceauth/allianceauth!1214
2020-05-27 02:23:37 +00:00
Ariel Rin
21e630209a Merge branch 'issue_1238' into 'master'
PEP440 versioning for admin dashboard

See merge request allianceauth/allianceauth!1213
2020-05-27 02:21:01 +00:00
Erik Kalkoken
e3933998ef PEP440 versioning for admin dashboard 2020-05-27 02:21:00 +00:00
Ariel Rin
667afe9051 Merge branch 'master' into 'master'
Update Extension Logger

Closes #1230

See merge request allianceauth/allianceauth!1206
2020-05-27 02:17:43 +00:00
AaronKable
26dc2881eb fix group managment ORM queries 2020-05-27 08:15:22 +08:00
Col Crunch
250cb33285 Raise an error if get_extension_logger recieves a non-string argument. 2020-05-26 13:49:06 -04:00
Col Crunch
db51abec1f Change default logging level for extension logger. 2020-05-26 13:26:19 -04:00
Col Crunch
530716d458 Fix docstring n get_extension_logger 2020-05-26 13:25:48 -04:00
Ariel Rin
f3065d79b3 Merge branch 'improve_evelinks' into 'master'
Add support for type icons to evelinks

See merge request allianceauth/allianceauth!1210
2020-05-25 14:51:42 +00:00
Erik Kalkoken
bca5f0472e Add support for type icons to evelinks 2020-05-25 14:51:41 +00:00
Ariel Rin
8e54c43917 Merge branch 'mumble-certhash' into 'master'
Add certhash field to Mumble user

See merge request allianceauth/allianceauth!1204
2020-05-25 14:46:03 +00:00
Ariel Rin
946df1d7a0 Merge branch 'issue_1234' into 'master'
Fix issue #1234 and add badges to group requests

Closes #1234

See merge request allianceauth/allianceauth!1212
2020-05-25 13:36:29 +00:00
ErikKalkoken
55f00f742c Fix issue #1234 and add badges to group requests 2020-05-25 15:22:08 +02:00
Ariel Rin
97b2cb71b7 Version Bump to 2.6.6a10 2020-05-25 12:55:05 +00:00
Ariel Rin
ba3a5ba53c Merge branch 'discord_delete_user_bug' into 'master'
Fix API exception handling for delete_user

See merge request allianceauth/allianceauth!1209
2020-05-25 01:28:25 +00:00
ErikKalkoken
953c09c999 Fix backoff exception handling for delete_user 2020-05-24 19:13:31 +02:00
Ariel Rin
b4cc325b07 Merge branch 'fix_test_order_issues' into 'master'
Make notification tests less order dependent

See merge request allianceauth/allianceauth!1208
2020-05-23 04:58:07 +00:00
AaronKable
28c1343f3e make tests less order dependant 2020-05-23 12:54:09 +08:00
Col Crunch
c16fd94c4a Update CI config for new version of gitlab. 2020-05-23 00:31:27 -04:00
Ariel Rin
bb2cc20838 Merge branch 'discord_ignore_managed_roles' into 'master'
Enable Discord service to deal with managed roles

See merge request allianceauth/allianceauth!1205
2020-05-23 04:28:01 +00:00
Erik Kalkoken
7b815fd010 Enable Discord service to deal with managed roles 2020-05-23 04:28:01 +00:00
AaronKable
18584974df remove test logging 2020-05-23 12:18:05 +08:00
Ariel Rin
15823b7785 Merge branch 'stop_context_spam' into 'master'
Stop the notification context provider from hitting the database for every menu hook

See merge request allianceauth/allianceauth!1201
2020-05-23 04:14:10 +00:00
Col Crunch
6c275d4cd2 Add comment to local describing how to change logger level. Also update docs to be consistent with this change. 2020-05-23 00:08:37 -04:00
Col Crunch
2d64ee5e2a Base extension logger level on the level of its parent. 2020-05-23 00:08:00 -04:00
AaronKable
3ae5ffa3f6 tests 2020-05-23 12:04:06 +08:00
Ben Cole
57d9ddc2c6 Add certhash field to Mumble user 2020-05-22 14:04:35 +01:00
Ariel Rin
8b84def494 Merge branch 'discord_update_usernames' into 'master'
Add update username feature

See merge request allianceauth/allianceauth!1203
2020-05-19 03:18:01 +00:00
ErikKalkoken
546f01ceb2 Add update username feature 2020-05-19 00:13:19 +02:00
Ariel Rin
be720d0e0f Merge branch 'redundant-sql' into 'master'
Use existing character variable on dashboard.

See merge request allianceauth/allianceauth!1202
2020-05-18 01:02:08 +00:00
Ariel Rin
72bed03244 Merge branch 'discord_service_overhaul' into 'master'
Discord service major overhaul

See merge request allianceauth/allianceauth!1200
2020-05-18 01:01:13 +00:00
Erik Kalkoken
38083ed284 Discord service major overhaul 2020-05-18 01:01:13 +00:00
Ariel Rin
53f1b94475 Correct typo in bzip2-devel dependency 2020-05-14 09:31:47 +00:00
AaronKable
ed4270a0e3 Use existing character variable. 2020-05-14 17:09:00 +08:00
Ariel Rin
f1d5cc8903 Merge branch 'discourse' into 'master'
API Headers for Discourse

See merge request allianceauth/allianceauth!1197
2020-05-12 00:00:43 +00:00
AaronKable
543fa3cfa9 extra logging for tests 2020-05-08 10:03:05 +08:00
AaronKable
f70fbbdfee headers for Discourse 2020-04-27 14:37:01 +08:00
Aaron Kable
1ce0dbde0e stop the notification context profider from hitting the database for each menu hook 2020-03-16 02:09:24 +00:00
166 changed files with 10452 additions and 2820 deletions

2
.gitignore vendored
View File

@@ -75,3 +75,5 @@ celerybeat-schedule
#other
.flake8
.pylintrc
Makefile

View File

@@ -1,8 +1,11 @@
stages:
- "test"
- test
- deploy
before_script:
- apt-get update && apt-get install redis-server -y
- redis-server --daemonize yes
- redis-cli ping
- python -V
- pip install wheel tox
@@ -47,5 +50,5 @@ deploy_production:
- python setup.py sdist
- twine upload dist/*
only:
- tags
rules:
- if: $CI_COMMIT_TAG

View File

@@ -36,7 +36,7 @@ Main features:
- Can be easily extended with additional services and apps. Many are provided by the community and can be found here: [Community Creations](https://gitlab.com/allianceauth/community-creations)
- Chinese :cn:, English :us:, German :de: and Spanish :es: localization
- English :flag_gb:, Chinese :flag_cn:, German :flag_de:, Spanish :flag_es:, Korean :flag_kr: and Russian :flag_ru: localization
For further details about AA - including an installation guide and a full list of included services and plugin apps - please see the [official documentation](http://allianceauth.rtfd.io).

View File

@@ -1,6 +1,8 @@
# This will make sure the app is always imported when
# Django starts so that shared_task will use this app.
__version__ = '2.6.5'
NAME = 'Alliance Auth v%s' % __version__
__version__ = '2.8.0a1'
__title__ = 'Alliance Auth'
__url__ = 'https://gitlab.com/allianceauth/allianceauth'
NAME = '%s v%s' % (__title__, __version__)
default_app_config = 'allianceauth.apps.AllianceAuthConfig'

View File

@@ -37,8 +37,11 @@ def make_service_hooks_update_groups_action(service):
:return: fn to update services groups for the selected users
"""
def update_service_groups(modeladmin, request, queryset):
for user in queryset: # queryset filtering doesn't work here?
service.update_groups(user)
if hasattr(service, 'update_groups_bulk'):
service.update_groups_bulk(queryset)
else:
for user in queryset: # queryset filtering doesn't work here?
service.update_groups(user)
update_service_groups.__name__ = str('update_{}_groups'.format(slugify(service.name)))
update_service_groups.short_description = "Sync groups for selected {} accounts".format(service.title)
@@ -52,8 +55,11 @@ def make_service_hooks_sync_nickname_action(service):
:return: fn to sync nickname for the selected users
"""
def sync_nickname(modeladmin, request, queryset):
for user in queryset: # queryset filtering doesn't work here?
service.sync_nickname(user)
if hasattr(service, 'sync_nicknames_bulk'):
service.sync_nicknames_bulk(queryset)
else:
for user in queryset: # queryset filtering doesn't work here?
service.sync_nickname(user)
sync_nickname.__name__ = str('sync_{}_nickname'.format(slugify(service.name)))
sync_nickname.short_description = "Sync nicknames for selected {} accounts".format(service.title)

View File

@@ -1,7 +1,8 @@
from django.contrib.auth.backends import ModelBackend
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
import logging
from django.contrib.auth.backends import ModelBackend
from django.contrib.auth.models import User, Permission
from .models import UserProfile, CharacterOwnership, OwnershipRecord
@@ -11,9 +12,11 @@ logger = logging.getLogger(__name__)
class StateBackend(ModelBackend):
@staticmethod
def _get_state_permissions(user_obj):
profile_state_field = UserProfile._meta.get_field('state')
user_state_query = 'state__%s__user' % profile_state_field.related_query_name()
return Permission.objects.filter(**{user_state_query: user_obj})
"""returns permissions for state of given user object"""
if hasattr(user_obj, "profile") and user_obj.profile:
return Permission.objects.filter(state=user_obj.profile.state)
else:
return Permission.objects.none()
def get_state_permissions(self, user_obj, obj=None):
return self._get_permissions(user_obj, obj, 'state')

View File

@@ -73,11 +73,17 @@ class UserProfile(models.Model):
if commit:
logger.info('Updating {} state to {}'.format(self.user, self.state))
self.save(update_fields=['state'])
notify(self.user, _('State Changed'),
_('Your user state has been changed to %(state)s') % ({'state': state}),
'info')
notify(
self.user,
_('State changed to: %s' % state),
_('Your user\'s state is now: %(state)s')
% ({'state': state}),
'info'
)
from allianceauth.authentication.signals import state_changed
state_changed.send(sender=self.__class__, user=self.user, state=self.state)
state_changed.send(
sender=self.__class__, user=self.user, state=self.state
)
def __str__(self):
return str(self.user)

View File

@@ -23,9 +23,7 @@ def trigger_state_check(state):
check_states = State.objects.filter(priority__lt=state.priority)
for profile in UserProfile.objects.filter(state__in=check_states):
if state.available_to_user(profile.user):
profile.state = state
profile.save(update_fields=['state'])
state_changed.send(sender=state.__class__, user=profile.user, state=state)
profile.assign_state(state)
@receiver(m2m_changed, sender=State.member_characters.through)

View File

@@ -14,7 +14,11 @@
<div class="col-sm-6 text-center">
<div class="panel panel-primary" style="height:100%">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Main Character" %}</h3>
<h3 class="panel-title">
{% blocktrans with state=request.user.profile.state %}
Main Character (State: {{ state }})
{% endblocktrans %}
</h3>
</div>
<div class="panel-body">
{% if request.user.profile.main_character %}
@@ -141,19 +145,17 @@
</table>
<table class="table table-aa visible-xs-block" style="width: 100%">
<tbody>
{% for ownership in request.user.character_ownerships.all %}
{% with ownership.character as char %}
<tr>
<td class="text-center" style="vertical-align: middle">
<img class="ra-avatar img-circle" src="{{ char.portrait_url_32 }}">
</td>
<td class="text-center" style="vertical-align: middle; width: 100%">
<strong>{{ char.character_name }}</strong><br>
{{ char.corporation_name }}<br>
{{ char.alliance_name|default:"" }}
</td>
</tr>
{% endwith %}
{% for char in characters %}
<tr>
<td class="text-center" style="vertical-align: middle">
<img class="ra-avatar img-circle" src="{{ char.portrait_url_32 }}">
</td>
<td class="text-center" style="vertical-align: middle; width: 100%">
<strong>{{ char.character_name }}</strong><br>
{{ char.corporation_name }}<br>
{{ char.alliance_name|default:"" }}
</td>
</tr>
{% endfor %}
</tbody>
</table>

View File

@@ -1,5 +1,5 @@
from urllib.parse import quote
from unittest.mock import patch
from unittest.mock import patch, MagicMock
from django.conf import settings
from django.contrib import admin
@@ -13,6 +13,7 @@ from allianceauth.authentication.models import (
from allianceauth.eveonline.models import (
EveCharacter, EveCorporationInfo, EveAllianceInfo
)
from allianceauth.services.hooks import ServicesHook
from allianceauth.tests.auth_utils import AuthUtils
from ..admin import (
@@ -28,7 +29,9 @@ from ..admin import (
user_main_organization,
user_profile_pic,
user_username,
update_main_character_model
update_main_character_model,
make_service_hooks_update_groups_action,
make_service_hooks_sync_nickname_action
)
from . import get_admin_change_view_url, get_admin_search_url
@@ -45,136 +48,144 @@ class MockRequest(object):
def __init__(self, user=None):
self.user = user
class TestCaseWithTestData(TestCase):
def create_test_data():
# groups
group_1 = Group.objects.create(
name='Group 1'
)
group_2 = Group.objects.create(
name='Group 2'
)
# user 1 - corp and alliance, normal user
character_1 = EveCharacter.objects.create(
character_id='1001',
character_name='Bruce Wayne',
corporation_id='2001',
corporation_name='Wayne Technologies',
corporation_ticker='WT',
alliance_id='3001',
alliance_name='Wayne Enterprises',
alliance_ticker='WE',
)
character_1a = EveCharacter.objects.create(
character_id='1002',
character_name='Batman',
corporation_id='2001',
corporation_name='Wayne Technologies',
corporation_ticker='WT',
alliance_id='3001',
alliance_name='Wayne Enterprises',
alliance_ticker='WE',
)
alliance = EveAllianceInfo.objects.create(
alliance_id='3001',
alliance_name='Wayne Enterprises',
alliance_ticker='WE',
executor_corp_id='2001'
)
EveCorporationInfo.objects.create(
corporation_id='2001',
corporation_name='Wayne Technologies',
corporation_ticker='WT',
member_count=42,
alliance=alliance
)
user_1 = User.objects.create_user(
character_1.character_name.replace(' ', '_'),
'abc@example.com',
'password'
)
CharacterOwnership.objects.create(
character=character_1,
owner_hash='x1' + character_1.character_name,
user=user_1
)
CharacterOwnership.objects.create(
character=character_1a,
owner_hash='x1' + character_1a.character_name,
user=user_1
)
user_1.profile.main_character = character_1
user_1.profile.save()
user_1.groups.add(group_1)
@classmethod
def setUpClass(cls):
super().setUpClass()
# user 2 - corp only, staff
character_2 = EveCharacter.objects.create(
character_id=1003,
character_name='Clark Kent',
corporation_id=2002,
corporation_name='Daily Planet',
corporation_ticker='DP',
alliance_id=None
)
EveCorporationInfo.objects.create(
corporation_id=2002,
corporation_name='Daily Plane',
corporation_ticker='DP',
member_count=99,
alliance=None
)
user_2 = User.objects.create_user(
character_2.character_name.replace(' ', '_'),
'abc@example.com',
'password'
)
CharacterOwnership.objects.create(
character=character_2,
owner_hash='x1' + character_2.character_name,
user=user_2
)
user_2.profile.main_character = character_2
user_2.profile.save()
user_2.groups.add(group_2)
user_2.is_staff = True
user_2.save()
# user 3 - no main, no group, superuser
character_3 = EveCharacter.objects.create(
character_id=1101,
character_name='Lex Luthor',
corporation_id=2101,
corporation_name='Lex Corp',
corporation_ticker='LC',
alliance_id=None
)
EveCorporationInfo.objects.create(
corporation_id=2101,
corporation_name='Lex Corp',
corporation_ticker='LC',
member_count=666,
alliance=None
)
EveAllianceInfo.objects.create(
alliance_id='3101',
alliance_name='Lex World Domination',
alliance_ticker='LWD',
executor_corp_id=''
)
user_3 = User.objects.create_user(
character_3.character_name.replace(' ', '_'),
'abc@example.com',
'password'
)
CharacterOwnership.objects.create(
character=character_3,
owner_hash='x1' + character_3.character_name,
user=user_3
)
user_3.is_superuser = True
user_3.save()
return user_1, user_2, user_3, group_1, group_2
for MyModel in [
EveAllianceInfo, EveCorporationInfo, EveCharacter, Group, User
]:
MyModel.objects.all().delete()
# groups
cls.group_1 = Group.objects.create(
name='Group 1'
)
cls.group_2 = Group.objects.create(
name='Group 2'
)
# user 1 - corp and alliance, normal user
character_1 = EveCharacter.objects.create(
character_id=1001,
character_name='Bruce Wayne',
corporation_id=2001,
corporation_name='Wayne Technologies',
corporation_ticker='WT',
alliance_id=3001,
alliance_name='Wayne Enterprises',
alliance_ticker='WE',
)
character_1a = EveCharacter.objects.create(
character_id=1002,
character_name='Batman',
corporation_id=2001,
corporation_name='Wayne Technologies',
corporation_ticker='WT',
alliance_id=3001,
alliance_name='Wayne Enterprises',
alliance_ticker='WE',
)
alliance = EveAllianceInfo.objects.create(
alliance_id=3001,
alliance_name='Wayne Enterprises',
alliance_ticker='WE',
executor_corp_id=2001
)
EveCorporationInfo.objects.create(
corporation_id=2001,
corporation_name='Wayne Technologies',
corporation_ticker='WT',
member_count=42,
alliance=alliance
)
cls.user_1 = User.objects.create_user(
character_1.character_name.replace(' ', '_'),
'abc@example.com',
'password'
)
CharacterOwnership.objects.create(
character=character_1,
owner_hash='x1' + character_1.character_name,
user=cls.user_1
)
CharacterOwnership.objects.create(
character=character_1a,
owner_hash='x1' + character_1a.character_name,
user=cls.user_1
)
cls.user_1.profile.main_character = character_1
cls.user_1.profile.save()
cls.user_1.groups.add(cls.group_1)
# user 2 - corp only, staff
character_2 = EveCharacter.objects.create(
character_id=1003,
character_name='Clark Kent',
corporation_id=2002,
corporation_name='Daily Planet',
corporation_ticker='DP',
alliance_id=None
)
EveCorporationInfo.objects.create(
corporation_id=2002,
corporation_name='Daily Plane',
corporation_ticker='DP',
member_count=99,
alliance=None
)
cls.user_2 = User.objects.create_user(
character_2.character_name.replace(' ', '_'),
'abc@example.com',
'password'
)
CharacterOwnership.objects.create(
character=character_2,
owner_hash='x1' + character_2.character_name,
user=cls.user_2
)
cls.user_2.profile.main_character = character_2
cls.user_2.profile.save()
cls.user_2.groups.add(cls.group_2)
cls.user_2.is_staff = True
cls.user_2.save()
# user 3 - no main, no group, superuser
character_3 = EveCharacter.objects.create(
character_id=1101,
character_name='Lex Luthor',
corporation_id=2101,
corporation_name='Lex Corp',
corporation_ticker='LC',
alliance_id=None
)
EveCorporationInfo.objects.create(
corporation_id=2101,
corporation_name='Lex Corp',
corporation_ticker='LC',
member_count=666,
alliance=None
)
EveAllianceInfo.objects.create(
alliance_id=3101,
alliance_name='Lex World Domination',
alliance_ticker='LWD',
executor_corp_id=2101
)
cls.user_3 = User.objects.create_user(
character_3.character_name.replace(' ', '_'),
'abc@example.com',
'password'
)
CharacterOwnership.objects.create(
character=character_3,
owner_hash='x1' + character_3.character_name,
user=cls.user_3
)
cls.user_3.is_superuser = True
cls.user_3.save()
def make_generic_search_request(ModelClass: type, search_term: str):
@@ -188,12 +199,7 @@ def make_generic_search_request(ModelClass: type, search_term: str):
)
class TestCharacterOwnershipAdmin(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.user_1, _, _, _, _ = create_test_data()
class TestCharacterOwnershipAdmin(TestCaseWithTestData):
def setUp(self):
self.modeladmin = CharacterOwnershipAdmin(
@@ -219,12 +225,7 @@ class TestCharacterOwnershipAdmin(TestCase):
self.assertEqual(response.status_code, expected)
class TestOwnershipRecordAdmin(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.user_1, _, _, _, _ = create_test_data()
class TestOwnershipRecordAdmin(TestCaseWithTestData):
def setUp(self):
self.modeladmin = OwnershipRecordAdmin(
@@ -250,13 +251,8 @@ class TestOwnershipRecordAdmin(TestCase):
self.assertEqual(response.status_code, expected)
class TestStateAdmin(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
create_test_data()
class TestStateAdmin(TestCaseWithTestData):
def setUp(self):
self.modeladmin = StateAdmin(
model=User, admin_site=AdminSite()
@@ -283,13 +279,7 @@ class TestStateAdmin(TestCase):
expected = 200
self.assertEqual(response.status_code, expected)
class TestUserAdmin(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.user_1, cls.user_2, cls.user_3, cls.group_1, cls.group_2 = \
create_test_data()
class TestUserAdmin(TestCaseWithTestData):
def setUp(self):
self.factory = RequestFactory()
@@ -520,8 +510,8 @@ class TestUserAdmin(TestCase):
filters = changelist.get_filters(request)
filterspec = filters[0][0]
expected = [
('2002', 'Daily Planet'),
('2001', 'Wayne Technologies'),
(2002, 'Daily Planet'),
(2001, 'Wayne Technologies'),
]
self.assertEqual(filterspec.lookup_choices, expected)
@@ -550,7 +540,7 @@ class TestUserAdmin(TestCase):
filters = changelist.get_filters(request)
filterspec = filters[0][0]
expected = [
('3001', 'Wayne Enterprises'),
(3001, 'Wayne Enterprises'),
]
self.assertEqual(filterspec.lookup_choices, expected)
@@ -578,4 +568,68 @@ class TestUserAdmin(TestCase):
obj = User.objects.first()
response = make_generic_search_request(type(obj), obj.username)
expected = 200
self.assertEqual(response.status_code, expected)
self.assertEqual(response.status_code, expected)
class TestMakeServicesHooksActions(TestCaseWithTestData):
class MyServicesHookTypeA(ServicesHook):
def __init__(self):
super().__init__()
self.name = 'My Service A'
def update_groups(self, user):
pass
def sync_nicknames(self, user):
pass
class MyServicesHookTypeB(ServicesHook):
def __init__(self):
super().__init__()
self.name = 'My Service B'
def update_groups(self, user):
pass
def update_groups_bulk(self, user):
pass
def sync_nicknames(self, user):
pass
def sync_nicknames_bulk(self, user):
pass
def test_service_has_update_groups_only(self):
service = self.MyServicesHookTypeA()
mock_service = MagicMock(spec=service)
action = make_service_hooks_update_groups_action(mock_service)
action(MagicMock(), MagicMock(), [self.user_1])
self.assertTrue(mock_service.update_groups.called)
def test_service_has_update_groups_bulk(self):
service = self.MyServicesHookTypeB()
mock_service = MagicMock(spec=service)
action = make_service_hooks_update_groups_action(mock_service)
action(MagicMock(), MagicMock(), [self.user_1])
self.assertFalse(mock_service.update_groups.called)
self.assertTrue(mock_service.update_groups_bulk.called)
def test_service_has_sync_nickname_only(self):
service = self.MyServicesHookTypeA()
mock_service = MagicMock(spec=service)
action = make_service_hooks_sync_nickname_action(mock_service)
action(MagicMock(), MagicMock(), [self.user_1])
self.assertTrue(mock_service.sync_nickname.called)
def test_service_has_sync_nicknames_bulk(self):
service = self.MyServicesHookTypeB()
mock_service = MagicMock(spec=service)
action = make_service_hooks_sync_nickname_action(mock_service)
action(MagicMock(), MagicMock(), [self.user_1])
self.assertFalse(mock_service.sync_nickname.called)
self.assertTrue(mock_service.sync_nicknames_bulk.called)

View File

@@ -0,0 +1,149 @@
from django.contrib.auth.models import User, Group
from django.test import TestCase
from allianceauth.eveonline.models import EveCharacter
from allianceauth.tests.auth_utils import AuthUtils
from esi.models import Token
from ..backends import StateBackend
from ..models import CharacterOwnership, UserProfile, OwnershipRecord
MODULE_PATH = 'allianceauth.authentication'
PERMISSION_1 = "authentication.add_user"
PERMISSION_2 = "authentication.change_user"
class TestStatePermissions(TestCase):
def setUp(self):
# permissions
self.permission_1 = AuthUtils.get_permission_by_name(PERMISSION_1)
self.permission_2 = AuthUtils.get_permission_by_name(PERMISSION_2)
# group
self.group_1 = Group.objects.create(name="Group 1")
self.group_2 = Group.objects.create(name="Group 2")
# state
self.state_1 = AuthUtils.get_member_state()
self.state_2 = AuthUtils.create_state("Other State", 75)
# user
self.user = AuthUtils.create_user("Bruce Wayne")
self.main = AuthUtils.add_main_character_2(self.user, self.user.username, 123)
def test_user_has_user_permissions(self):
self.user.user_permissions.add(self.permission_1)
user = User.objects.get(pk=self.user.pk)
self.assertTrue(user.has_perm(PERMISSION_1))
def test_user_has_group_permissions(self):
self.group_1.permissions.add(self.permission_1)
self.user.groups.add(self.group_1)
user = User.objects.get(pk=self.user.pk)
self.assertTrue(user.has_perm(PERMISSION_1))
def test_user_has_state_permissions(self):
self.state_1.permissions.add(self.permission_1)
self.state_1.member_characters.add(self.main)
user = User.objects.get(pk=self.user.pk)
self.assertTrue(user.has_perm(PERMISSION_1))
def test_when_user_changes_state_perms_change_accordingly(self):
self.state_1.permissions.add(self.permission_1)
self.state_1.member_characters.add(self.main)
user = User.objects.get(pk=self.user.pk)
self.assertTrue(user.has_perm(PERMISSION_1))
self.state_2.permissions.add(self.permission_2)
self.state_2.member_characters.add(self.main)
self.state_1.member_characters.remove(self.main)
user = User.objects.get(pk=self.user.pk)
self.assertFalse(user.has_perm(PERMISSION_1))
self.assertTrue(user.has_perm(PERMISSION_2))
def test_state_permissions_are_returned_for_current_user_object(self):
# verify state permissions are returns for the current user object
# and not for it's instance in the database, which might be outdated
self.state_1.permissions.add(self.permission_1)
self.state_2.permissions.add(self.permission_2)
self.state_1.member_characters.add(self.main)
user = User.objects.get(pk=self.user.pk)
user.profile.state = self.state_2
self.assertFalse(user.has_perm(PERMISSION_1))
self.assertTrue(user.has_perm(PERMISSION_2))
class TestAuthenticate(TestCase):
@classmethod
def setUpTestData(cls):
cls.main_character = EveCharacter.objects.create(
character_id=1,
character_name='Main Character',
corporation_id=1,
corporation_name='Corp',
corporation_ticker='CORP',
)
cls.alt_character = EveCharacter.objects.create(
character_id=2,
character_name='Alt Character',
corporation_id=1,
corporation_name='Corp',
corporation_ticker='CORP',
)
cls.unclaimed_character = EveCharacter.objects.create(
character_id=3,
character_name='Unclaimed Character',
corporation_id=1,
corporation_name='Corp',
corporation_ticker='CORP',
)
cls.user = AuthUtils.create_user('test_user', disconnect_signals=True)
cls.old_user = AuthUtils.create_user('old_user', disconnect_signals=True)
AuthUtils.disconnect_signals()
CharacterOwnership.objects.create(user=cls.user, character=cls.main_character, owner_hash='1')
CharacterOwnership.objects.create(user=cls.user, character=cls.alt_character, owner_hash='2')
UserProfile.objects.update_or_create(user=cls.user, defaults={'main_character': cls.main_character})
AuthUtils.connect_signals()
def test_authenticate_main_character(self):
t = Token(character_id=self.main_character.character_id, character_owner_hash='1')
user = StateBackend().authenticate(token=t)
self.assertEquals(user, self.user)
def test_authenticate_alt_character(self):
t = Token(character_id=self.alt_character.character_id, character_owner_hash='2')
user = StateBackend().authenticate(token=t)
self.assertEquals(user, self.user)
def test_authenticate_unclaimed_character(self):
t = Token(character_id=self.unclaimed_character.character_id, character_name=self.unclaimed_character.character_name, character_owner_hash='3')
user = StateBackend().authenticate(token=t)
self.assertNotEqual(user, self.user)
self.assertEqual(user.username, 'Unclaimed_Character')
self.assertEqual(user.profile.main_character, self.unclaimed_character)
def test_authenticate_character_record(self):
t = Token(character_id=self.unclaimed_character.character_id, character_name=self.unclaimed_character.character_name, character_owner_hash='4')
OwnershipRecord.objects.create(user=self.old_user, character=self.unclaimed_character, owner_hash='4')
user = StateBackend().authenticate(token=t)
self.assertEqual(user, self.old_user)
self.assertTrue(CharacterOwnership.objects.filter(owner_hash='4', user=self.old_user).exists())
self.assertTrue(user.profile.main_character)
def test_iterate_username(self):
t = Token(character_id=self.unclaimed_character.character_id,
character_name=self.unclaimed_character.character_name, character_owner_hash='3')
username = StateBackend().authenticate(token=t).username
t.character_owner_hash = '4'
username_1 = StateBackend().authenticate(token=t).username
t.character_owner_hash = '5'
username_2 = StateBackend().authenticate(token=t).username
self.assertNotEqual(username, username_1, username_2)
self.assertTrue(username_1.endswith('_1'))
self.assertTrue(username_2.endswith('_2'))

View File

@@ -0,0 +1,35 @@
from io import StringIO
from django.core.management import call_command
from django.test import TestCase
from allianceauth.tests.auth_utils import AuthUtils
from ..models import CharacterOwnership, UserProfile
class ManagementCommandTestCase(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = AuthUtils.create_user('test user', disconnect_signals=True)
AuthUtils.add_main_character(cls.user, 'test character', '1', '2', 'test corp', 'test')
character = UserProfile.objects.get(user=cls.user).main_character
CharacterOwnership.objects.create(user=cls.user, character=character, owner_hash='test')
def setUp(self):
self.stdout = StringIO()
def test_ownership(self):
call_command('checkmains', stdout=self.stdout)
self.assertFalse(UserProfile.objects.filter(main_character__isnull=True).count())
self.assertNotIn(self.user.username, self.stdout.getvalue())
self.assertIn('All main characters', self.stdout.getvalue())
def test_no_ownership(self):
user = AuthUtils.create_user('v1 user', disconnect_signals=True)
AuthUtils.add_main_character(user, 'v1 character', '10', '20', 'test corp', 'test')
self.assertFalse(UserProfile.objects.filter(main_character__isnull=True).count())
call_command('checkmains', stdout=self.stdout)
self.assertEqual(UserProfile.objects.filter(main_character__isnull=True).count(), 1)
self.assertIn(user.username, self.stdout.getvalue())

View File

@@ -0,0 +1,68 @@
from unittest import mock
from urllib import parse
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.http.response import HttpResponse
from django.shortcuts import reverse
from django.test import TestCase
from django.test.client import RequestFactory
from allianceauth.eveonline.models import EveCharacter
from allianceauth.tests.auth_utils import AuthUtils
from ..decorators import main_character_required
from ..models import CharacterOwnership
MODULE_PATH = 'allianceauth.authentication'
class DecoratorTestCase(TestCase):
@staticmethod
@main_character_required
def dummy_view(*args, **kwargs):
return HttpResponse(status=200)
@classmethod
def setUpTestData(cls):
cls.main_user = AuthUtils.create_user('main_user', disconnect_signals=True)
cls.no_main_user = AuthUtils.create_user(
'no_main_user', disconnect_signals=True
)
main_character = EveCharacter.objects.create(
character_id=1,
character_name='Main Character',
corporation_id=1,
corporation_name='Corp',
corporation_ticker='CORP',
)
CharacterOwnership.objects.create(
user=cls.main_user, character=main_character, owner_hash='1'
)
cls.main_user.profile.main_character = main_character
def setUp(self):
self.request = RequestFactory().get('/test/')
@mock.patch(MODULE_PATH + '.decorators.messages')
def test_login_redirect(self, m):
setattr(self.request, 'user', AnonymousUser())
response = self.dummy_view(self.request)
self.assertEqual(response.status_code, 302)
url = getattr(response, 'url', None)
self.assertEqual(parse.urlparse(url).path, reverse(settings.LOGIN_URL))
@mock.patch(MODULE_PATH + '.decorators.messages')
def test_main_character_redirect(self, m):
setattr(self.request, 'user', self.no_main_user)
response = self.dummy_view(self.request)
self.assertEqual(response.status_code, 302)
url = getattr(response, 'url', None)
self.assertEqual(url, reverse('authentication:dashboard'))
@mock.patch(MODULE_PATH + '.decorators.messages')
def test_successful_request(self, m):
setattr(self.request, 'user', self.main_user)
response = self.dummy_view(self.request)
self.assertEqual(response.status_code, 200)

View File

@@ -1,147 +1,20 @@
from unittest import mock
from io import StringIO
from urllib import parse
from django.conf import settings
from django.contrib.auth.models import AnonymousUser, User
from django.core.management import call_command
from django.http.response import HttpResponse
from django.shortcuts import reverse
from django.contrib.auth.models import User
from django.test import TestCase
from django.test.client import RequestFactory
from allianceauth.authentication.decorators import main_character_required
from allianceauth.eveonline.models import EveCharacter, EveCorporationInfo,\
EveAllianceInfo
from allianceauth.tests.auth_utils import AuthUtils
from esi.errors import IncompleteResponseError
from esi.models import Token
from ..backends import StateBackend
from ..models import CharacterOwnership, UserProfile, State, get_guest_state,\
OwnershipRecord
from ..models import CharacterOwnership, State, get_guest_state
from ..tasks import check_character_ownership
MODULE_PATH = 'allianceauth.authentication'
class DecoratorTestCase(TestCase):
@staticmethod
@main_character_required
def dummy_view(*args, **kwargs):
return HttpResponse(status=200)
@classmethod
def setUpTestData(cls):
cls.main_user = AuthUtils.create_user('main_user', disconnect_signals=True)
cls.no_main_user = AuthUtils.create_user('no_main_user', disconnect_signals=True)
main_character = EveCharacter.objects.create(
character_id=1,
character_name='Main Character',
corporation_id=1,
corporation_name='Corp',
corporation_ticker='CORP',
)
CharacterOwnership.objects.create(user=cls.main_user, character=main_character, owner_hash='1')
cls.main_user.profile.main_character = main_character
def setUp(self):
self.request = RequestFactory().get('/test/')
@mock.patch(MODULE_PATH + '.decorators.messages')
def test_login_redirect(self, m):
setattr(self.request, 'user', AnonymousUser())
response = self.dummy_view(self.request)
self.assertEqual(response.status_code, 302)
url = getattr(response, 'url', None)
self.assertEqual(parse.urlparse(url).path, reverse(settings.LOGIN_URL))
@mock.patch(MODULE_PATH + '.decorators.messages')
def test_main_character_redirect(self, m):
setattr(self.request, 'user', self.no_main_user)
response = self.dummy_view(self.request)
self.assertEqual(response.status_code, 302)
url = getattr(response, 'url', None)
self.assertEqual(url, reverse('authentication:dashboard'))
@mock.patch(MODULE_PATH + '.decorators.messages')
def test_successful_request(self, m):
setattr(self.request, 'user', self.main_user)
response = self.dummy_view(self.request)
self.assertEqual(response.status_code, 200)
class BackendTestCase(TestCase):
@classmethod
def setUpTestData(cls):
cls.main_character = EveCharacter.objects.create(
character_id=1,
character_name='Main Character',
corporation_id=1,
corporation_name='Corp',
corporation_ticker='CORP',
)
cls.alt_character = EveCharacter.objects.create(
character_id=2,
character_name='Alt Character',
corporation_id=1,
corporation_name='Corp',
corporation_ticker='CORP',
)
cls.unclaimed_character = EveCharacter.objects.create(
character_id=3,
character_name='Unclaimed Character',
corporation_id=1,
corporation_name='Corp',
corporation_ticker='CORP',
)
cls.user = AuthUtils.create_user('test_user', disconnect_signals=True)
cls.old_user = AuthUtils.create_user('old_user', disconnect_signals=True)
AuthUtils.disconnect_signals()
CharacterOwnership.objects.create(user=cls.user, character=cls.main_character, owner_hash='1')
CharacterOwnership.objects.create(user=cls.user, character=cls.alt_character, owner_hash='2')
UserProfile.objects.update_or_create(user=cls.user, defaults={'main_character': cls.main_character})
AuthUtils.connect_signals()
def test_authenticate_main_character(self):
t = Token(character_id=self.main_character.character_id, character_owner_hash='1')
user = StateBackend().authenticate(token=t)
self.assertEquals(user, self.user)
def test_authenticate_alt_character(self):
t = Token(character_id=self.alt_character.character_id, character_owner_hash='2')
user = StateBackend().authenticate(token=t)
self.assertEquals(user, self.user)
def test_authenticate_unclaimed_character(self):
t = Token(character_id=self.unclaimed_character.character_id, character_name=self.unclaimed_character.character_name, character_owner_hash='3')
user = StateBackend().authenticate(token=t)
self.assertNotEqual(user, self.user)
self.assertEqual(user.username, 'Unclaimed_Character')
self.assertEqual(user.profile.main_character, self.unclaimed_character)
def test_authenticate_character_record(self):
t = Token(character_id=self.unclaimed_character.character_id, character_name=self.unclaimed_character.character_name, character_owner_hash='4')
record = OwnershipRecord.objects.create(user=self.old_user, character=self.unclaimed_character, owner_hash='4')
user = StateBackend().authenticate(token=t)
self.assertEqual(user, self.old_user)
self.assertTrue(CharacterOwnership.objects.filter(owner_hash='4', user=self.old_user).exists())
self.assertTrue(user.profile.main_character)
def test_iterate_username(self):
t = Token(character_id=self.unclaimed_character.character_id,
character_name=self.unclaimed_character.character_name, character_owner_hash='3')
username = StateBackend().authenticate(token=t).username
t.character_owner_hash = '4'
username_1 = StateBackend().authenticate(token=t).username
t.character_owner_hash = '5'
username_2 = StateBackend().authenticate(token=t).username
self.assertNotEqual(username, username_1, username_2)
self.assertTrue(username_1.endswith('_1'))
self.assertTrue(username_2.endswith('_2'))
class CharacterOwnershipTestCase(TestCase):
@classmethod
def setUpTestData(cls):
@@ -343,10 +216,10 @@ class CharacterOwnershipCheckTestCase(TestCase):
cls.user = AuthUtils.create_user('test_user', disconnect_signals=True)
AuthUtils.add_main_character(cls.user, 'Test Character', '1', corp_id='1', alliance_id='1',
corp_name='Test Corp', alliance_name='Test Alliance')
cls.character = EveCharacter.objects.get(character_id='1')
cls.character = EveCharacter.objects.get(character_id=1)
cls.token = Token.objects.create(
user=cls.user,
character_id='1',
character_id=1,
character_name='Test',
character_owner_hash='1',
)
@@ -378,30 +251,3 @@ class CharacterOwnershipCheckTestCase(TestCase):
filter.return_value.exists.return_value = False
check_character_ownership(self.ownership)
self.assertTrue(filter.return_value.delete.called)
class ManagementCommandTestCase(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = AuthUtils.create_user('test user', disconnect_signals=True)
AuthUtils.add_main_character(cls.user, 'test character', '1', '2', 'test corp', 'test')
character = UserProfile.objects.get(user=cls.user).main_character
CharacterOwnership.objects.create(user=cls.user, character=character, owner_hash='test')
def setUp(self):
self.stdout = StringIO()
def test_ownership(self):
call_command('checkmains', stdout=self.stdout)
self.assertFalse(UserProfile.objects.filter(main_character__isnull=True).count())
self.assertNotIn(self.user.username, self.stdout.getvalue())
self.assertIn('All main characters', self.stdout.getvalue())
def test_no_ownership(self):
user = AuthUtils.create_user('v1 user', disconnect_signals=True)
AuthUtils.add_main_character(user, 'v1 character', '10', '20', 'test corp', 'test')
self.assertFalse(UserProfile.objects.filter(main_character__isnull=True).count())
call_command('checkmains', stdout=self.stdout)
self.assertEqual(UserProfile.objects.filter(main_character__isnull=True).count(), 1)
self.assertIn(user.username, self.stdout.getvalue())

View File

@@ -0,0 +1,290 @@
from math import ceil
from unittest.mock import patch
from requests import RequestException
import requests_mock
from packaging.version import Version as Pep440Version
from django.test import TestCase
from allianceauth.templatetags.admin_status import (
status_overview,
_fetch_list_from_gitlab,
_current_notifications,
_current_version_summary,
_fetch_notification_issues_from_gitlab,
_fetch_tags_from_gitlab,
_latests_versions
)
MODULE_PATH = 'allianceauth.templatetags'
def create_tags_list(tag_names: list):
return [{'name': str(tag_name)} for tag_name in tag_names]
GITHUB_TAGS = create_tags_list(['v2.4.6a1', 'v2.4.5', 'v2.4.0', 'v2.0.0', 'v1.1.1'])
GITHUB_NOTIFICATION_ISSUES = [
{
'id': 1,
'title': 'first issue'
},
{
'id': 2,
'title': 'second issue'
},
{
'id': 3,
'title': 'third issue'
},
{
'id': 4,
'title': 'forth issue'
},
{
'id': 5,
'title': 'fifth issue'
},
{
'id': 6,
'title': 'sixth issue'
},
]
TEST_VERSION = '2.6.5'
class TestStatusOverviewTag(TestCase):
@patch(MODULE_PATH + '.admin_status.__version__', TEST_VERSION)
@patch(MODULE_PATH + '.admin_status._fetch_celery_queue_length')
@patch(MODULE_PATH + '.admin_status._current_version_summary')
@patch(MODULE_PATH + '.admin_status._current_notifications')
def test_status_overview(
self,
mock_current_notifications,
mock_current_version_info,
mock_fetch_celery_queue_length
):
notifications = {
'notifications': GITHUB_NOTIFICATION_ISSUES[:5]
}
mock_current_notifications.return_value = notifications
version_info = {
'latest_major': True,
'latest_minor': True,
'latest_patch': True,
'latest_beta': False,
'current_version': TEST_VERSION,
'latest_major_version': '2.4.5',
'latest_minor_version': '2.4.0',
'latest_patch_version': '2.4.5',
'latest_beta_version': '2.4.4a1',
}
mock_current_version_info.return_value = version_info
mock_fetch_celery_queue_length.return_value = 3
result = status_overview()
expected = {
'notifications': GITHUB_NOTIFICATION_ISSUES[:5],
'latest_major': True,
'latest_minor': True,
'latest_patch': True,
'latest_beta': False,
'current_version': TEST_VERSION,
'latest_major_version': '2.4.5',
'latest_minor_version': '2.4.0',
'latest_patch_version': '2.4.5',
'latest_beta_version': '2.4.4a1',
'task_queue_length': 3,
}
self.assertEqual(result, expected)
class TestNotifications(TestCase):
@requests_mock.mock()
def test_fetch_notification_issues_from_gitlab(self, requests_mocker):
url = (
'https://gitlab.com/api/v4/projects/allianceauth%2Fallianceauth/issues'
'?labels=announcement'
)
requests_mocker.get(url, json=GITHUB_NOTIFICATION_ISSUES)
result = _fetch_notification_issues_from_gitlab()
self.assertEqual(result, GITHUB_NOTIFICATION_ISSUES)
@patch(MODULE_PATH + '.admin_status.cache')
def test_current_notifications_normal(self, mock_cache):
mock_cache.get_or_set.return_value = GITHUB_NOTIFICATION_ISSUES
result = _current_notifications()
self.assertEqual(result['notifications'], GITHUB_NOTIFICATION_ISSUES[:5])
@patch(MODULE_PATH + '.admin_status.cache')
def test_current_notifications_failed(self, mock_cache):
mock_cache.get_or_set.side_effect = RequestException
result = _current_notifications()
self.assertEqual(result['notifications'], list())
@patch(MODULE_PATH + '.admin_status.cache')
def test_current_notifications_is_none(self, mock_cache):
mock_cache.get_or_set.return_value = None
result = _current_notifications()
self.assertEqual(result['notifications'], list())
class TestCeleryQueueLength(TestCase):
def test_get_celery_queue_length(self):
pass
class TestVersionTags(TestCase):
@patch(MODULE_PATH + '.admin_status.__version__', TEST_VERSION)
@patch(MODULE_PATH + '.admin_status.cache')
def test_current_version_info_normal(self, mock_cache):
mock_cache.get_or_set.return_value = GITHUB_TAGS
result = _current_version_summary()
self.assertTrue(result['latest_major'])
self.assertTrue(result['latest_minor'])
self.assertTrue(result['latest_patch'])
self.assertEqual(result['latest_major_version'], '2.0.0')
self.assertEqual(result['latest_minor_version'], '2.4.0')
self.assertEqual(result['latest_patch_version'], '2.4.5')
self.assertEqual(result['latest_beta_version'], '2.4.6a1')
@patch(MODULE_PATH + '.admin_status.__version__', TEST_VERSION)
@patch(MODULE_PATH + '.admin_status.cache')
def test_current_version_info_failed(self, mock_cache):
mock_cache.get_or_set.side_effect = RequestException
expected = {}
result = _current_version_summary()
self.assertEqual(result, expected)
@requests_mock.mock()
def test_fetch_tags_from_gitlab(self, requests_mocker):
url = (
'https://gitlab.com/api/v4/projects/allianceauth%2Fallianceauth'
'/repository/tags'
)
requests_mocker.get(url, json=GITHUB_TAGS)
result = _fetch_tags_from_gitlab()
self.assertEqual(result, GITHUB_TAGS)
@patch(MODULE_PATH + '.admin_status.__version__', TEST_VERSION)
@patch(MODULE_PATH + '.admin_status.cache')
def test_current_version_info_return_no_data(self, mock_cache):
mock_cache.get_or_set.return_value = None
expected = {}
result = _current_version_summary()
self.assertEqual(result, expected)
class TestLatestsVersion(TestCase):
def test_all_version_types_defined(self):
tags = create_tags_list(
['2.1.1', '2.1.0', '2.0.0', '2.1.1a1', '1.1.1', '1.1.0', '1.0.0']
)
major, minor, patch, beta = _latests_versions(tags)
self.assertEqual(major, Pep440Version('2.0.0'))
self.assertEqual(minor, Pep440Version('2.1.0'))
self.assertEqual(patch, Pep440Version('2.1.1'))
self.assertEqual(beta, Pep440Version('2.1.1a1'))
def test_major_and_minor_not_defined_with_zero(self):
tags = create_tags_list(
['2.1.2', '2.1.1', '2.0.1', '2.1.1a1', '1.1.1', '1.1.0', '1.0.0']
)
major, minor, patch, beta = _latests_versions(tags)
self.assertEqual(major, Pep440Version('2.0.1'))
self.assertEqual(minor, Pep440Version('2.1.1'))
self.assertEqual(patch, Pep440Version('2.1.2'))
self.assertEqual(beta, Pep440Version('2.1.1a1'))
def test_can_ignore_invalid_versions(self):
tags = create_tags_list(
['2.1.1', '2.1.0', '2.0.0', '2.1.1a1', 'invalid']
)
major, minor, patch, beta = _latests_versions(tags)
self.assertEqual(major, Pep440Version('2.0.0'))
self.assertEqual(minor, Pep440Version('2.1.0'))
self.assertEqual(patch, Pep440Version('2.1.1'))
self.assertEqual(beta, Pep440Version('2.1.1a1'))
class TestFetchListFromGitlab(TestCase):
page_size = 2
def setUp(self):
self.url = (
'https://gitlab.com/api/v4/projects/allianceauth%2Fallianceauth'
'/repository/tags'
)
@classmethod
def my_callback(cls, request, context):
page = int(request.qs['page'][0])
start = (page - 1) * cls.page_size
end = start + cls.page_size
return GITHUB_TAGS[start:end]
@requests_mock.mock()
def test_can_fetch_one_page_with_header(self, requests_mocker):
headers = {
'x-total-pages': '1'
}
requests_mocker.get(self.url, json=GITHUB_TAGS, headers=headers)
result = _fetch_list_from_gitlab(self.url)
self.assertEqual(result, GITHUB_TAGS)
self.assertEqual(requests_mocker.call_count, 1)
@requests_mock.mock()
def test_can_fetch_one_page_wo_header(self, requests_mocker):
requests_mocker.get(self.url, json=GITHUB_TAGS)
result = _fetch_list_from_gitlab(self.url)
self.assertEqual(result, GITHUB_TAGS)
self.assertEqual(requests_mocker.call_count, 1)
@requests_mock.mock()
def test_can_fetch_one_page_and_ignore_invalid_header(self, requests_mocker):
headers = {
'x-total-pages': 'invalid'
}
requests_mocker.get(self.url, json=GITHUB_TAGS, headers=headers)
result = _fetch_list_from_gitlab(self.url)
self.assertEqual(result, GITHUB_TAGS)
self.assertEqual(requests_mocker.call_count, 1)
@requests_mock.mock()
def test_can_fetch_multiple_pages(self, requests_mocker):
total_pages = ceil(len(GITHUB_TAGS) / self.page_size)
headers = {
'x-total-pages': str(total_pages)
}
requests_mocker.get(self.url, json=self.my_callback, headers=headers)
result = _fetch_list_from_gitlab(self.url)
self.assertEqual(result, GITHUB_TAGS)
self.assertEqual(requests_mocker.call_count, total_pages)
@requests_mock.mock()
def test_can_fetch_given_number_of_pages_only(self, requests_mocker):
total_pages = ceil(len(GITHUB_TAGS) / self.page_size)
headers = {
'x-total-pages': str(total_pages)
}
requests_mocker.get(self.url, json=self.my_callback, headers=headers)
max_pages = 2
result = _fetch_list_from_gitlab(self.url, max_pages=max_pages)
self.assertEqual(result, GITHUB_TAGS[:4])
self.assertEqual(requests_mocker.call_count, max_pages)

View File

@@ -8,7 +8,7 @@ class CorpStats(MenuItemHook):
def __init__(self):
MenuItemHook.__init__(self,
_('Corporation Stats'),
'fa fa-share-alt fa-fw',
'fas fa-share-alt fa-fw',
'corputils:view',
navactive=['corputils:'])

View File

@@ -17,9 +17,9 @@ class CorpStatsManagerTestCase(TestCase):
cls.user = AuthUtils.create_user('test')
AuthUtils.add_main_character(cls.user, 'test character', '1', corp_id='2', corp_name='test_corp', corp_ticker='TEST', alliance_id='3', alliance_name='TEST')
cls.user.profile.refresh_from_db()
cls.alliance = EveAllianceInfo.objects.create(alliance_id='3', alliance_name='test alliance', alliance_ticker='TEST', executor_corp_id='2')
cls.corp = EveCorporationInfo.objects.create(corporation_id='2', corporation_name='test corp', corporation_ticker='TEST', alliance=cls.alliance, member_count=1)
cls.token = Token.objects.create(user=cls.user, access_token='a', character_id='1', character_name='test character', character_owner_hash='z')
cls.alliance = EveAllianceInfo.objects.create(alliance_id=3, alliance_name='test alliance', alliance_ticker='TEST', executor_corp_id=2)
cls.corp = EveCorporationInfo.objects.create(corporation_id=2, corporation_name='test corp', corporation_ticker='TEST', alliance=cls.alliance, member_count=1)
cls.token = Token.objects.create(user=cls.user, access_token='a', character_id=1, character_name='test character', character_owner_hash='z')
cls.corpstats = CorpStats.objects.create(corp=cls.corp, token=cls.token)
cls.view_corp_permission = Permission.objects.get_by_natural_key('view_corp_corpstats', 'corputils', 'corpstats')
cls.view_alliance_permission = Permission.objects.get_by_natural_key('view_alliance_corpstats', 'corputils', 'corpstats')
@@ -66,9 +66,9 @@ class CorpStatsUpdateTestCase(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = AuthUtils.create_user('test')
AuthUtils.add_main_character(cls.user, 'test character', '1', corp_id='2', corp_name='test_corp', corp_ticker='TEST', alliance_id='3', alliance_name='TEST')
cls.token = Token.objects.create(user=cls.user, access_token='a', character_id='1', character_name='test character', character_owner_hash='z')
cls.corp = EveCorporationInfo.objects.create(corporation_id='2', corporation_name='test corp', corporation_ticker='TEST', member_count=1)
AuthUtils.add_main_character(cls.user, 'test character', '1', corp_id=2, corp_name='test_corp', corp_ticker='TEST', alliance_id=3, alliance_name='TEST')
cls.token = Token.objects.create(user=cls.user, access_token='a', character_id=1, character_name='test character', character_owner_hash='z')
cls.corp = EveCorporationInfo.objects.create(corporation_id=2, corporation_name='test corp', corporation_ticker='TEST', member_count=1)
def setUp(self):
self.corpstats = CorpStats.objects.get_or_create(token=self.token, corp=self.corp)[0]
@@ -88,11 +88,11 @@ class CorpStatsUpdateTestCase(TestCase):
SwaggerClient.from_spec.return_value.Corporation.get_corporations_corporation_id_members.return_value.result.return_value = [1]
SwaggerClient.from_spec.return_value.Universe.post_universe_names.return_value.result.return_value = [{'id': 1, 'name': 'test character'}]
self.corpstats.update()
self.assertTrue(CorpMember.objects.filter(character_id='1', character_name='test character', corpstats=self.corpstats).exists())
self.assertTrue(CorpMember.objects.filter(character_id=1, character_name='test character', corpstats=self.corpstats).exists())
@mock.patch('esi.clients.SwaggerClient')
def test_update_remove_member(self, SwaggerClient):
CorpMember.objects.create(character_id='2', character_name='old test character', corpstats=self.corpstats)
CorpMember.objects.create(character_id=2, character_name='old test character', corpstats=self.corpstats)
SwaggerClient.from_spec.return_value.Character.get_characters_character_id.return_value.result.return_value = {'corporation_id': 2}
SwaggerClient.from_spec.return_value.Corporation.get_corporations_corporation_id_members.return_value.result.return_value = [1]
SwaggerClient.from_spec.return_value.Universe.post_universe_names.return_value.result.return_value = [{'id': 1, 'name': 'test character'}]
@@ -130,15 +130,15 @@ class CorpStatsPropertiesTestCase(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = AuthUtils.create_user('test')
AuthUtils.add_main_character(cls.user, 'test character', '1', corp_id='2', corp_name='test_corp', corp_ticker='TEST', alliance_id='3', alliance_name='TEST')
AuthUtils.add_main_character(cls.user, 'test character', '1', corp_id=2, corp_name='test_corp', corp_ticker='TEST', alliance_id=3, alliance_name='TEST')
cls.user.profile.refresh_from_db()
cls.token = Token.objects.create(user=cls.user, access_token='a', character_id='1', character_name='test character', character_owner_hash='z')
cls.corp = EveCorporationInfo.objects.create(corporation_id='2', corporation_name='test corp', corporation_ticker='TEST', member_count=1)
cls.token = Token.objects.create(user=cls.user, access_token='a', character_id=1, character_name='test character', character_owner_hash='z')
cls.corp = EveCorporationInfo.objects.create(corporation_id=2, corporation_name='test corp', corporation_ticker='TEST', member_count=1)
cls.corpstats = CorpStats.objects.create(token=cls.token, corp=cls.corp)
cls.character = EveCharacter.objects.create(character_name='another test character', character_id='4', corporation_id='2', corporation_name='test corp', corporation_ticker='TEST')
cls.character = EveCharacter.objects.create(character_name='another test character', character_id=4, corporation_id=2, corporation_name='test corp', corporation_ticker='TEST')
def test_member_count(self):
member = CorpMember.objects.create(corpstats=self.corpstats, character_id='1', character_name='test character')
member = CorpMember.objects.create(corpstats=self.corpstats, character_id=2, character_name='test character')
self.assertEqual(self.corpstats.member_count, 1)
member.delete()
self.assertEqual(self.corpstats.member_count, 0)
@@ -147,7 +147,7 @@ class CorpStatsPropertiesTestCase(TestCase):
AuthUtils.disconnect_signals()
co = CharacterOwnership.objects.create(character=self.character, user=self.user, owner_hash='a')
AuthUtils.connect_signals()
CorpMember.objects.create(corpstats=self.corpstats, character_id='4', character_name='test character')
CorpMember.objects.create(corpstats=self.corpstats, character_id=4, character_name='test character')
self.assertEqual(self.corpstats.user_count, 1)
co.delete()
self.assertEqual(self.corpstats.user_count, 0)
@@ -156,7 +156,8 @@ class CorpStatsPropertiesTestCase(TestCase):
AuthUtils.disconnect_signals()
co = CharacterOwnership.objects.create(character=self.character, user=self.user, owner_hash='a')
AuthUtils.connect_signals()
member = CorpMember.objects.create(corpstats=self.corpstats, character_id='4', character_name='test character')
member = CorpMember.objects.create(corpstats=self.corpstats, character_id=4, character_name='test character')
self.corpstats.refresh_from_db()
self.assertIn(member, self.corpstats.registered_members)
self.assertEqual(self.corpstats.registered_member_count, 1)
@@ -165,7 +166,7 @@ class CorpStatsPropertiesTestCase(TestCase):
self.assertEqual(self.corpstats.registered_member_count, 0)
def test_unregistered_members(self):
member = CorpMember.objects.create(corpstats=self.corpstats, character_id='4', character_name='test character')
member = CorpMember.objects.create(corpstats=self.corpstats, character_id=4, character_name='test character')
self.corpstats.refresh_from_db()
self.assertIn(member, self.corpstats.unregistered_members)
self.assertEqual(self.corpstats.unregistered_member_count, 1)
@@ -178,13 +179,13 @@ class CorpStatsPropertiesTestCase(TestCase):
def test_mains(self):
# test when is a main
member = CorpMember.objects.create(corpstats=self.corpstats, character_id='1', character_name='test character')
member = CorpMember.objects.create(corpstats=self.corpstats, character_id=1, character_name='test character')
self.assertIn(member, self.corpstats.mains)
self.assertEqual(self.corpstats.main_count, 1)
# test when is an alt
old_main = self.user.profile.main_character
character = EveCharacter.objects.create(character_name='other character', character_id=10, corporation_name='test corp', corporation_id='2', corporation_ticker='TEST')
character = EveCharacter.objects.create(character_name='other character', character_id=10, corporation_name='test corp', corporation_id=2, corporation_ticker='TEST')
AuthUtils.disconnect_signals()
co = CharacterOwnership.objects.create(character=character, user=self.user, owner_hash='b')
self.user.profile.main_character = character
@@ -208,7 +209,7 @@ class CorpStatsPropertiesTestCase(TestCase):
self.assertEqual(self.corpstats.corp_logo(size=128), 'https://images.evetech.net/corporations/2/logo?size=128')
self.assertEqual(self.corpstats.alliance_logo(size=128), 'https://images.evetech.net/alliances/1/logo?size=128')
alliance = EveAllianceInfo.objects.create(alliance_name='test alliance', alliance_id='3', alliance_ticker='TEST', executor_corp_id='2')
alliance = EveAllianceInfo.objects.create(alliance_name='test alliance', alliance_id=3, alliance_ticker='TEST', executor_corp_id=2)
self.corp.alliance = alliance
self.corp.save()
self.assertEqual(self.corpstats.alliance_logo(size=128), 'https://images.evetech.net/alliances/3/logo?size=128')
@@ -221,14 +222,14 @@ class CorpMemberTestCase(TestCase):
cls.user = AuthUtils.create_user('test')
AuthUtils.add_main_character(cls.user, 'test character', '1', corp_id='2', corp_name='test_corp', corp_ticker='TEST', alliance_id='3', alliance_name='TEST')
cls.user.profile.refresh_from_db()
cls.token = Token.objects.create(user=cls.user, access_token='a', character_id='1', character_name='test character', character_owner_hash='a')
cls.corp = EveCorporationInfo.objects.create(corporation_id='2', corporation_name='test corp', corporation_ticker='TEST', member_count=1)
cls.token = Token.objects.create(user=cls.user, access_token='a', character_id=1, character_name='test character', character_owner_hash='a')
cls.corp = EveCorporationInfo.objects.create(corporation_id=2, corporation_name='test corp', corporation_ticker='TEST', member_count=1)
cls.corpstats = CorpStats.objects.create(token=cls.token, corp=cls.corp)
cls.member = CorpMember.objects.create(corpstats=cls.corpstats, character_id='2', character_name='other test character')
cls.member = CorpMember.objects.create(corpstats=cls.corpstats, character_id=2, character_name='other test character')
def test_character(self):
self.assertIsNone(self.member.character)
character = EveCharacter.objects.create(character_id='2', character_name='other test character', corporation_id='2', corporation_name='test corp', corporation_ticker='TEST')
character = EveCharacter.objects.create(character_id=2, character_name='other test character', corporation_id=2, corporation_name='test corp', corporation_ticker='TEST')
self.assertEqual(self.member.character, character)
def test_main_character(self):
@@ -238,7 +239,7 @@ class CorpMemberTestCase(TestCase):
self.assertIsNone(self.member.main_character)
# test when member.character is not None but also not a main
character = EveCharacter.objects.create(character_id='2', character_name='other test character', corporation_id='2', corporation_name='test corp', corporation_ticker='TEST')
character = EveCharacter.objects.create(character_id=2, character_name='other test character', corporation_id=2, corporation_name='test corp', corporation_ticker='TEST')
CharacterOwnership.objects.create(character=character, user=self.user, owner_hash='b')
self.member.refresh_from_db()
self.assertNotEqual(self.member.main_character, self.member.character)
@@ -260,14 +261,14 @@ class CorpMemberTestCase(TestCase):
def test_alts(self):
self.assertListEqual(self.member.alts, [])
character = EveCharacter.objects.create(character_id='2', character_name='other test character', corporation_id='2', corporation_name='test corp', corporation_ticker='TEST')
character = EveCharacter.objects.create(character_id=2, character_name='other test character', corporation_id=2, corporation_name='test corp', corporation_ticker='TEST')
CharacterOwnership.objects.create(character=character, user=self.user, owner_hash='b')
self.assertIn(character, self.member.alts)
def test_registered(self):
self.assertFalse(self.member.registered)
AuthUtils.disconnect_signals()
character = EveCharacter.objects.create(character_id='2', character_name='other test character', corporation_id='2', corporation_name='test corp', corporation_ticker='TEST')
character = EveCharacter.objects.create(character_id=2, character_name='other test character', corporation_id=2, corporation_name='test corp', corporation_ticker='TEST')
CharacterOwnership.objects.create(character=character, user=self.user, owner_hash='b')
self.assertTrue(self.member.registered)
AuthUtils.connect_signals()

View File

@@ -4,14 +4,14 @@
# It contains of modules for views and templatetags for templates
# list of all eve entity categories as defined in ESI
ESI_CATEGORY_AGENT = "agent"
ESI_CATEGORY_ALLIANCE = "alliance"
ESI_CATEGORY_CHARACTER = "character"
ESI_CATEGORY_CONSTELLATION = "constellation"
ESI_CATEGORY_CORPORATION = "corporation"
ESI_CATEGORY_FACTION = "faction"
ESI_CATEGORY_INVENTORYTYPE = "inventory_type"
ESI_CATEGORY_REGION = "region"
ESI_CATEGORY_SOLARSYSTEM = "solar_system"
ESI_CATEGORY_STATION = "station"
ESI_CATEGORY_WORMHOLE = "wormhole"
_ESI_CATEGORY_AGENT = "agent"
_ESI_CATEGORY_ALLIANCE = "alliance"
_ESI_CATEGORY_CHARACTER = "character"
_ESI_CATEGORY_CONSTELLATION = "constellation"
_ESI_CATEGORY_CORPORATION = "corporation"
_ESI_CATEGORY_FACTION = "faction"
_ESI_CATEGORY_INVENTORYTYPE = "inventory_type"
_ESI_CATEGORY_REGION = "region"
_ESI_CATEGORY_SOLARSYSTEM = "solar_system"
_ESI_CATEGORY_STATION = "station"
_ESI_CATEGORY_WORMHOLE = "wormhole"

View File

@@ -2,24 +2,30 @@
from urllib.parse import urljoin, quote
from . import *
from . import (
_ESI_CATEGORY_ALLIANCE,
_ESI_CATEGORY_CORPORATION,
_ESI_CATEGORY_REGION,
_ESI_CATEGORY_SOLARSYSTEM
)
BASE_URL = 'http://evemaps.dotlan.net'
_BASE_URL = 'http://evemaps.dotlan.net'
def _build_url(category: str, name: str) -> str:
"""return url to profile page for an eve entity"""
if category == ESI_CATEGORY_ALLIANCE:
if category == _ESI_CATEGORY_ALLIANCE:
partial = 'alliance'
elif category == ESI_CATEGORY_CORPORATION:
elif category == _ESI_CATEGORY_CORPORATION:
partial = 'corp'
elif category == ESI_CATEGORY_REGION:
elif category == _ESI_CATEGORY_REGION:
partial = 'map'
elif category == ESI_CATEGORY_SOLARSYSTEM:
elif category == _ESI_CATEGORY_SOLARSYSTEM:
partial = 'system'
else:
@@ -28,7 +34,7 @@ def _build_url(category: str, name: str) -> str:
)
url = urljoin(
BASE_URL,
_BASE_URL,
'{}/{}'.format(partial, quote(str(name).replace(" ", "_")))
)
@@ -37,16 +43,19 @@ def _build_url(category: str, name: str) -> str:
def alliance_url(name: str) -> str:
"""url for page about given alliance on dotlan"""
return _build_url(ESI_CATEGORY_ALLIANCE, name)
return _build_url(_ESI_CATEGORY_ALLIANCE, name)
def corporation_url(name: str) -> str:
"""url for page about given corporation on dotlan"""
return _build_url(ESI_CATEGORY_CORPORATION, name)
return _build_url(_ESI_CATEGORY_CORPORATION, name)
def region_url(name: str) -> str:
"""url for page about given region on dotlan"""
return _build_url(ESI_CATEGORY_REGION, name)
return _build_url(_ESI_CATEGORY_REGION, name)
def solar_system_url(name: str) -> str:
"""url for page about given solar system on dotlan"""
return _build_url(ESI_CATEGORY_SOLARSYSTEM, name)
return _build_url(_ESI_CATEGORY_SOLARSYSTEM, name)

View File

@@ -0,0 +1,129 @@
from . import (
_ESI_CATEGORY_ALLIANCE,
_ESI_CATEGORY_CHARACTER,
_ESI_CATEGORY_CORPORATION,
_ESI_CATEGORY_INVENTORYTYPE
)
_EVE_IMAGE_SERVER_URL = 'https://images.evetech.net'
_DEFAULT_IMAGE_SIZE = 32
def _eve_entity_image_url(
category: str,
entity_id: int,
size: int = 32,
variant: str = None,
tenant: str = None,
) -> str:
"""returns image URL for an Eve Online ID.
Supported categories: alliance, corporation, character, inventory_type
Arguments:
- category: category of the ID, see ESI category constants
- entity_id: Eve ID of the entity
- size: (optional) render size of the image.must be between 32 (default) and 1024
- variant: (optional) image variant for category. currently not relevant.
- tenant: (optional) Eve Server, either `tranquility`(default) or `singularity`
Returns:
- URL string for the requested image on the Eve image server
Exceptions:
- Throws ValueError on invalid input
"""
# input validations
categories = {
_ESI_CATEGORY_ALLIANCE: {
'endpoint': 'alliances',
'variants': ['logo']
},
_ESI_CATEGORY_CORPORATION: {
'endpoint': 'corporations',
'variants': ['logo']
},
_ESI_CATEGORY_CHARACTER: {
'endpoint': 'characters',
'variants': ['portrait']
},
_ESI_CATEGORY_INVENTORYTYPE: {
'endpoint': 'types',
'variants': ['icon', 'render']
}
}
tenants = ['tranquility', 'singularity']
if not entity_id:
raise ValueError('Invalid entity_id: {}'.format(entity_id))
else:
entity_id = int(entity_id)
if not size or size < 32 or size > 1024 or (size & (size - 1) != 0):
raise ValueError('Invalid size: {}'.format(size))
if category not in categories:
raise ValueError('Invalid category {}'.format(category))
else:
endpoint = categories[category]['endpoint']
if variant:
if variant not in categories[category]['variants']:
raise ValueError('Invalid variant {} for category {}'.format(
variant,
category
))
else:
variant = categories[category]['variants'][0]
if tenant and tenant not in tenants:
raise ValueError('Invalid tenant {}'.format(tenant))
# compose result URL
result = '{}/{}/{}/{}?size={}'.format(
_EVE_IMAGE_SERVER_URL,
endpoint,
entity_id,
variant,
size
)
if tenant:
result += '&tenant={}'.format(tenant)
return result
def alliance_logo_url(alliance_id: int, size: int = _DEFAULT_IMAGE_SIZE) -> str:
"""image URL for the given alliance ID"""
return _eve_entity_image_url(_ESI_CATEGORY_ALLIANCE, alliance_id, size)
def corporation_logo_url(
corporation_id: int, size: int = _DEFAULT_IMAGE_SIZE
) -> str:
"""image URL for the given corporation ID"""
return _eve_entity_image_url(
_ESI_CATEGORY_CORPORATION, corporation_id, size
)
def character_portrait_url(
character_id: int, size: int = _DEFAULT_IMAGE_SIZE
) -> str:
"""image URL for the given character ID"""
return _eve_entity_image_url(_ESI_CATEGORY_CHARACTER, character_id, size)
def type_icon_url(type_id: int, size: int = _DEFAULT_IMAGE_SIZE) -> str:
"""icon image URL for the given type ID"""
return _eve_entity_image_url(
_ESI_CATEGORY_INVENTORYTYPE, type_id, size, variant='icon'
)
def type_render_url(type_id: int, size: int = _DEFAULT_IMAGE_SIZE) -> str:
"""render image URL for the given type ID"""
return _eve_entity_image_url(
_ESI_CATEGORY_INVENTORYTYPE, type_id, size, variant='render'
)

View File

@@ -1,22 +1,27 @@
# this module generates profile URLs for evewho
from urllib.parse import urljoin, quote
from urllib.parse import urljoin
from . import *
from . import (
_ESI_CATEGORY_ALLIANCE,
_ESI_CATEGORY_CORPORATION,
_ESI_CATEGORY_CHARACTER,
)
BASE_URL = 'https://evewho.com'
_BASE_URL = 'https://evewho.com'
def _build_url(category: str, eve_id: int) -> str:
"""return url to profile page for an eve entity"""
if category == ESI_CATEGORY_ALLIANCE:
if category == _ESI_CATEGORY_ALLIANCE:
partial = 'alliance'
elif category == ESI_CATEGORY_CORPORATION:
elif category == _ESI_CATEGORY_CORPORATION:
partial = 'corporation'
elif category == ESI_CATEGORY_CHARACTER:
elif category == _ESI_CATEGORY_CHARACTER:
partial = 'character'
else:
@@ -25,7 +30,7 @@ def _build_url(category: str, eve_id: int) -> str:
)
url = urljoin(
BASE_URL,
_BASE_URL,
'{}/{}'.format(partial, int(eve_id))
)
return url
@@ -33,12 +38,14 @@ def _build_url(category: str, eve_id: int) -> str:
def alliance_url(eve_id: int) -> str:
"""url for page about given alliance on evewho"""
return _build_url(ESI_CATEGORY_ALLIANCE, eve_id)
return _build_url(_ESI_CATEGORY_ALLIANCE, eve_id)
def character_url(eve_id: int) -> str:
"""url for page about given character on evewho"""
return _build_url(ESI_CATEGORY_CHARACTER, eve_id)
return _build_url(_ESI_CATEGORY_CHARACTER, eve_id)
def corporation_url(eve_id: int) -> str:
"""url for page about given corporation on evewho"""
return _build_url(ESI_CATEGORY_CORPORATION, eve_id)
return _build_url(_ESI_CATEGORY_CORPORATION, eve_id)

View File

@@ -1,7 +1,7 @@
from django.test import TestCase
from ...models import EveCharacter, EveCorporationInfo, EveAllianceInfo
from .. import dotlan, zkillboard, evewho
from .. import dotlan, zkillboard, evewho, eveimageserver
from ...templatetags import evelinks
@@ -90,3 +90,115 @@ class TestZkillboard(TestCase):
'https://zkillboard.com/system/12345678/'
)
class TestEveImageServer(TestCase):
"""unit test for eveimageserver"""
def test_sizes(self):
self.assertEqual(
eveimageserver._eve_entity_image_url('character', 42),
'https://images.evetech.net/characters/42/portrait?size=32'
)
self.assertEqual(
eveimageserver._eve_entity_image_url('character', 42, size=32),
'https://images.evetech.net/characters/42/portrait?size=32'
)
self.assertEqual(
eveimageserver._eve_entity_image_url('character', 42, size=64),
'https://images.evetech.net/characters/42/portrait?size=64'
)
self.assertEqual(
eveimageserver._eve_entity_image_url('character', 42, size=128),
'https://images.evetech.net/characters/42/portrait?size=128'
)
self.assertEqual(
eveimageserver._eve_entity_image_url('character', 42, size=256),
'https://images.evetech.net/characters/42/portrait?size=256'
)
self.assertEqual(
eveimageserver._eve_entity_image_url('character', 42, size=512),
'https://images.evetech.net/characters/42/portrait?size=512'
)
self.assertEqual(
eveimageserver._eve_entity_image_url('character', 42, size=1024),
'https://images.evetech.net/characters/42/portrait?size=1024'
)
with self.assertRaises(ValueError):
eveimageserver._eve_entity_image_url('corporation', 42, size=-5)
with self.assertRaises(ValueError):
eveimageserver._eve_entity_image_url('corporation', 42, size=0)
with self.assertRaises(ValueError):
eveimageserver._eve_entity_image_url('corporation', 42, size=31)
with self.assertRaises(ValueError):
eveimageserver._eve_entity_image_url('corporation', 42, size=1025)
with self.assertRaises(ValueError):
eveimageserver._eve_entity_image_url('corporation', 42, size=2048)
def test_variant(self):
self.assertEqual(
eveimageserver._eve_entity_image_url('character', 42, variant='portrait'),
'https://images.evetech.net/characters/42/portrait?size=32'
)
self.assertEqual(
eveimageserver._eve_entity_image_url('alliance', 42, variant='logo'),
'https://images.evetech.net/alliances/42/logo?size=32'
)
with self.assertRaises(ValueError):
eveimageserver._eve_entity_image_url('character', 42, variant='logo')
def test_alliance(self):
self.assertEqual(
eveimageserver._eve_entity_image_url('alliance', 42),
'https://images.evetech.net/alliances/42/logo?size=32'
)
self.assertEqual(
eveimageserver._eve_entity_image_url('corporation', 42),
'https://images.evetech.net/corporations/42/logo?size=32'
)
self.assertEqual(
eveimageserver._eve_entity_image_url('character', 42),
'https://images.evetech.net/characters/42/portrait?size=32'
)
with self.assertRaises(ValueError):
eveimageserver._eve_entity_image_url('station', 42)
def test_tenants(self):
self.assertEqual(
eveimageserver._eve_entity_image_url('character', 42, tenant='tranquility'),
'https://images.evetech.net/characters/42/portrait?size=32&tenant=tranquility'
)
self.assertEqual(
eveimageserver._eve_entity_image_url('character', 42, tenant='singularity'),
'https://images.evetech.net/characters/42/portrait?size=32&tenant=singularity'
)
with self.assertRaises(ValueError):
eveimageserver._eve_entity_image_url('character', 42, tenant='xxx')
def test_alliance_logo_url(self):
expected = 'https://images.evetech.net/alliances/42/logo?size=128'
self.assertEqual(eveimageserver.alliance_logo_url(42, 128), expected)
def test_corporation_logo_url(self):
expected = 'https://images.evetech.net/corporations/42/logo?size=128'
self.assertEqual(eveimageserver.corporation_logo_url(42, 128), expected)
def test_character_portrait_url(self):
expected = 'https://images.evetech.net/characters/42/portrait?size=128'
self.assertEqual(
eveimageserver.character_portrait_url(42, 128), expected
)
def test_type_icon_url(self):
expected = 'https://images.evetech.net/types/42/icon?size=128'
self.assertEqual(eveimageserver.type_icon_url(42, 128), expected)
def test_type_render_url(self):
expected = 'https://images.evetech.net/types/42/render?size=128'
self.assertEqual(eveimageserver.type_render_url(42, 128), expected)

View File

@@ -1,7 +1,7 @@
from django.test import TestCase
from ...models import EveCharacter, EveCorporationInfo, EveAllianceInfo
from .. import dotlan, zkillboard, evewho
from .. import eveimageserver, evewho, dotlan, zkillboard
from ...templatetags import evelinks
@@ -332,3 +332,28 @@ class TestTemplateTags(TestCase):
''
)
def test_type_icon_url(self):
expected = eveimageserver.type_icon_url(123)
self.assertEqual(evelinks.type_icon_url(123), expected)
expected = eveimageserver.type_icon_url(123, 128)
self.assertEqual(evelinks.type_icon_url(123, 128), expected)
expected = ''
self.assertEqual(evelinks.type_icon_url(123, 99), expected)
expected = ''
self.assertEqual(evelinks.type_icon_url(None), expected)
def test_type_render_url(self):
expected = eveimageserver.type_render_url(123)
self.assertEqual(evelinks.type_render_url(123), expected)
expected = eveimageserver.type_render_url(123, 128)
self.assertEqual(evelinks.type_render_url(123, 128), expected)
expected = ''
self.assertEqual(evelinks.type_render_url(123, 99), expected)
expected = ''
self.assertEqual(evelinks.type_render_url(None), expected)

View File

@@ -1,28 +1,35 @@
# this module generates profile URLs for zKillboard
from urllib.parse import urljoin, quote
from urllib.parse import urljoin
from . import *
from . import (
_ESI_CATEGORY_ALLIANCE,
_ESI_CATEGORY_CORPORATION,
_ESI_CATEGORY_CHARACTER,
_ESI_CATEGORY_REGION,
_ESI_CATEGORY_SOLARSYSTEM
)
BASE_URL = 'https://zkillboard.com'
_BASE_URL = 'https://zkillboard.com'
def _build_url(category: str, eve_id: int) -> str:
"""return url to profile page for an eve entity"""
if category == ESI_CATEGORY_ALLIANCE:
if category == _ESI_CATEGORY_ALLIANCE:
partial = 'alliance'
elif category == ESI_CATEGORY_CORPORATION:
elif category == _ESI_CATEGORY_CORPORATION:
partial = 'corporation'
elif category == ESI_CATEGORY_CHARACTER:
elif category == _ESI_CATEGORY_CHARACTER:
partial = 'character'
elif category == ESI_CATEGORY_REGION:
elif category == _ESI_CATEGORY_REGION:
partial = 'region'
elif category == ESI_CATEGORY_SOLARSYSTEM:
elif category == _ESI_CATEGORY_SOLARSYSTEM:
partial = 'system'
else:
@@ -31,7 +38,7 @@ def _build_url(category: str, eve_id: int) -> str:
)
url = urljoin(
BASE_URL,
_BASE_URL,
'{}/{}/'.format(partial, int(eve_id))
)
return url
@@ -39,19 +46,23 @@ def _build_url(category: str, eve_id: int) -> str:
def alliance_url(eve_id: int) -> str:
"""url for page about given alliance on zKillboard"""
return _build_url(ESI_CATEGORY_ALLIANCE, eve_id)
return _build_url(_ESI_CATEGORY_ALLIANCE, eve_id)
def character_url(eve_id: int) -> str:
"""url for page about given character on zKillboard"""
return _build_url(ESI_CATEGORY_CHARACTER, eve_id)
return _build_url(_ESI_CATEGORY_CHARACTER, eve_id)
def corporation_url(eve_id: int) -> str:
"""url for page about given corporation on zKillboard"""
return _build_url(ESI_CATEGORY_CORPORATION, eve_id)
return _build_url(_ESI_CATEGORY_CORPORATION, eve_id)
def region_url(eve_id: int) -> str:
"""url for page about given region on zKillboard"""
return _build_url(ESI_CATEGORY_REGION, eve_id)
return _build_url(_ESI_CATEGORY_REGION, eve_id)
def solar_system_url(eve_id: int) -> str:
return _build_url(ESI_CATEGORY_SOLARSYSTEM, eve_id)
return _build_url(_ESI_CATEGORY_SOLARSYSTEM, eve_id)

View File

@@ -89,4 +89,6 @@ class EveCorporationManager(models.Manager):
)
def update_corporation(self, corp_id):
return self.get(corporation_id=corp_id).update_corporation(self.provider.get_corporation(corp_id))
return self\
.get(corporation_id=corp_id)\
.update_corporation(self.provider.get_corporation(corp_id))

View File

@@ -0,0 +1,43 @@
# Generated by Django 2.2.12 on 2020-05-25 02:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('eveonline', '0010_alliance_ticker'),
]
operations = [
migrations.AlterField(
model_name='eveallianceinfo',
name='alliance_id',
field=models.PositiveIntegerField(unique=True),
),
migrations.AlterField(
model_name='eveallianceinfo',
name='executor_corp_id',
field=models.PositiveIntegerField(),
),
migrations.AlterField(
model_name='evecharacter',
name='alliance_id',
field=models.PositiveIntegerField(blank=True, default=None, null=True),
),
migrations.AlterField(
model_name='evecharacter',
name='character_id',
field=models.PositiveIntegerField(unique=True),
),
migrations.AlterField(
model_name='evecharacter',
name='corporation_id',
field=models.PositiveIntegerField(),
),
migrations.AlterField(
model_name='evecorporationinfo',
name='corporation_id',
field=models.PositiveIntegerField(unique=True),
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 2.2.12 on 2020-05-26 02:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('eveonline', '0011_ids_to_integers'),
]
operations = [
migrations.AddIndex(
model_name='eveallianceinfo',
index=models.Index(fields=['executor_corp_id'], name='eveonline_e_executo_7f3280_idx'),
),
migrations.AddIndex(
model_name='evecharacter',
index=models.Index(fields=['corporation_id'], name='eveonline_e_corpora_cb4cd9_idx'),
),
migrations.AddIndex(
model_name='evecharacter',
index=models.Index(fields=['alliance_id'], name='eveonline_e_allianc_39ee2a_idx'),
),
migrations.AddIndex(
model_name='evecharacter',
index=models.Index(fields=['corporation_name'], name='eveonline_e_corpora_893c60_idx'),
),
migrations.AddIndex(
model_name='evecharacter',
index=models.Index(fields=['alliance_name'], name='eveonline_e_allianc_63fd98_idx'),
),
]

View File

@@ -5,109 +5,35 @@ from .managers import EveCharacterManager, EveCharacterProviderManager
from .managers import EveCorporationManager, EveCorporationProviderManager
from .managers import EveAllianceManager, EveAllianceProviderManager
from . import providers
from .evelinks import eveimageserver
EVE_IMAGE_SERVER_URL = 'https://images.evetech.net'
def _eve_entity_image_url(
category: str,
id: int,
size: int = 32,
variant: str = None,
tenant: str = None,
) -> str:
"""returns image URL for an Eve Online ID.
Supported categories: `alliance`, `corporation`, `character`
Arguments:
- category: category of the ID
- id: Eve ID of the entity
- size: (optional) render size of the image.must be between 32 (default) and 1024
- variant: (optional) image variant for category. currently not relevant.
- tentant: (optional) Eve Server, either `tranquility`(default) or `singularity`
Returns:
- URL string for the requested image on the Eve image server
Exceptions:
- Throws ValueError on invalid input
"""
# input validations
categories = {
'alliance': {
'endpoint': 'alliances',
'variants': [
'logo'
]
},
'corporation': {
'endpoint': 'corporations',
'variants': [
'logo'
]
},
'character': {
'endpoint': 'characters',
'variants': [
'portrait'
]
}
}
tenants = ['tranquility', 'singularity']
if size < 32 or size > 1024 or (size & (size - 1) != 0):
raise ValueError('Invalid size: {}'.format(size))
if category not in categories:
raise ValueError('Invalid category {}'.format(category))
else:
endpoint = categories[category]['endpoint']
if variant:
if variant not in categories[category]['variants']:
raise ValueError('Invalid variant {} for category {}'.format(
variant,
category
))
else:
variant = categories[category]['variants'][0]
if tenant and tenant not in tenants:
raise ValueError('Invalid tentant {}'.format(tenant))
# compose result URL
result = '{}/{}/{}/{}?size={}'.format(
EVE_IMAGE_SERVER_URL,
endpoint,
id,
variant,
size
)
if tenant:
result += '&tenant={}'.format(tenant)
return result
_DEFAULT_IMAGE_SIZE = 32
class EveAllianceInfo(models.Model):
alliance_id = models.CharField(max_length=254, unique=True)
alliance_id = models.PositiveIntegerField(unique=True)
alliance_name = models.CharField(max_length=254, unique=True)
alliance_ticker = models.CharField(max_length=254)
executor_corp_id = models.CharField(max_length=254)
executor_corp_id = models.PositiveIntegerField()
objects = EveAllianceManager()
provider = EveAllianceProviderManager()
class Meta:
indexes = [models.Index(fields=['executor_corp_id',])]
def populate_alliance(self):
alliance = self.provider.get_alliance(self.alliance_id)
for corp_id in alliance.corp_ids:
if not EveCorporationInfo.objects.filter(corporation_id=corp_id).exists():
EveCorporationInfo.objects.create_corporation(corp_id)
EveCorporationInfo.objects.filter(corporation_id__in=alliance.corp_ids).update(alliance=self)
EveCorporationInfo.objects.filter(alliance=self).exclude(corporation_id__in=alliance.corp_ids).update(
alliance=None)
EveCorporationInfo.objects.filter(
corporation_id__in=alliance.corp_ids).update(alliance=self
)
EveCorporationInfo.objects\
.filter(alliance=self)\
.exclude(corporation_id__in=alliance.corp_ids)\
.update(alliance=None)
def update_alliance(self, alliance: providers.Alliance = None):
if alliance is None:
@@ -120,11 +46,13 @@ class EveAllianceInfo(models.Model):
return self.alliance_name
@staticmethod
def generic_logo_url(alliance_id: int, size: int = 32) -> str:
def generic_logo_url(
alliance_id: int, size: int = _DEFAULT_IMAGE_SIZE
) -> str:
"""image URL for the given alliance ID"""
return _eve_entity_image_url('alliance', alliance_id, size)
return eveimageserver.alliance_logo_url(alliance_id, size)
def logo_url(self, size:int = 32) -> str:
def logo_url(self, size: int = _DEFAULT_IMAGE_SIZE) -> str:
"""image URL of this alliance"""
return self.generic_logo_url(self.alliance_id, size)
@@ -150,11 +78,13 @@ class EveAllianceInfo(models.Model):
class EveCorporationInfo(models.Model):
corporation_id = models.CharField(max_length=254, unique=True)
corporation_id = models.PositiveIntegerField(unique=True)
corporation_name = models.CharField(max_length=254, unique=True)
corporation_ticker = models.CharField(max_length=254)
member_count = models.IntegerField()
alliance = models.ForeignKey(EveAllianceInfo, blank=True, null=True, on_delete=models.SET_NULL)
alliance = models.ForeignKey(
EveAllianceInfo, blank=True, null=True, on_delete=models.SET_NULL
)
objects = EveCorporationManager()
provider = EveCorporationProviderManager()
@@ -174,11 +104,13 @@ class EveCorporationInfo(models.Model):
return self.corporation_name
@staticmethod
def generic_logo_url(corporation_id: int, size: int = 32) -> str:
def generic_logo_url(
corporation_id: int, size: int = _DEFAULT_IMAGE_SIZE
) -> str:
"""image URL for the given corporation ID"""
return _eve_entity_image_url('corporation', corporation_id, size)
return eveimageserver.corporation_logo_url(corporation_id, size)
def logo_url(self, size:int = 32) -> str:
def logo_url(self, size: int = _DEFAULT_IMAGE_SIZE) -> str:
"""image URL for this corporation"""
return self.generic_logo_url(self.corporation_id, size)
@@ -204,18 +136,26 @@ class EveCorporationInfo(models.Model):
class EveCharacter(models.Model):
character_id = models.CharField(max_length=254, unique=True)
character_id = models.PositiveIntegerField(unique=True)
character_name = models.CharField(max_length=254, unique=True)
corporation_id = models.CharField(max_length=254)
corporation_id = models.PositiveIntegerField()
corporation_name = models.CharField(max_length=254)
corporation_ticker = models.CharField(max_length=5)
alliance_id = models.CharField(max_length=254, blank=True, null=True, default='')
alliance_id = models.PositiveIntegerField(blank=True, null=True, default=None)
alliance_name = models.CharField(max_length=254, blank=True, null=True, default='')
alliance_ticker = models.CharField(max_length=5, blank=True, null=True, default='')
objects = EveCharacterManager()
provider = EveCharacterProviderManager()
class Meta:
indexes = [
models.Index(fields=['corporation_id',]),
models.Index(fields=['alliance_id',]),
models.Index(fields=['corporation_name',]),
models.Index(fields=['alliance_name',]),
]
@property
def alliance(self) -> Union[EveAllianceInfo, None]:
"""
@@ -253,11 +193,13 @@ class EveCharacter(models.Model):
return self.character_name
@staticmethod
def generic_portrait_url(character_id: int, size: int = 32) -> str:
def generic_portrait_url(
character_id: int, size: int = _DEFAULT_IMAGE_SIZE
) -> str:
"""image URL for the given character ID"""
return _eve_entity_image_url('character', character_id, size)
return eveimageserver.character_portrait_url(character_id, size)
def portrait_url(self, size = 32) -> str:
def portrait_url(self, size=_DEFAULT_IMAGE_SIZE) -> str:
"""image URL for this character"""
return self.generic_portrait_url(self.character_id, size)
@@ -281,7 +223,7 @@ class EveCharacter(models.Model):
"""image URL for this character"""
return self.portrait_url(256)
def corporation_logo_url(self, size = 32) -> str:
def corporation_logo_url(self, size=_DEFAULT_IMAGE_SIZE) -> str:
"""image URL for corporation of this character"""
return EveCorporationInfo.generic_logo_url(self.corporation_id, size)
@@ -305,7 +247,7 @@ class EveCharacter(models.Model):
"""image URL for corporation of this character"""
return self.corporation_logo_url(256)
def alliance_logo_url(self, size = 32) -> str:
def alliance_logo_url(self, size=_DEFAULT_IMAGE_SIZE) -> str:
"""image URL for alliance of this character or empty string"""
if self.alliance_id:
return EveAllianceInfo.generic_logo_url(self.alliance_id, size)

View File

@@ -5,35 +5,96 @@ from .models import EveAllianceInfo
from .models import EveCharacter
from .models import EveCorporationInfo
from . import providers
logger = logging.getLogger(__name__)
TASK_PRIORITY = 7
CHUNK_SIZE = 500
def chunks(lst, n):
"""Yield successive n-sized chunks from lst."""
for i in range(0, len(lst), n):
yield lst[i:i + n]
@shared_task
def update_corp(corp_id):
"""Update given corporation from ESI"""
EveCorporationInfo.objects.update_corporation(corp_id)
@shared_task
def update_alliance(alliance_id):
"""Update given alliance from ESI"""
EveAllianceInfo.objects.update_alliance(alliance_id).populate_alliance()
@shared_task
def update_character(character_id):
"""Update given character from ESI"""
EveCharacter.objects.update_character(character_id)
@shared_task
def run_model_update():
"""Update all alliances, corporations and characters from ESI"""
# update existing corp models
for corp in EveCorporationInfo.objects.all().values('corporation_id'):
update_corp.apply_async(args=[corp['corporation_id']], priority=TASK_PRIORITY)
update_corp.apply_async(
args=[corp['corporation_id']], priority=TASK_PRIORITY
)
# update existing alliance models
for alliance in EveAllianceInfo.objects.all().values('alliance_id'):
update_alliance.apply_async(args=[alliance['alliance_id']], priority=TASK_PRIORITY)
update_alliance.apply_async(
args=[alliance['alliance_id']], priority=TASK_PRIORITY
)
#update existing character models
for character in EveCharacter.objects.all().values('character_id'):
update_character.apply_async(args=[character['character_id']], priority=TASK_PRIORITY)
# update existing character models
character_ids = EveCharacter.objects.all().values_list('character_id', flat=True)
for character_ids_chunk in chunks(character_ids, CHUNK_SIZE):
affiliations_raw = providers.provider.client.Character\
.post_characters_affiliation(characters=character_ids_chunk).result()
character_names = providers.provider.client.Universe\
.post_universe_names(ids=character_ids_chunk).result()
affiliations = {
affiliation.get('character_id'): affiliation
for affiliation in affiliations_raw
}
# add character names to affiliations
for character in character_names:
character_id = character.get('id')
if character_id in affiliations:
affiliations[character_id]['name'] = character.get('name')
# fetch current characters
characters = EveCharacter.objects.filter(character_id__in=character_ids_chunk)\
.values('character_id', 'corporation_id', 'alliance_id', 'character_name')
for character in characters:
character_id = character.get('character_id')
if character_id in affiliations:
affiliation = affiliations[character_id]
corp_changed = (
character.get('corporation_id') != affiliation.get('corporation_id')
)
alliance_id = character.get('alliance_id')
if not alliance_id:
alliance_id = None
alliance_changed = alliance_id != affiliation.get('alliance_id')
name_changed = False
fetched_name = affiliation.get('name', False)
if fetched_name:
name_changed = character.get('character_name') != fetched_name
if corp_changed or alliance_changed or name_changed:
update_character.apply_async(
args=[character.get('character_id')], priority=TASK_PRIORITY
)

View File

@@ -15,7 +15,7 @@
from django import template
from ..models import EveCharacter, EveCorporationInfo, EveAllianceInfo
from ..evelinks import evewho, dotlan, zkillboard
from ..evelinks import eveimageserver, evewho, dotlan, zkillboard
register = template.Library()
@@ -163,7 +163,7 @@ def dotlan_solar_system_url(eve_obj: object) -> str:
return _generic_evelinks_url(dotlan, 'solar_system_url', eve_obj)
#zkillboard
# zkillboard
@register.filter
def zkillboard_character_url(eve_obj: EveCharacter) -> str:
@@ -212,7 +212,6 @@ def zkillboard_solar_system_url(eve_obj: object) -> str:
# image urls
@register.filter
def character_portrait_url(
eve_obj: object,
@@ -284,3 +283,30 @@ def alliance_logo_url(
except ValueError:
return ''
@register.filter
def type_icon_url(
type_id: int,
size: int = _DEFAULT_IMAGE_SIZE
) -> str:
"""generates a icon image URL for the given type ID
Returns URL or empty string
"""
try:
return eveimageserver.type_icon_url(type_id, size)
except ValueError:
return ''
@register.filter
def type_render_url(
type_id: int,
size: int = _DEFAULT_IMAGE_SIZE
) -> str:
"""generates a render image URL for the given type ID
Returns URL or empty string
"""
try:
return eveimageserver.type_render_url(type_id, size)
except ValueError:
return ''

View File

@@ -12,7 +12,7 @@ class EveCharacterProviderManagerTestCase(TestCase):
expected = Character()
provider.get_character.return_value = expected
result = EveCharacter.provider.get_character('1234')
result = EveCharacter.provider.get_character(1234)
self.assertEqual(expected, result)
@@ -22,30 +22,30 @@ class EveCharacterManagerTestCase(TestCase):
class TestCharacter(Character):
@property
def alliance(self):
return Alliance(id='3456', name='Test Alliance')
return Alliance(id=3456, name='Test Alliance')
@property
def corp(self):
return Corporation(
id='2345',
id=2345,
name='Test Corp',
alliance_id='3456',
ticker='0BUGS'
alliance_id=3456,
ticker='0BUGS' #lies, blatant lies!
)
@mock.patch('allianceauth.eveonline.managers.providers.provider')
def test_create_character(self, provider):
# Also covers create_character_obj
expected = self.TestCharacter(
id='1234',
id=1234,
name='Test Character',
corp_id='2345',
alliance_id='3456'
corp_id=2345,
alliance_id=3456
)
provider.get_character.return_value = expected
result = EveCharacter.objects.create_character('1234')
result = EveCharacter.objects.create_character(1234)
self.assertEqual(result.character_id, expected.id)
self.assertEqual(result.character_name, expected.name)
@@ -59,25 +59,24 @@ class EveCharacterManagerTestCase(TestCase):
def test_update_character(self, provider):
# Also covers Model.update_character
existing = EveCharacter.objects.create(
character_id='1234',
character_id=1234,
character_name='character.name',
corporation_id='character.corp.id',
corporation_id=23457,
corporation_name='character.corp.name',
corporation_ticker='character.corp.ticker',
alliance_id='character.alliance.id',
corporation_ticker='cc1',
alliance_id=34567,
alliance_name='character.alliance.name',
)
expected = self.TestCharacter(
id='1234',
id=1234,
name='Test Character',
corp_id='2345',
alliance_id='3456'
corp_id=2345,
alliance_id=3456
)
provider.get_character.return_value = expected
result = EveCharacter.objects.update_character('1234')
result = EveCharacter.objects.update_character(1234)
self.assertEqual(result.character_id, expected.id)
self.assertEqual(result.character_name, expected.name)
@@ -90,23 +89,23 @@ class EveCharacterManagerTestCase(TestCase):
def test_get_character_by_id(self):
EveCharacter.objects.all().delete()
EveCharacter.objects.create(
character_id='1234',
character_id=1234,
character_name='character.name',
corporation_id='character.corp.id',
corporation_id=2345,
corporation_name='character.corp.name',
corporation_ticker='character.corp.ticker',
alliance_id='character.alliance.id',
corporation_ticker='cc1',
alliance_id=3456,
alliance_name='character.alliance.name',
)
# try to get existing character
result = EveCharacter.objects.get_character_by_id('1234')
result = EveCharacter.objects.get_character_by_id(1234)
self.assertEqual(result.character_id, '1234')
self.assertEqual(result.character_id, 1234)
self.assertEqual(result.character_name, 'character.name')
# try to get non existing character
self.assertIsNone(EveCharacter.objects.get_character_by_id('9999'))
self.assertIsNone(EveCharacter.objects.get_character_by_id(9999))
class EveAllianceProviderManagerTestCase(TestCase):
@@ -115,7 +114,7 @@ class EveAllianceProviderManagerTestCase(TestCase):
expected = Alliance()
provider.get_alliance.return_value = expected
result = EveAllianceInfo.provider.get_alliance('1234')
result = EveAllianceInfo.provider.get_alliance(1234)
self.assertEqual(expected, result)
@@ -131,16 +130,16 @@ class EveAllianceManagerTestCase(TestCase):
def test_create_alliance(self, provider, populate_alliance):
# Also covers create_alliance_obj
expected = self.TestAlliance(
id='3456',
id=3456,
name='Test Alliance',
ticker='TEST',
corp_ids=['2345'],
executor_corp_id='2345'
corp_ids=[2345],
executor_corp_id=2345
)
provider.get_alliance.return_value = expected
result = EveAllianceInfo.objects.create_alliance('3456')
result = EveAllianceInfo.objects.create_alliance(3456)
self.assertEqual(result.alliance_id, expected.id)
self.assertEqual(result.alliance_name, expected.name)
@@ -152,22 +151,22 @@ class EveAllianceManagerTestCase(TestCase):
def test_update_alliance(self, provider):
# Also covers Model.update_alliance
EveAllianceInfo.objects.create(
alliance_id='3456',
alliance_id=3456,
alliance_name='alliance.name',
alliance_ticker='alliance.ticker',
executor_corp_id='alliance.executor_corp_id',
alliance_ticker='at1',
executor_corp_id=2345,
)
expected = self.TestAlliance(
id='3456',
id=3456,
name='Test Alliance',
ticker='TEST',
corp_ids=['2345'],
executor_corp_id='2345'
corp_ids=[2345],
executor_corp_id=2345
)
provider.get_alliance.return_value = expected
result = EveAllianceInfo.objects.update_alliance('3456')
result = EveAllianceInfo.objects.update_alliance(3456)
# This is the only thing ever updated in code
self.assertEqual(result.executor_corp_id, expected.executor_corp_id)
@@ -179,7 +178,7 @@ class EveCorporationProviderManagerTestCase(TestCase):
expected = Corporation()
provider.get_corp.return_value = expected
result = EveCorporationInfo.provider.get_corporation('2345')
result = EveCorporationInfo.provider.get_corporation(2345)
self.assertEqual(expected, result)
@@ -190,39 +189,39 @@ class EveCorporationManagerTestCase(TestCase):
@property
def alliance(self):
return EveAllianceManagerTestCase.TestAlliance(
id='3456',
id=3456,
name='Test Alliance',
ticker='TEST',
corp_ids=['2345'],
executor_corp_id='2345'
corp_ids=[2345],
executor_corp_id=2345
)
@property
def ceo(self):
return EveCharacterManagerTestCase.TestCharacter(
id='1234',
id=1234,
name='Test Character',
corp_id='2345',
alliance_id='3456'
corp_id=2345,
alliance_id=3456
)
@mock.patch('allianceauth.eveonline.managers.providers.provider')
def test_create_corporation(self, provider):
# Also covers create_corp_obj
exp_alliance = EveAllianceInfo.objects.create(
alliance_id='3456',
alliance_id=3456,
alliance_name='alliance.name',
alliance_ticker='alliance.ticker',
executor_corp_id='alliance.executor_corp_id',
alliance_ticker='99bug',
executor_corp_id=2345,
)
expected = self.TestCorporation(
id='2345',
id=2345,
name='Test Corp',
ticker='0BUGS',
ceo_id='1234',
ceo_id=1234,
members=1,
alliance_id='3456'
alliance_id=3456
)
provider.get_corp.return_value = expected
@@ -240,17 +239,17 @@ class EveCorporationManagerTestCase(TestCase):
# variant to test no alliance case
# Also covers create_corp_obj
expected = self.TestCorporation(
id='2345',
id=2345,
name='Test Corp',
ticker='0BUGS',
ceo_id='1234',
ceo_id=1234,
members=1,
alliance_id='3456'
alliance_id=3456
)
provider.get_corp.return_value = expected
result = EveCorporationInfo.objects.create_corporation('2345')
result = EveCorporationInfo.objects.create_corporation(2345)
self.assertEqual(result.corporation_id, expected.id)
self.assertEqual(result.corporation_name, expected.name)
@@ -262,27 +261,27 @@ class EveCorporationManagerTestCase(TestCase):
def test_update_corporation(self, provider):
# Also covers Model.update_corporation
exp_alliance = EveAllianceInfo.objects.create(
alliance_id='3456',
alliance_id=3456,
alliance_name='alliance.name',
alliance_ticker='alliance.ticker',
executor_corp_id='alliance.executor_corp_id',
alliance_ticker='at1',
executor_corp_id=2345,
)
EveCorporationInfo.objects.create(
corporation_id='2345',
corporation_id=2345,
corporation_name='corp.name',
corporation_ticker='corp.ticker',
corporation_ticker='cc1',
member_count=10,
alliance=None,
)
expected = self.TestCorporation(
id='2345',
id=2345,
name='Test Corp',
ticker='0BUGS',
ceo_id='1234',
ceo_id=1234,
members=1,
alliance_id='3456'
alliance_id=3456
)
provider.get_corp.return_value = expected

View File

@@ -2,130 +2,40 @@ from unittest.mock import Mock, patch
from django.test import TestCase
from ..models import EveCharacter, EveCorporationInfo, \
EveAllianceInfo, _eve_entity_image_url
from ..models import (
EveCharacter, EveCorporationInfo, EveAllianceInfo
)
from ..providers import Alliance, Corporation, Character
from ..evelinks import eveimageserver
class EveUniverseImageUrlTestCase(TestCase):
"""unit test for _eve_entity_image_url()"""
def test_sizes(self):
self.assertEqual(
_eve_entity_image_url('character', 42),
'https://images.evetech.net/characters/42/portrait?size=32'
)
self.assertEqual(
_eve_entity_image_url('character', 42, size=32),
'https://images.evetech.net/characters/42/portrait?size=32'
)
self.assertEqual(
_eve_entity_image_url('character', 42, size=64),
'https://images.evetech.net/characters/42/portrait?size=64'
)
self.assertEqual(
_eve_entity_image_url('character', 42, size=128),
'https://images.evetech.net/characters/42/portrait?size=128'
)
self.assertEqual(
_eve_entity_image_url('character', 42, size=256),
'https://images.evetech.net/characters/42/portrait?size=256'
)
self.assertEqual(
_eve_entity_image_url('character', 42, size=512),
'https://images.evetech.net/characters/42/portrait?size=512'
)
self.assertEqual(
_eve_entity_image_url('character', 42, size=1024),
'https://images.evetech.net/characters/42/portrait?size=1024'
)
with self.assertRaises(ValueError):
_eve_entity_image_url('corporation', 42, size=-5)
with self.assertRaises(ValueError):
_eve_entity_image_url('corporation', 42, size=0)
with self.assertRaises(ValueError):
_eve_entity_image_url('corporation', 42, size=31)
with self.assertRaises(ValueError):
_eve_entity_image_url('corporation', 42, size=1025)
with self.assertRaises(ValueError):
_eve_entity_image_url('corporation', 42, size=2048)
def test_variant(self):
self.assertEqual(
_eve_entity_image_url('character', 42, variant='portrait'),
'https://images.evetech.net/characters/42/portrait?size=32'
)
self.assertEqual(
_eve_entity_image_url('alliance', 42, variant='logo'),
'https://images.evetech.net/alliances/42/logo?size=32'
)
with self.assertRaises(ValueError):
_eve_entity_image_url('character', 42, variant='logo')
def test_alliance(self):
self.assertEqual(
_eve_entity_image_url('alliance', 42),
'https://images.evetech.net/alliances/42/logo?size=32'
)
self.assertEqual(
_eve_entity_image_url('corporation', 42),
'https://images.evetech.net/corporations/42/logo?size=32'
)
self.assertEqual(
_eve_entity_image_url('character', 42),
'https://images.evetech.net/characters/42/portrait?size=32'
)
with self.assertRaises(ValueError):
_eve_entity_image_url('station', 42)
def test_tenants(self):
self.assertEqual(
_eve_entity_image_url('character', 42, tenant='tranquility'),
'https://images.evetech.net/characters/42/portrait?size=32&tenant=tranquility'
)
self.assertEqual(
_eve_entity_image_url('character', 42, tenant='singularity'),
'https://images.evetech.net/characters/42/portrait?size=32&tenant=singularity'
)
with self.assertRaises(ValueError):
_eve_entity_image_url('character', 42, tenant='xxx')
class EveCharacterTestCase(TestCase):
def test_corporation_prop(self):
"""
Test that the correct corporation is returned by the corporation property
"""
character = EveCharacter.objects.create(
character_id='1234',
character_id=1234,
character_name='character.name',
corporation_id='2345',
corporation_id=2345,
corporation_name='character.corp.name',
corporation_ticker='character.corp.ticker',
alliance_id='character.alliance.id',
corporation_ticker='cc1',
alliance_id=12345,
alliance_name='character.alliance.name',
)
expected = EveCorporationInfo.objects.create(
corporation_id='2345',
corporation_id=2345,
corporation_name='corp.name',
corporation_ticker='corp.ticker',
corporation_ticker='cc1',
member_count=10,
alliance=None,
)
incorrect = EveCorporationInfo.objects.create(
corporation_id='9999',
corporation_id=9999,
corporation_name='corp.name1',
corporation_ticker='corp.ticker1',
corporation_ticker='cc11',
member_count=10,
alliance=None,
)
@@ -139,44 +49,44 @@ class EveCharacterTestCase(TestCase):
object is not in the database
"""
character = EveCharacter.objects.create(
character_id='1234',
character_id=1234,
character_name='character.name',
corporation_id='2345',
corporation_id=2345,
corporation_name='character.corp.name',
corporation_ticker='character.corp.ticker',
alliance_id='character.alliance.id',
corporation_ticker='cc1',
alliance_id=123456,
alliance_name='character.alliance.name',
)
with self.assertRaises(EveCorporationInfo.DoesNotExist):
result = character.corporation
character.corporation
def test_alliance_prop(self):
"""
Test that the correct alliance is returned by the alliance property
"""
character = EveCharacter.objects.create(
character_id='1234',
character_id=1234,
character_name='character.name',
corporation_id='2345',
corporation_id=2345,
corporation_name='character.corp.name',
corporation_ticker='character.corp.ticker',
alliance_id='3456',
corporation_ticker='cc1',
alliance_id=3456,
alliance_name='character.alliance.name',
)
expected = EveAllianceInfo.objects.create(
alliance_id='3456',
alliance_id=3456,
alliance_name='alliance.name',
alliance_ticker='alliance.ticker',
executor_corp_id='alliance.executor_corp_id',
alliance_ticker='ac2',
executor_corp_id=2345,
)
incorrect = EveAllianceInfo.objects.create(
alliance_id='9001',
alliance_id=9001,
alliance_name='alliance.name1',
alliance_ticker='alliance.ticker1',
executor_corp_id='alliance.executor_corp_id1',
alliance_ticker='ac1',
executor_corp_id=2654,
)
self.assertEqual(character.alliance, expected)
@@ -188,28 +98,28 @@ class EveCharacterTestCase(TestCase):
object is not in the database
"""
character = EveCharacter.objects.create(
character_id='1234',
character_id=1234,
character_name='character.name',
corporation_id='2345',
corporation_id=2345,
corporation_name='character.corp.name',
corporation_ticker='character.corp.ticker',
alliance_id='3456',
corporation_ticker='cc1',
alliance_id=3456,
alliance_name='character.alliance.name',
)
with self.assertRaises(EveAllianceInfo.DoesNotExist):
result = character.alliance
character.alliance
def test_alliance_prop_none(self):
"""
Check that None is returned when the character has no alliance
"""
character = EveCharacter.objects.create(
character_id='1234',
character_id=1234,
character_name='character.name',
corporation_id='2345',
corporation_id=2345,
corporation_name='character.corp.name',
corporation_ticker='character.corp.ticker',
corporation_ticker='cc1',
alliance_id=None,
alliance_name=None,
)
@@ -227,12 +137,12 @@ class EveCharacterTestCase(TestCase):
)
my_character = EveCharacter.objects.create(
character_id='1001',
character_id=1001,
character_name='Bruce Wayne',
corporation_id='2001',
corporation_id=2001,
corporation_name='Dummy Corp 1',
corporation_ticker='DC1',
alliance_id='3001',
alliance_id=3001,
alliance_name='Dummy Alliance 1',
)
my_updated_character = Character(
@@ -244,90 +154,87 @@ class EveCharacterTestCase(TestCase):
# todo: add test cases not yet covered, e.g. with alliance
def test_image_url(self):
self.assertEqual(
EveCharacter.generic_portrait_url(42),
_eve_entity_image_url('character', 42)
eveimageserver._eve_entity_image_url('character', 42)
)
self.assertEqual(
EveCharacter.generic_portrait_url(42, 256),
_eve_entity_image_url('character', 42, 256)
eveimageserver._eve_entity_image_url('character', 42, 256)
)
def test_portrait_urls(self):
x = EveCharacter(
character_id='42',
character_id=42,
character_name='character.name',
corporation_id='123',
corporation_id=123,
corporation_name='corporation.name',
corporation_ticker='ABC',
)
self.assertEqual(
x.portrait_url(),
_eve_entity_image_url('character', 42)
eveimageserver._eve_entity_image_url('character', 42)
)
self.assertEqual(
x.portrait_url(64),
_eve_entity_image_url('character', 42, size=64)
eveimageserver._eve_entity_image_url('character', 42, size=64)
)
self.assertEqual(
x.portrait_url_32,
_eve_entity_image_url('character', 42, size=32)
eveimageserver._eve_entity_image_url('character', 42, size=32)
)
self.assertEqual(
x.portrait_url_64,
_eve_entity_image_url('character', 42, size=64)
eveimageserver._eve_entity_image_url('character', 42, size=64)
)
self.assertEqual(
x.portrait_url_128,
_eve_entity_image_url('character', 42, size=128)
eveimageserver._eve_entity_image_url('character', 42, size=128)
)
self.assertEqual(
x.portrait_url_256,
_eve_entity_image_url('character', 42, size=256)
eveimageserver._eve_entity_image_url('character', 42, size=256)
)
def test_corporation_logo_urls(self):
x = EveCharacter(
character_id='42',
character_id=42,
character_name='character.name',
corporation_id='123',
corporation_id=123,
corporation_name='corporation.name',
corporation_ticker='ABC',
)
self.assertEqual(
x.corporation_logo_url(),
_eve_entity_image_url('corporation', 123)
eveimageserver._eve_entity_image_url('corporation', 123)
)
self.assertEqual(
x.corporation_logo_url(256),
_eve_entity_image_url('corporation', 123, size=256)
eveimageserver._eve_entity_image_url('corporation', 123, size=256)
)
self.assertEqual(
x.corporation_logo_url_32,
_eve_entity_image_url('corporation', 123, size=32)
eveimageserver._eve_entity_image_url('corporation', 123, size=32)
)
self.assertEqual(
x.corporation_logo_url_64,
_eve_entity_image_url('corporation', 123, size=64)
eveimageserver._eve_entity_image_url('corporation', 123, size=64)
)
self.assertEqual(
x.corporation_logo_url_128,
_eve_entity_image_url('corporation', 123, size=128)
eveimageserver._eve_entity_image_url('corporation', 123, size=128)
)
self.assertEqual(
x.corporation_logo_url_256,
_eve_entity_image_url('corporation', 123, size=256)
eveimageserver._eve_entity_image_url('corporation', 123, size=256)
)
def test_alliance_logo_urls(self):
x = EveCharacter(
character_id='42',
character_id=42,
character_name='character.name',
corporation_id='123',
corporation_id=123,
corporation_name='corporation.name',
corporation_ticker='ABC',
)
@@ -354,27 +261,27 @@ class EveCharacterTestCase(TestCase):
x.alliance_id = 987
self.assertEqual(
x.alliance_logo_url(),
_eve_entity_image_url('alliance', 987)
eveimageserver._eve_entity_image_url('alliance', 987)
)
self.assertEqual(
x.alliance_logo_url(128),
_eve_entity_image_url('alliance', 987, size=128)
eveimageserver._eve_entity_image_url('alliance', 987, size=128)
)
self.assertEqual(
x.alliance_logo_url_32,
_eve_entity_image_url('alliance', 987, size=32)
eveimageserver._eve_entity_image_url('alliance', 987, size=32)
)
self.assertEqual(
x.alliance_logo_url_64,
_eve_entity_image_url('alliance', 987, size=64)
eveimageserver._eve_entity_image_url('alliance', 987, size=64)
)
self.assertEqual(
x.alliance_logo_url_128,
_eve_entity_image_url('alliance', 987, size=128)
eveimageserver._eve_entity_image_url('alliance', 987, size=128)
)
self.assertEqual(
x.alliance_logo_url_256,
_eve_entity_image_url('alliance', 987, size=256)
eveimageserver._eve_entity_image_url('alliance', 987, size=256)
)
@@ -456,7 +363,6 @@ class EveAllianceTestCase(TestCase):
# potential bug
# update_alliance() is only updateting executor_corp_id when object is given
def test_update_alliance_wo_object(self):
mock_EveAllianceProviderManager = Mock()
mock_EveAllianceProviderManager.get_alliance.return_value = \
@@ -475,11 +381,11 @@ class EveAllianceTestCase(TestCase):
)
my_alliance.provider = mock_EveAllianceProviderManager
my_alliance.save()
updated_alliance = Alliance(
name='Dummy Alliance 2',
corp_ids=[2004],
executor_corp_id=2004
)
Alliance(
name='Dummy Alliance 2',
corp_ids=[2004],
executor_corp_id=2004
)
my_alliance.update_alliance()
my_alliance.refresh_from_db()
self.assertEqual(int(my_alliance.executor_corp_id), 2004)
@@ -487,23 +393,22 @@ class EveAllianceTestCase(TestCase):
# potential bug
# update_alliance() is only updateting executor_corp_id nothing else ???
def test_image_url(self):
self.assertEqual(
EveAllianceInfo.generic_logo_url(42),
_eve_entity_image_url('alliance', 42)
eveimageserver._eve_entity_image_url('alliance', 42)
)
self.assertEqual(
EveAllianceInfo.generic_logo_url(42, 256),
_eve_entity_image_url('alliance', 42, 256)
eveimageserver._eve_entity_image_url('alliance', 42, 256)
)
def test_logo_url(self):
x = EveAllianceInfo(
alliance_id='42',
alliance_id=42,
alliance_name='alliance.name',
alliance_ticker='ABC',
executor_corp_id='123'
executor_corp_id=123
)
self.assertEqual(
x.logo_url(),
@@ -563,9 +468,7 @@ class EveCorporationTestCase(TestCase):
def test_update_corporation_no_object_w_alliance(self):
mock_provider = Mock()
mock_provider.get_corporation.return_value = Corporation(
members=87
)
mock_provider.get_corporation.return_value = Corporation(members=87)
self.my_corp.provider = mock_provider
self.my_corp.update_corporation()
@@ -585,15 +488,14 @@ class EveCorporationTestCase(TestCase):
self.assertEqual(my_corp2.member_count, 8)
self.assertIsNone(my_corp2.alliance)
def test_image_url(self):
self.assertEqual(
EveCorporationInfo.generic_logo_url(42),
_eve_entity_image_url('corporation', 42)
eveimageserver._eve_entity_image_url('corporation', 42)
)
self.assertEqual(
EveCorporationInfo.generic_logo_url(42, 256),
_eve_entity_image_url('corporation', 42, 256)
eveimageserver._eve_entity_image_url('corporation', 42, 256)
)
def test_logo_url(self):
@@ -621,4 +523,3 @@ class EveCorporationTestCase(TestCase):
self.my_corp.logo_url_256,
'https://images.evetech.net/corporations/2001/logo?size=256'
)

View File

@@ -1,21 +1,28 @@
import os
from unittest.mock import Mock, patch
from bravado.exception import HTTPNotFound, HTTPUnprocessableEntity
from bravado.exception import HTTPNotFound
from jsonschema.exceptions import RefResolutionError
from django.test import TestCase
from . import set_logger
from ..models import EveCharacter, EveCorporationInfo, EveAllianceInfo
from ..providers import ObjectNotFound, Entity, Character, Corporation, \
Alliance, ItemType, EveProvider, EveSwaggerProvider
from ..providers import (
ObjectNotFound,
Entity,
Character,
Corporation,
Alliance,
ItemType,
EveProvider,
EveSwaggerProvider
)
MODULE_PATH = 'allianceauth.eveonline.providers'
SWAGGER_OLD_SPEC_PATH = os.path.join(os.path.dirname(
os.path.abspath(__file__)), 'swagger_old.json'
)
os.path.abspath(__file__)), 'swagger_old.json'
)
set_logger(MODULE_PATH, __file__)
@@ -51,7 +58,6 @@ class TestEntity(TestCase):
x = Entity()
self.assertEqual(repr(x), '<Entity (None): None>')
def test_bool(self):
x = Entity(1001)
self.assertTrue(bool(x))
@@ -99,7 +105,6 @@ class TestCorporation(TestCase):
# should fetch alliance once only
self.assertEqual(mock_provider_get_alliance.call_count, 1)
@patch(MODULE_PATH + '.EveSwaggerProvider.get_alliance')
def test_alliance_not_defined(self, mock_provider_get_alliance):
mock_provider_get_alliance.return_value = None
@@ -110,7 +115,6 @@ class TestCorporation(TestCase):
Entity(None, None)
)
@patch(MODULE_PATH + '.EveSwaggerProvider.get_character')
def test_ceo(self, mock_provider_get_character):
my_ceo = Character(
@@ -200,7 +204,6 @@ class TestAlliance(TestCase):
# should be called once by used corp only
self.assertEqual(mock_provider_get_corp.call_count, 2)
@patch(MODULE_PATH + '.EveSwaggerProvider.get_corp')
def test_corps(self, mock_provider_get_corp):
mock_provider_get_corp.side_effect = TestAlliance._get_corp
@@ -253,7 +256,6 @@ class TestCharacter(TestCase):
# should call the provider one time only
self.assertEqual(mock_provider_get_corp.call_count, 1)
@patch(MODULE_PATH + '.EveSwaggerProvider.get_alliance')
@patch(MODULE_PATH + '.EveSwaggerProvider.get_corp')
@@ -283,7 +285,6 @@ class TestCharacter(TestCase):
self.assertEqual(mock_provider_get_corp.call_count, 1)
self.assertEqual(mock_provider_get_alliance.call_count, 1)
def test_alliance_has_none(self):
self.my_character.alliance_id = None
self.assertEqual(self.my_character.alliance, Entity(None, None))
@@ -343,7 +344,6 @@ class TestEveSwaggerProvider(TestCase):
else:
raise HTTPNotFound(Mock())
@staticmethod
def esi_get_alliances_alliance_id_corporations(alliance_id):
alliances = {
@@ -357,7 +357,6 @@ class TestEveSwaggerProvider(TestCase):
else:
raise HTTPNotFound(Mock())
@staticmethod
def esi_get_corporations_corporation_id(corporation_id):
corporations = {
@@ -382,7 +381,6 @@ class TestEveSwaggerProvider(TestCase):
else:
raise HTTPNotFound(Mock())
@staticmethod
def esi_get_characters_character_id(character_id):
characters = {
@@ -403,7 +401,6 @@ class TestEveSwaggerProvider(TestCase):
else:
raise HTTPNotFound(Mock())
@staticmethod
def esi_post_characters_affiliation(characters):
character_data = {
@@ -428,7 +425,6 @@ class TestEveSwaggerProvider(TestCase):
else:
raise TypeError()
@staticmethod
def esi_get_universe_types_type_id(type_id):
types = {
@@ -446,13 +442,11 @@ class TestEveSwaggerProvider(TestCase):
else:
raise HTTPNotFound(Mock())
@patch(MODULE_PATH + '.esi_client_factory')
def test_str(self, mock_esi_client_factory):
my_provider = EveSwaggerProvider()
self.assertEqual(str(my_provider), 'esi')
@patch(MODULE_PATH + '.esi_client_factory')
def test_get_alliance(self, mock_esi_client_factory):
mock_esi_client_factory.return_value\
@@ -481,7 +475,6 @@ class TestEveSwaggerProvider(TestCase):
with self.assertRaises(ObjectNotFound):
my_provider.get_alliance(3999)
@patch(MODULE_PATH + '.esi_client_factory')
def test_get_corp(self, mock_esi_client_factory):
mock_esi_client_factory.return_value\
@@ -508,7 +501,6 @@ class TestEveSwaggerProvider(TestCase):
with self.assertRaises(ObjectNotFound):
my_provider.get_corp(2999)
@patch(MODULE_PATH + '.esi_client_factory')
def test_get_character(self, mock_esi_client_factory):
mock_esi_client_factory.return_value\
@@ -536,7 +528,6 @@ class TestEveSwaggerProvider(TestCase):
with self.assertRaises(ObjectNotFound):
my_provider.get_character(1999)
@patch(MODULE_PATH + '.esi_client_factory')
def test_get_itemtype(self, mock_esi_client_factory):
mock_esi_client_factory.return_value\
@@ -601,5 +592,3 @@ class TestEveSwaggerProvider(TestCase):
self.assertTrue(mock_esi_client_factory.called)
self.assertIsNotNone(my_provider._client)
self.assertEqual(my_client, 'my_client')

View File

@@ -3,8 +3,12 @@ from unittest.mock import patch, Mock
from django.test import TestCase
from ..models import EveCharacter, EveCorporationInfo, EveAllianceInfo
from ..tasks import update_alliance, update_corp, update_character, \
from ..tasks import (
update_alliance,
update_corp,
update_character,
run_model_update
)
class TestTasks(TestCase):
@@ -13,98 +17,229 @@ class TestTasks(TestCase):
def test_update_corp(self, mock_EveCorporationInfo):
update_corp(42)
self.assertEqual(
mock_EveCorporationInfo.objects.update_corporation.call_count,
1
mock_EveCorporationInfo.objects.update_corporation.call_count, 1
)
self.assertEqual(
mock_EveCorporationInfo.objects.update_corporation.call_args[0][0],
42
mock_EveCorporationInfo.objects.update_corporation.call_args[0][0], 42
)
@patch('allianceauth.eveonline.tasks.EveAllianceInfo')
def test_update_alliance(self, mock_EveAllianceInfo):
update_alliance(42)
self.assertEqual(
mock_EveAllianceInfo.objects.update_alliance.call_args[0][0],
42
mock_EveAllianceInfo.objects.update_alliance.call_args[0][0], 42
)
self.assertEqual(
mock_EveAllianceInfo.objects\
.update_alliance.return_value.populate_alliance.call_count,
1
mock_EveAllianceInfo.objects
.update_alliance.return_value.populate_alliance.call_count, 1
)
@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
mock_EveCharacter.objects.update_character.call_count, 1
)
self.assertEqual(
mock_EveCharacter.objects.update_character.call_args[0][0],
42
mock_EveCharacter.objects.update_character.call_args[0][0], 42
)
@patch('allianceauth.eveonline.tasks.update_character')
@patch('allianceauth.eveonline.tasks.update_alliance')
@patch('allianceauth.eveonline.tasks.update_corp')
def test_run_model_update(
self,
mock_update_corp,
mock_update_alliance,
mock_update_character,
):
@patch('allianceauth.eveonline.tasks.update_character')
@patch('allianceauth.eveonline.tasks.update_alliance')
@patch('allianceauth.eveonline.tasks.update_corp')
@patch('allianceauth.eveonline.providers.provider')
@patch('allianceauth.eveonline.tasks.CHUNK_SIZE', 2)
class TestRunModelUpdate(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
EveCorporationInfo.objects.all().delete()
EveAllianceInfo.objects.all().delete()
EveCharacter.objects.all().delete()
EveCorporationInfo.objects.create(
corporation_id='2345',
corporation_id=2345,
corporation_name='corp.name',
corporation_ticker='corp.ticker',
corporation_ticker='c.c.t',
member_count=10,
alliance=None,
)
EveAllianceInfo.objects.create(
alliance_id='3456',
alliance_id=3456,
alliance_name='alliance.name',
alliance_ticker='alliance.ticker',
executor_corp_id='alliance.executor_corp_id',
)
alliance_ticker='a.t',
executor_corp_id=5,
)
EveCharacter.objects.create(
character_id='1234',
character_name='character.name',
corporation_id='character.corp.id',
character_id=1,
character_name='character.name1',
corporation_id=2345,
corporation_name='character.corp.name',
corporation_ticker='c.c.t', # max 5 chars
alliance_id='character.alliance.id',
alliance_id=None
)
EveCharacter.objects.create(
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()
self.assertEqual(
mock_provider.client.Character.post_characters_affiliation.call_count, 2
)
self.assertEqual(
mock_provider.client.Universe.post_universe_names.call_count, 2
)
# character 1 has changed corp
# character 2 no change
# character 3 has changed alliance
# character 4 has changed name
self.assertEqual(mock_update_corp.apply_async.call_count, 1)
self.assertEqual(
int(mock_update_corp.apply_async.call_args[1]['args'][0]),
2345
int(mock_update_corp.apply_async.call_args[1]['args'][0]), 2345
)
self.assertEqual(mock_update_alliance.apply_async.call_count, 1)
self.assertEqual(
int(mock_update_alliance.apply_async.call_args[1]['args'][0]),
3456
)
int(mock_update_alliance.apply_async.call_args[1]['args'][0]), 3456
)
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(
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
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
self.assertEqual(mock_update_character.apply_async.call_count, 1)
self.assertEqual(
int(mock_update_character.apply_async.call_args[1]['args'][0]),
1234
)
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):
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
del self.names[3]
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 = {1, 3}
self.assertSetEqual(characters_updated, excepted)

View File

@@ -6,7 +6,7 @@ from allianceauth.services.hooks import MenuItemHook, UrlHook
@hooks.register('menu_item_hook')
def register_menu():
return MenuItemHook(_('Fleet Activity Tracking'), 'fa fa-users fa-lightbulb-o fa-fw', 'fatlink:view',
return MenuItemHook(_('Fleet Activity Tracking'), 'fas fa-users fa-fw', 'fatlink:view',
navactive=['fatlink:'])

View File

@@ -0,0 +1,37 @@
from django.utils.translation import ugettext_lazy as _
from allianceauth.services.hooks import MenuItemHook, UrlHook
from allianceauth import hooks
from . import urls
from .managers import GroupManager
class GroupManagementMenuItem(MenuItemHook):
""" This class ensures only authorized users will see the menu entry """
def __init__(self):
# setup menu entry for sidebar
MenuItemHook.__init__(
self,
text=_('Group Management'),
classes='fas fa-users-cog fa-fw',
url_name='groupmanagement:management',
order=50,
navactive=['groupmanagement:management']
)
def render(self, request):
if GroupManager.can_manage_groups(request.user):
self.count = GroupManager.pending_requests_count_for_user(request.user)
return MenuItemHook.render(self, request)
return ''
@hooks.register('menu_item_hook')
def register_menu():
return GroupManagementMenuItem()
@hooks.register('url_hook')
def register_urls():
return UrlHook(urls, 'group', r'^group/')

View File

@@ -1,5 +0,0 @@
from allianceauth.groupmanagement.managers import GroupManager
def can_manage_groups(request):
return {'can_manage_groups': GroupManager.can_manage_groups(request.user)}

View File

@@ -4,6 +4,7 @@ from django.contrib.auth.models import Group, User
from django.db.models import Q, QuerySet
from allianceauth.authentication.models import State
from .models import GroupRequest
logger = logging.getLogger(__name__)
@@ -101,3 +102,18 @@ class GroupManager:
if user.is_authenticated:
return cls.has_management_permission(user) or cls.get_group_leaders_groups(user).filter(pk=group.pk).exists()
return False
@classmethod
def pending_requests_count_for_user(cls, user: User) -> int:
"""Returns the number of pending group requests for the given user"""
if cls.has_management_permission(user):
return GroupRequest.objects.filter(status="pending").count()
else:
return (
GroupRequest.objects
.filter(status="pending")
.filter(group__authgroup__group_leaders__exact=user)
.select_related("group__authgroup__group_leaders")
.count()
)

View File

@@ -4,7 +4,6 @@ from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
from allianceauth.authentication.models import State
from datetime import datetime
class GroupRequest(models.Model):

View File

@@ -33,7 +33,7 @@
<tbody>
{% for entry in entries %}
<tr>
<td class="text-center">{{ entry.date }}</td>
<td class="text-center">{{ entry.date|date:"Y-M-d, H:i" }}</td>
<td class="text-center">{{ entry.requestor }}</td>
<td class="text-center">{{ entry.req_char }}</td>
<td class="text-center">{{ entry.req_char.corporation_name }}</td>
@@ -66,7 +66,8 @@
{% block extra_javascript %}
{% include 'bundles/datatables-js.html' %}
<script type="text/javascript" src="{% static 'js/filterDropDown/filterDropDown.min.js' %}"></script>
{% include 'bundles/moment-js.html' with locale=True %}
<script type="application/javascript" src="{% static 'js/filterDropDown/filterDropDown.min.js' %}"></script>
{% endblock %}
{% block extra_css %}
@@ -74,7 +75,26 @@
{% endblock %}
{% block extra_script %}
$.fn.dataTable.moment = function ( format, locale ) {
var types = $.fn.dataTable.ext.type;
// Add type detection
types.detect.unshift( function ( d ) {
return moment( d, format, locale, true ).isValid() ?
'moment-'+format :
null;
} );
// Add sorting method - use an integer for the sorting
types.order[ 'moment-'+format+'-pre' ] = function ( d ) {
return moment( d, format, locale, true ).unix();
};
};
$(document).ready(function(){
$.fn.dataTable.moment( 'YYYY-MMM-D, HH:mm' );
$('#log-entries').DataTable({
order: [[ 0, 'desc' ], [ 1, 'asc' ] ],
filterDropDown:

View File

@@ -36,7 +36,7 @@
<tr>
<td class="text-right">
{% if member.is_leader %}
<i class="fa fa-star"></i>&nbsp;
<i class="fas fa-star"></i>&nbsp;
{% endif %}
<img src="{{ member.main_char|character_portrait_url:32 }}" class="img-circle">
</td>
@@ -69,7 +69,7 @@
{% endfor %}
</tbody>
</table>
<p class="text-muted"><i class="fa fa-star"></i>: Group leader</p>
<p class="text-muted"><i class="fas fa-star"></i>: Group leader</p>
</div>
{% else %}
<div class="alert alert-warning text-center">

View File

@@ -73,7 +73,7 @@
</div>
{% endblock content %}
{% block extra_javascript %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/2.0.4/clipboard.min.js"></script>
{% include 'bundles/clipboard-js.html' %}
<script>
new ClipboardJS('#clipboard-copy');
</script>

View File

@@ -20,8 +20,22 @@
{% include 'groupmanagement/menu.html' %}
<ul class="nav nav-tabs">
<li class="active"><a data-toggle="tab" href="#add">{% trans "Join Requests" %}</a></li>
<li><a data-toggle="tab" href="#leave">{% trans "Leave Requests" %}</a></li>
<li class="active">
<a data-toggle="tab" href="#add">
{% trans "Join Requests" %}
{% if acceptrequests %}
<span class="badge">{{ acceptrequests|length }}</span>
{% endif %}
</a>
</li>
<li>
<a data-toggle="tab" href="#leave">
{% trans "Leave Requests" %}
{% if leaverequests %}
<span class="badge">{{ leaverequests|length }}</span>
{% endif %}
</a>
</li>
</ul>
<div class="tab-content">

View File

@@ -11,11 +11,7 @@ from allianceauth.eveonline.models import (
EveCharacter, EveCorporationInfo, EveAllianceInfo
)
from ..admin import (
HasLeaderFilter,
GroupAdmin,
Group
)
from ..admin import HasLeaderFilter, GroupAdmin, Group
from . import get_admin_change_view_url
if 'allianceauth.eveonline.autogroups' in settings.INSTALLED_APPS:
@@ -88,33 +84,33 @@ class TestGroupAdmin(TestCase):
# user 1 - corp and alliance, normal user
cls.character_1 = EveCharacter.objects.create(
character_id='1001',
character_id=1001,
character_name='Bruce Wayne',
corporation_id='2001',
corporation_id=2001,
corporation_name='Wayne Technologies',
corporation_ticker='WT',
alliance_id='3001',
alliance_id=3001,
alliance_name='Wayne Enterprises',
alliance_ticker='WE',
)
cls.character_1a = EveCharacter.objects.create(
character_id='1002',
character_id=1002,
character_name='Batman',
corporation_id='2001',
corporation_id=2001,
corporation_name='Wayne Technologies',
corporation_ticker='WT',
alliance_id='3001',
alliance_id=3001,
alliance_name='Wayne Enterprises',
alliance_ticker='WE',
)
alliance = EveAllianceInfo.objects.create(
alliance_id='3001',
alliance_id=3001,
alliance_name='Wayne Enterprises',
alliance_ticker='WE',
executor_corp_id='2001'
executor_corp_id=2001
)
EveCorporationInfo.objects.create(
corporation_id='2001',
corporation_id=2001,
corporation_name='Wayne Technologies',
corporation_ticker='WT',
member_count=42,
@@ -189,10 +185,10 @@ class TestGroupAdmin(TestCase):
alliance=None
)
EveAllianceInfo.objects.create(
alliance_id='3101',
alliance_id=3101,
alliance_name='Lex World Domination',
alliance_ticker='LWD',
executor_corp_id=''
executor_corp_id=2101
)
cls.user_3 = User.objects.create_user(
cls.character_3.character_name.replace(' ', '_'),
@@ -219,8 +215,8 @@ class TestGroupAdmin(TestCase):
"""create autogroups for corps and alliances"""
if _has_auto_groups:
autogroups_config = AutogroupsConfig(
corp_groups = True,
alliance_groups = True
corp_groups=True,
alliance_groups=True
)
autogroups_config.save()
for state in State.objects.all():
@@ -277,7 +273,7 @@ class TestGroupAdmin(TestCase):
if _has_auto_groups:
@patch(MODULE_PATH + '._has_auto_groups', True)
def test_properties_6(self):
def test_properties_7(self):
self._create_autogroups()
expected = ['Auto Group']
my_group = Group.objects\
@@ -337,8 +333,8 @@ class TestGroupAdmin(TestCase):
changelist = my_modeladmin.get_changelist_instance(request)
queryset = changelist.get_queryset(request)
expected = Group.objects.exclude(
managedalliancegroup__isnull=True,
managedcorpgroup__isnull=True
managedalliancegroup__isnull=True,
managedcorpgroup__isnull=True
)
self.assertSetEqual(set(queryset), set(expected))
@@ -394,4 +390,4 @@ class TestGroupAdmin(TestCase):
c = Client()
c.login(username='superuser', password='secret')
response = c.get(get_admin_change_view_url(self.group_1))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.status_code, 200)

View File

@@ -7,7 +7,7 @@ from django.urls import reverse
from allianceauth.eveonline.models import EveCorporationInfo, EveAllianceInfo
from allianceauth.tests.auth_utils import AuthUtils
from ..models import AuthGroup
from ..models import GroupRequest
from ..managers import GroupManager
@@ -15,6 +15,7 @@ class MockUserNotAuthenticated():
def __init__(self):
self.is_authenticated = False
class GroupManagementVisibilityTestCase(TestCase):
@classmethod
def setUpTestData(cls):
@@ -37,22 +38,20 @@ class GroupManagementVisibilityTestCase(TestCase):
def _refresh_user(self):
self.user = User.objects.get(pk=self.user.pk)
def test_get_group_leaders_groups(self):
self.group1.authgroup.group_leaders.add(self.user)
self.group2.authgroup.group_leader_groups.add(self.group1)
self._refresh_user()
groups = GroupManager.get_group_leaders_groups(self.user)
self.assertIn(self.group1, groups) #avail due to user
self.assertNotIn(self.group2, groups) #not avail due to group
self.assertNotIn(self.group3, groups) #not avail at all
self.assertIn(self.group1, groups) #avail due to user
self.assertNotIn(self.group2, groups) #not avail due to group
self.assertNotIn(self.group3, groups) #not avail at all
self.user.groups.add(self.group1)
self._refresh_user()
groups = GroupManager.get_group_leaders_groups(self.user)
def test_can_manage_group(self):
self.group1.authgroup.group_leaders.add(self.user)
self.user.groups.add(self.group1)
@@ -182,7 +181,6 @@ class TestGroupManager(TestCase):
]:
self.assertFalse(GroupManager.joinable_group(x, member_state))
def test_joinable_group_guest(self):
guest_state = AuthUtils.get_guest_state()
for x in [
@@ -200,7 +198,6 @@ class TestGroupManager(TestCase):
]:
self.assertFalse(GroupManager.joinable_group(x, guest_state))
def test_get_all_non_internal_groups(self):
result = GroupManager.get_all_non_internal_groups()
expected = {
@@ -224,7 +221,7 @@ class TestGroupManager(TestCase):
def test_get_joinable_groups_for_user_no_permission(self):
AuthUtils.assign_state(self.user, AuthUtils.get_guest_state())
result = GroupManager.get_joinable_groups_for_user(self.user)
expected= {self.group_public_1, self.group_public_2}
expected = {self.group_public_1, self.group_public_2}
self.assertSetEqual(set(result), expected)
def test_get_joinable_groups_for_user_guest_w_permission_(self):
@@ -335,3 +332,96 @@ class TestGroupManager(TestCase):
self.assertFalse(
GroupManager.can_manage_group(user, self.group_default)
)
class TestPendingRequestsCountForUser(TestCase):
def setUp(self) -> None:
self.group_1 = Group.objects.create(name="Group 1")
self.group_2 = Group.objects.create(name="Group 2")
self.user_leader_1 = AuthUtils.create_member('Clark Kent')
self.group_1.authgroup.group_leaders.add(self.user_leader_1)
self.user_leader_2 = AuthUtils.create_member('Peter Parker')
self.group_2.authgroup.group_leaders.add(self.user_leader_2)
self.user_requestor = AuthUtils.create_member('Bruce Wayne')
def test_single_request_for_leader(self):
# given user_leader_1 is leader of group_1
# and user_leader_2 is leader of group_2
# when user_requestor is requesting access to group 1
# then return 1 for user_leader 1 and 0 for user_leader_2
GroupRequest.objects.create(
status="pending", user=self.user_requestor, group=self.group_1
)
self.assertEqual(
GroupManager.pending_requests_count_for_user(self.user_leader_1), 1
)
self.assertEqual(
GroupManager.pending_requests_count_for_user(self.user_leader_2), 0
)
def test_return_none_for_none_leader(self):
# given user_requestor is leader of no group
# when user_requestor is requesting access to group 1
# then return 0 for user_requestor
GroupRequest.objects.create(
status="pending", user=self.user_requestor, group=self.group_1
)
self.assertEqual(
GroupManager.pending_requests_count_for_user(self.user_requestor), 0
)
def test_single_leave_request(self):
# given user_leader_2 is leader of group_2
# and user_requestor is member of group 2
# when user_requestor is requesting to leave group 2
# then return 1 for user_leader_2
self.user_requestor.groups.add(self.group_2)
GroupRequest.objects.create(
status="pending",
user=self.user_requestor,
group=self.group_2,
leave_request=True
)
self.assertEqual(
GroupManager.pending_requests_count_for_user(self.user_leader_2), 1
)
def test_join_and_leave_request(self):
# given user_leader_2 is leader of group_2
# and user_requestor is member of group 2
# when user_requestor is requesting to leave group 2
# and user_requestor_2 is requesting to join group 2
# then return 2 for user_leader_2
self.user_requestor.groups.add(self.group_2)
user_requestor_2 = AuthUtils.create_member("Lex Luther")
GroupRequest.objects.create(
status="pending",
user=user_requestor_2,
group=self.group_2
)
GroupRequest.objects.create(
status="pending",
user=self.user_requestor,
group=self.group_2,
leave_request=True
)
self.assertEqual(
GroupManager.pending_requests_count_for_user(self.user_leader_2), 2
)
def test_single_request_for_user_with_management_perm(self):
# given user_leader_4 which is leafer of no group
# but has the management permissions
# when user_requestor is requesting access to group 1
# then return 1 for user_leader_4
user_leader_4 = AuthUtils.create_member("Lex Luther")
AuthUtils.add_permission_to_user_by_name("auth.group_management", user_leader_4)
user_leader_4 = User.objects.get(pk=user_leader_4.pk)
GroupRequest.objects.create(
status="pending", user=self.user_requestor, group=self.group_1
)
self.assertEqual(
GroupManager.pending_requests_count_for_user(self.user_leader_1), 1
)

View File

@@ -1,32 +1,29 @@
from . import views
from django.conf.urls import include, url
from django.conf.urls import url
app_name = 'groupmanagement'
urlpatterns = [
url(r'^groups/', views.groups_view, name='groups'),
url(r'^group/', include([
url(r'^management/', views.group_management,
name='management'),
url(r'^membership/$', views.group_membership,
name='membership'),
url(r'^membership/(\w+)/$', views.group_membership_list,
name='membership_list'),
url(r'^membership/(\w+)/audit/$', views.group_membership_audit, name="audit_log"),
url(r'^membership/(\w+)/remove/(\w+)/$', views.group_membership_remove,
name='membership_remove'),
url(r'^request_add/(\w+)', views.group_request_add,
name='request_add'),
url(r'^request/accept/(\w+)', views.group_accept_request,
name='accept_request'),
url(r'^request/reject/(\w+)', views.group_reject_request,
name='reject_request'),
url(r'^request_leave/(\w+)', views.group_request_leave,
name='request_leave'),
url(r'leave_request/accept/(\w+)', views.group_leave_accept_request,
name='leave_accept_request'),
url(r'^leave_request/reject/(\w+)', views.group_leave_reject_request,
name='leave_reject_request'),
])),
url(r'^groups/', views.groups_view, name='groups'),
url(r'^management/', views.group_management,
name='management'),
url(r'^membership/$', views.group_membership,
name='membership'),
url(r'^membership/(\w+)/$', views.group_membership_list,
name='membership_list'),
url(r'^membership/(\w+)/audit/$', views.group_membership_audit, name="audit_log"),
url(r'^membership/(\w+)/remove/(\w+)/$', views.group_membership_remove,
name='membership_remove'),
url(r'^request_add/(\w+)', views.group_request_add,
name='request_add'),
url(r'^request/accept/(\w+)', views.group_accept_request,
name='accept_request'),
url(r'^request/reject/(\w+)', views.group_reject_request,
name='reject_request'),
url(r'^request_leave/(\w+)', views.group_request_leave,
name='request_leave'),
url(r'leave_request/accept/(\w+)', views.group_leave_accept_request,
name='leave_accept_request'),
url(r'^leave_request/reject/(\w+)', views.group_leave_reject_request,
name='leave_reject_request'),
]

View File

@@ -6,7 +6,6 @@ from django.contrib.auth.decorators import login_required
from django.contrib.auth.decorators import user_passes_test
from django.contrib.auth.models import Group
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.core.paginator import Paginator, EmptyPage
from django.db.models import Count
from django.http import Http404
from django.shortcuts import render, redirect, get_object_or_404
@@ -28,7 +27,7 @@ def group_management(request):
acceptrequests = []
leaverequests = []
base_group_query = GroupRequest.objects.select_related('user', 'group')
base_group_query = GroupRequest.objects.select_related('user', 'group', 'user__profile__main_character')
if GroupManager.has_management_permission(request.user):
# Full access
group_requests = base_group_query.all()
@@ -76,7 +75,6 @@ def group_membership_audit(request, group_id):
logger.debug("group_management_audit called by user %s" % request.user)
group = get_object_or_404(Group, id=group_id)
try:
# Check its a joinable group i.e. not corp or internal
# And the user has permission to manage it
if not GroupManager.check_internal_group(group) or not GroupManager.can_manage_group(request.user, group):
@@ -93,8 +91,6 @@ def group_membership_audit(request, group_id):
return render(request, 'groupmanagement/audit.html', context=render_items)
@login_required
@user_passes_test(GroupManager.can_manage_groups)
def group_membership_list(request, group_id):
@@ -124,7 +120,7 @@ def group_membership_list(request, group_id):
for member in \
group.user_set\
.all()\
.select_related('profile')\
.select_related('profile', 'profile__main_character')\
.order_by('profile__main_character__character_name'):
members.append({

View File

@@ -1,17 +1,25 @@
from allianceauth.services.hooks import MenuItemHook, UrlHook
from django.utils.translation import ugettext_lazy as _
from allianceauth import hooks
from allianceauth.hrapplications import urls
from allianceauth.services.hooks import MenuItemHook, UrlHook
from . import urls
from .models import Application
class ApplicationsMenu(MenuItemHook):
def __init__(self):
MenuItemHook.__init__(self,
_('Applications'),
'fa fa-file-o fa-fw',
'far fa-file fa-fw',
'hrapplications:index',
navactive=['hrapplications:'])
def render(self, request):
app_count = Application.objects.pending_requests_count_for_user(request.user)
self.count = app_count if app_count and app_count > 0 else None
return MenuItemHook.render(self, request)
@hooks.register('menu_item_hook')
def register_menu():

View File

@@ -0,0 +1,25 @@
from django.contrib.auth.models import User
from django.db import models
from typing import Optional
class ApplicationManager(models.Manager):
def pending_requests_count_for_user(self, user: User) -> Optional[int]:
"""Returns the number of pending group requests for the given user"""
if user.is_superuser:
return self.filter(approved__isnull=True).count()
elif user.has_perm("auth.human_resources"):
main_character = user.profile.main_character
if main_character:
return (
self
.select_related("form__corp")
.filter(form__corp__corporation_id=main_character.corporation_id)
.filter(approved__isnull=True)
.count()
)
else:
return None
else:
return None

View File

@@ -2,8 +2,9 @@ from django.contrib.auth.models import User
from django.db import models
from sortedm2m.fields import SortedManyToManyField
from allianceauth.eveonline.models import EveCharacter
from allianceauth.eveonline.models import EveCorporationInfo
from allianceauth.eveonline.models import EveCharacter, EveCorporationInfo
from .managers import ApplicationManager
class ApplicationQuestion(models.Model):
@@ -22,6 +23,7 @@ class ApplicationChoice(models.Model):
def __str__(self):
return self.choice_text
class ApplicationForm(models.Model):
questions = SortedManyToManyField(ApplicationQuestion)
corp = models.OneToOneField(EveCorporationInfo, on_delete=models.CASCADE)
@@ -38,6 +40,8 @@ class Application(models.Model):
reviewer_character = models.ForeignKey(EveCharacter, on_delete=models.SET_NULL, blank=True, null=True)
created = models.DateTimeField(auto_now_add=True)
objects = ApplicationManager()
def __str__(self):
return str(self.user) + " Application To " + str(self.form)

View File

@@ -1 +1,103 @@
# Create your tests here.
from django.contrib.auth.models import User
from django.test import TestCase
from allianceauth.eveonline.models import EveCorporationInfo
from allianceauth.tests.auth_utils import AuthUtils
from .models import Application, ApplicationForm, ApplicationQuestion, ApplicationChoice
class TestApplicationManagersPendingRequestsCountForUser(TestCase):
def setUp(self) -> None:
self.corporation_1 = EveCorporationInfo.objects.create(
corporation_id=2001, corporation_name="Wayne Tech", member_count=42
)
self.corporation_2 = EveCorporationInfo.objects.create(
corporation_id=2011, corporation_name="Lex Corp", member_count=666
)
question = ApplicationQuestion.objects.create(title="Dummy Question")
ApplicationChoice.objects.create(question=question, choice_text="yes")
ApplicationChoice.objects.create(question=question, choice_text="no")
self.form_corporation_1 = ApplicationForm.objects.create(
corp=self.corporation_1
)
self.form_corporation_1.questions.add(question)
self.form_corporation_2 = ApplicationForm.objects.create(
corp=self.corporation_2
)
self.form_corporation_2.questions.add(question)
self.user_requestor = AuthUtils.create_member("Peter Parker")
self.user_manager = AuthUtils.create_member("Bruce Wayne")
AuthUtils.add_main_character_2(
self.user_manager,
self.user_manager.username,
1001,
self.corporation_1.corporation_id,
self.corporation_1.corporation_name,
)
AuthUtils.add_permission_to_user_by_name(
"auth.human_resources", self.user_manager
)
self.user_manager = User.objects.get(pk=self.user_manager.pk)
def test_no_pending_application(self):
# given manager of corporation 1 has permission
# when no application is pending for corporation 1
# return 0
self.assertEqual(
Application.objects.pending_requests_count_for_user(self.user_manager), 0
)
def test_single_pending_application(self):
# given manager of corporation 1 has permission
# when 1 application is pending for corporation 1
# return 1
Application.objects.create(
form=self.form_corporation_1, user=self.user_requestor
)
self.assertEqual(
Application.objects.pending_requests_count_for_user(self.user_manager), 1
)
def test_user_has_no_permission(self):
# given user has no permission
# when 1 application is pending
# return None
self.assertIsNone(
Application.objects.pending_requests_count_for_user(self.user_requestor)
)
def test_two_pending_applications_for_different_corporations_normal_manager(self):
# given manager of corporation 1 has permission
# when 1 application is pending for corporation 1
# and 1 application is pending for corporation 2
# return 1
Application.objects.create(
form=self.form_corporation_1, user=self.user_requestor
)
Application.objects.create(
form=self.form_corporation_2, user=self.user_requestor
)
self.assertEqual(
Application.objects.pending_requests_count_for_user(self.user_manager), 1
)
def test_two_pending_applications_for_different_corporations_manager_is_super(self):
# given manager of corporation 1 has permission
# when 1 application is pending for corporation 1
# and 1 application is pending for corporation 2
# return 1
Application.objects.create(
form=self.form_corporation_1, user=self.user_requestor
)
Application.objects.create(
form=self.form_corporation_2, user=self.user_requestor
)
superuser = User.objects.create_superuser(
"Superman", "superman@example.com", "password"
)
self.assertEqual(
Application.objects.pending_requests_count_for_user(superuser), 2
)

View File

@@ -13,7 +13,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-05-08 00:57+0000\n"
"POT-Creation-Date: 2020-07-29 04:56+0000\n"
"PO-Revision-Date: 2020-02-18 03:14+0000\n"
"Last-Translator: Rounon Dax <rounon.dax@terra-nanotech.de>, 2020\n"
"Language-Team: German (https://www.transifex.com/alliance-auth/teams/107430/de/)\n"
@@ -32,55 +32,55 @@ msgstr ""
msgid "Email"
msgstr "E-Mail"
#: allianceauth/authentication/models.py:76
msgid "State Changed"
msgstr "Status geändert"
#: allianceauth/authentication/models.py:77
#: allianceauth/authentication/models.py:78
#, python-format
msgid "Your user state has been changed to %(state)s"
msgstr "Dein Nutzerstatus hat sich geändert zu %(state)s"
msgid "State changed to: %s"
msgstr "Status geändert zu %s"
#: allianceauth/authentication/models.py:79
#, python-format
msgid "Your user's state is now: %(state)s"
msgstr "Dein Nutzerstatus ist nun %(state)s"
#: allianceauth/authentication/templates/authentication/dashboard.html:5
#: allianceauth/authentication/templates/authentication/dashboard.html:8
#: allianceauth/templates/allianceauth/side-menu.html:10
#: allianceauth/templates/allianceauth/side-menu.html:12
msgid "Dashboard"
msgstr "Dashboard"
#: allianceauth/authentication/templates/authentication/dashboard.html:17
#: allianceauth/corputils/templates/corputils/corpstats.html:116
#: allianceauth/corputils/templates/corputils/search.html:16
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticscorpview.html:22
#: allianceauth/hrapplications/templates/hrapplications/management.html:83
#: allianceauth/hrapplications/templates/hrapplications/management.html:128
#: allianceauth/hrapplications/templates/hrapplications/searchview.html:25
#: allianceauth/hrapplications/templates/hrapplications/view.html:32
msgid "Main Character"
msgstr "Hauptcharakter"
#: allianceauth/authentication/templates/authentication/dashboard.html:18
#, python-format
msgid ""
"\n"
" Main Character (State: %(state)s)\n"
" "
msgstr ""
"\n"
"Hauptcharakter (Status: %(state)s)"
#: allianceauth/authentication/templates/authentication/dashboard.html:77
#: allianceauth/authentication/templates/authentication/dashboard.html:81
msgid "No main character set."
msgstr "Kein Hauptcharakter gesetzt."
#: allianceauth/authentication/templates/authentication/dashboard.html:84
#: allianceauth/authentication/templates/authentication/dashboard.html:88
msgid "Add Character"
msgstr "Charakter hinzufügen"
#: allianceauth/authentication/templates/authentication/dashboard.html:88
#: allianceauth/authentication/templates/authentication/dashboard.html:92
msgid "Change Main"
msgstr "Hauptcharakter ändern"
#: allianceauth/authentication/templates/authentication/dashboard.html:97
#: allianceauth/authentication/templates/authentication/dashboard.html:101
msgid "Group Memberships"
msgstr "Gruppen"
#: allianceauth/authentication/templates/authentication/dashboard.html:117
#: allianceauth/authentication/templates/authentication/dashboard.html:121
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticscorpview.html:23
#: allianceauth/hrapplications/templates/hrapplications/view.html:41
msgid "Characters"
msgstr "Charaktere"
#: allianceauth/authentication/templates/authentication/dashboard.html:125
#: allianceauth/authentication/templates/authentication/dashboard.html:129
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:73
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:22
#: allianceauth/groupmanagement/templates/groupmanagement/groups.html:15
@@ -89,13 +89,13 @@ msgstr "Charaktere"
msgid "Name"
msgstr "Name"
#: allianceauth/authentication/templates/authentication/dashboard.html:126
#: allianceauth/authentication/templates/authentication/dashboard.html:130
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticsview.html:23
#: allianceauth/hrapplications/templates/hrapplications/view.html:46
msgid "Corp"
msgstr "Corp"
#: allianceauth/authentication/templates/authentication/dashboard.html:127
#: allianceauth/authentication/templates/authentication/dashboard.html:131
#: allianceauth/corputils/templates/corputils/corpstats.html:77
#: allianceauth/hrapplications/templates/hrapplications/view.html:47
msgid "Alliance"
@@ -236,8 +236,8 @@ msgstr "Letzes Update:"
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:28
#: allianceauth/groupmanagement/templates/groupmanagement/audit.html:27
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembers.html:29
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:37
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:96
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:51
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:110
msgid "Character"
msgstr "Charakter"
@@ -259,6 +259,16 @@ msgstr "Corporation"
msgid "Killboard"
msgstr "Killboard"
#: allianceauth/corputils/templates/corputils/corpstats.html:116
#: allianceauth/corputils/templates/corputils/search.html:16
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticscorpview.html:22
#: allianceauth/hrapplications/templates/hrapplications/management.html:83
#: allianceauth/hrapplications/templates/hrapplications/management.html:128
#: allianceauth/hrapplications/templates/hrapplications/searchview.html:25
#: allianceauth/hrapplications/templates/hrapplications/view.html:32
msgid "Main Character"
msgstr "Hauptcharakter"
#: allianceauth/corputils/templates/corputils/corpstats.html:117
#: allianceauth/corputils/templates/corputils/search.html:17
msgid "Main Corporation"
@@ -594,8 +604,8 @@ msgid "Portrait"
msgstr "Portrait"
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembers.html:30
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:38
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:97
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:52
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:111
#: allianceauth/permissions_tool/templates/permissions_tool/audit.html:23
msgid "Organization"
msgstr "Organization"
@@ -614,7 +624,7 @@ msgstr "Gruppenmitgliedschaft"
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:14
#: allianceauth/permissions_tool/templates/permissions_tool/overview.html:40
#: allianceauth/templates/allianceauth/side-menu.html:15
#: allianceauth/templates/allianceauth/side-menu.html:17
msgid "Groups"
msgstr "Gruppen"
@@ -658,8 +668,8 @@ msgid "Audit Members"
msgstr "Mitglieder Protokoll"
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:56
msgid "Copy Direrct Join Link"
msgstr ""
msgid "Copy Direct Join Link"
msgstr "Direktlink kopieren"
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:68
msgid "No groups to list."
@@ -690,37 +700,37 @@ msgstr "Keine Gruppen verfügbar"
msgid "Groups Management"
msgstr "Gruppenverwaltung"
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:23
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:25
msgid "Join Requests"
msgstr "Beitrittsgesuche"
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:24
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:33
msgid "Leave Requests"
msgstr "Austrittsgesuche"
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:39
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:98
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:53
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:112
#: allianceauth/permissions_tool/templates/permissions_tool/audit.html:20
#: allianceauth/services/modules/openfire/forms.py:6
msgid "Group"
msgstr "Gruppen"
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:71
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:130
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:85
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:144
msgid "Accept"
msgstr "Akzeptieren"
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:74
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:133
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:88
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:147
#: allianceauth/hrapplications/templates/hrapplications/view.html:85
msgid "Reject"
msgstr "Ablehnen"
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:83
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:97
msgid "No group add requests."
msgstr "Keine Gruppenbeitrittsanfragen."
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:142
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:156
msgid "No group leave requests."
msgstr "Keine Gruppenaustrittsanfragen"
@@ -729,7 +739,7 @@ msgid "Toggle navigation"
msgstr "Toggle Navigation"
#: allianceauth/groupmanagement/templates/groupmanagement/menu.html:15
#: allianceauth/templates/allianceauth/side-menu.html:23
#: allianceauth/templates/allianceauth/side-menu.html:25
msgid "Group Management"
msgstr "Gruppenverwaltung"
@@ -741,26 +751,26 @@ msgstr "Gruppenanfragen"
msgid "Group Membership"
msgstr "Gruppenmitgliedschaft"
#: allianceauth/groupmanagement/views.py:166
#: allianceauth/groupmanagement/views.py:162
#, python-format
msgid "Removed user %(user)s from group %(group)s."
msgstr "Benutzer %(user)s von Gruppe %(group)s entfernt."
#: allianceauth/groupmanagement/views.py:168
#: allianceauth/groupmanagement/views.py:164
msgid "User does not exist in that group"
msgstr "Benutzer existiert nicht in dieser Gruppe"
#: allianceauth/groupmanagement/views.py:171
#: allianceauth/groupmanagement/views.py:167
msgid "Group does not exist"
msgstr "Gruppe existiert nicht"
#: allianceauth/groupmanagement/views.py:198
#: allianceauth/groupmanagement/views.py:194
#, python-format
msgid "Accepted application from %(mainchar)s to %(group)s."
msgstr "Beitrittsgesuch von %(mainchar)s zur Gruppe %(group)s zugestimmt."
#: allianceauth/groupmanagement/views.py:205
#: allianceauth/groupmanagement/views.py:238
#: allianceauth/groupmanagement/views.py:201
#: allianceauth/groupmanagement/views.py:234
#, python-format
msgid ""
"An unhandled error occurred while processing the application from "
@@ -769,18 +779,18 @@ msgstr ""
"Bei der Bearbeitung des Beitrittsgesuchs von %(mainchar)s zur Gruppe "
"%(group)s ist ein unbehandelter Fehler aufgetreten."
#: allianceauth/groupmanagement/views.py:231
#: allianceauth/groupmanagement/views.py:227
#, python-format
msgid "Rejected application from %(mainchar)s to %(group)s."
msgstr "Beitrittsgesuch von %(mainchar)s zur Gruppe %(group)s abgelehnt."
#: allianceauth/groupmanagement/views.py:267
#: allianceauth/groupmanagement/views.py:263
#, python-format
msgid "Accepted application from %(mainchar)s to leave %(group)s."
msgstr "Austrittsgesuch von %(mainchar)s für Gruppe %(group)s akzeptiert."
#: allianceauth/groupmanagement/views.py:273
#: allianceauth/groupmanagement/views.py:307
#: allianceauth/groupmanagement/views.py:269
#: allianceauth/groupmanagement/views.py:303
#, python-format
msgid ""
"An unhandled error occurred while processing the application from "
@@ -789,26 +799,26 @@ msgstr ""
"Bei der Bearbeitung des Austrittsgesuchs von %(mainchar)s für Gruppe "
"%(group)s ist ein unbehandelter Fehler aufgetreten."
#: allianceauth/groupmanagement/views.py:300
#: allianceauth/groupmanagement/views.py:296
#, python-format
msgid "Rejected application from %(mainchar)s to leave %(group)s."
msgstr "Austrittsgesuch von %(mainchar)s für Gruppe %(group)s abgelehnt."
#: allianceauth/groupmanagement/views.py:346
#: allianceauth/groupmanagement/views.py:358
#: allianceauth/groupmanagement/views.py:342
#: allianceauth/groupmanagement/views.py:354
msgid "You cannot join that group"
msgstr "Du kannst dieser Gruppe nicht beitreten"
#: allianceauth/groupmanagement/views.py:352
#: allianceauth/groupmanagement/views.py:348
msgid "You are already a member of that group."
msgstr "Du bist bereits Mitglied dieser Gruppe."
#: allianceauth/groupmanagement/views.py:367
#: allianceauth/groupmanagement/views.py:363
msgid "You already have a pending application for that group."
msgstr "Du hast Dich bereits für diese Gruppe beworben."
#: allianceauth/groupmanagement/views.py:370
#: allianceauth/groupmanagement/views.py:408
#: allianceauth/groupmanagement/views.py:366
#: allianceauth/groupmanagement/views.py:404
#: allianceauth/hrapplications/templates/hrapplications/management.html:37
#: allianceauth/hrapplications/templates/hrapplications/management.html:72
#: allianceauth/hrapplications/templates/hrapplications/management.html:99
@@ -820,24 +830,24 @@ msgstr "Du hast Dich bereits für diese Gruppe beworben."
msgid "Pending"
msgstr "Beantragt"
#: allianceauth/groupmanagement/views.py:376
#: allianceauth/groupmanagement/views.py:372
#, python-format
msgid "Applied to group %(group)s."
msgstr "Beitritt zur Gruppe %(group)s beantragt."
#: allianceauth/groupmanagement/views.py:387
#: allianceauth/groupmanagement/views.py:383
msgid "You cannot leave that group"
msgstr "Du kannst diese Gruppe nicht verlassen"
#: allianceauth/groupmanagement/views.py:392
#: allianceauth/groupmanagement/views.py:388
msgid "You are not a member of that group"
msgstr "Du bist kein Mitglied dieser Gruppe"
#: allianceauth/groupmanagement/views.py:401
#: allianceauth/groupmanagement/views.py:397
msgid "You already have a pending leave request for that group."
msgstr "Du hast bereits ein ausstehendes Austrittsgesuch für diese Gruppe."
#: allianceauth/groupmanagement/views.py:414
#: allianceauth/groupmanagement/views.py:410
#, python-format
msgid "Applied to leave group %(group)s."
msgstr "Austrittsgesuch für Gruppe %(group)s gesendet."
@@ -1300,23 +1310,54 @@ msgstr "Passwort"
msgid "Password must be at least 8 characters long."
msgstr "Passwort muss mindestens 8 Zeichen lang sein"
#: allianceauth/services/modules/discord/templates/services/discord/discord_service_ctrl.html:23
#: allianceauth/services/modules/discord/models.py:224
msgid "Discord Account Disabled"
msgstr "Discord Konto deaktiviert"
#: allianceauth/services/modules/discord/models.py:226
msgid ""
"Your Discord account was disabeled automatically by Auth. If you think this "
"was a mistake, please contact an admin."
msgstr ""
"Dein Discord Konto wurde automatisch durch Auth deaktiviert. Wenn Du glaubst"
" dies war ein Fehler, kontaktiere bitte einen Administrator."
#: allianceauth/services/modules/discord/templates/services/discord/discord_service_ctrl.html:18
msgid "Join the Discord server"
msgstr "Discord Server beitreten"
#: allianceauth/services/modules/discord/templates/services/discord/discord_service_ctrl.html:22
msgid "Leave- and rejoin the Discord Server (Reset)"
msgstr "Discord Server verlassen und wieder beitreten (Zurücksetzen)"
#: allianceauth/services/modules/discord/templates/services/discord/discord_service_ctrl.html:25
msgid "Leave the Discord server"
msgstr "Discord Server verlassen"
#: allianceauth/services/modules/discord/templates/services/discord/discord_service_ctrl.html:32
msgid "Link Discord Server"
msgstr "Verbinde Discord Server"
#: allianceauth/services/modules/discord/views.py:26
#: allianceauth/services/modules/discord/views.py:30
msgid "Deactivated Discord account."
msgstr "Discord Konto deaktiviert."
#: allianceauth/services/modules/discord/views.py:29
#: allianceauth/services/modules/discord/views.py:41
#: allianceauth/services/modules/discord/views.py:65
#: allianceauth/services/modules/discord/views.py:36
#: allianceauth/services/modules/discord/views.py:59
msgid "An error occurred while processing your Discord account."
msgstr "Es gab einen Fehler bei der Verarbeitung Deines Discord Kontos."
#: allianceauth/services/modules/discord/views.py:62
msgid "Activated Discord account."
msgstr "Discord Konto aktiviert."
#: allianceauth/services/modules/discord/views.py:102
msgid "Your Discord account has been successfully activated."
msgstr "Dein Discord Konto wurde erfolgreich aktiviert."
#: allianceauth/services/modules/discord/views.py:108
msgid ""
"An error occurred while trying to activate your Discord account. Please try "
"again."
msgstr ""
"Es gab einen Fehler während der Aktivierung Deines Discord Kontos. Bitte "
"versuche es noch einmal."
#: allianceauth/services/modules/discourse/views.py:37
msgid "You are not authorized to access Discourse."
@@ -1884,32 +1925,30 @@ msgid "Current"
msgstr "Aktuell"
#: allianceauth/templates/allianceauth/admin-status/overview.html:40
msgid "Latest Major"
msgstr "Aktuellste Hauptversion"
msgid "Latest Stable"
msgstr "Aktuellste stabile Version"
#: allianceauth/templates/allianceauth/admin-status/overview.html:46
#: allianceauth/templates/allianceauth/admin-status/overview.html:56
#: allianceauth/templates/allianceauth/admin-status/overview.html:66
msgid "Update available"
msgstr "Update verfügbar"
#: allianceauth/templates/allianceauth/admin-status/overview.html:50
msgid "Latest Minor"
msgstr "Aktuellste Unterversion"
#: allianceauth/templates/allianceauth/admin-status/overview.html:51
msgid "Latest Pre-Release"
msgstr "Aktuellste Testversion"
#: allianceauth/templates/allianceauth/admin-status/overview.html:60
msgid "Latest Patch"
msgstr "Aktuellste Patchversion"
#: allianceauth/templates/allianceauth/admin-status/overview.html:57
msgid "Pre-Release available"
msgstr "Testversion verfügbar"
#: allianceauth/templates/allianceauth/admin-status/overview.html:73
#: allianceauth/templates/allianceauth/admin-status/overview.html:65
msgid "Task Queue"
msgstr "Warteschlange"
#: allianceauth/templates/allianceauth/admin-status/overview.html:90
#: allianceauth/templates/allianceauth/admin-status/overview.html:82
msgid "Error retrieving task queue length"
msgstr "Fehler beim Ermitteln der Warteschlange."
#: allianceauth/templates/allianceauth/admin-status/overview.html:92
#: allianceauth/templates/allianceauth/admin-status/overview.html:84
#, python-format
msgid "%(tasks)s task"
msgid_plural "%(tasks)s tasks"

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-05-08 00:57+0000\n"
"POT-Creation-Date: 2020-07-29 04:56+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -26,55 +26,53 @@ msgstr ""
msgid "Email"
msgstr ""
#: allianceauth/authentication/models.py:76
msgid "State Changed"
#: allianceauth/authentication/models.py:78
#, python-format
msgid "State changed to: %s"
msgstr ""
#: allianceauth/authentication/models.py:77
#: allianceauth/authentication/models.py:79
#, python-format
msgid "Your user state has been changed to %(state)s"
msgid "Your user's state is now: %(state)s"
msgstr ""
#: allianceauth/authentication/templates/authentication/dashboard.html:5
#: allianceauth/authentication/templates/authentication/dashboard.html:8
#: allianceauth/templates/allianceauth/side-menu.html:10
#: allianceauth/templates/allianceauth/side-menu.html:12
msgid "Dashboard"
msgstr ""
#: allianceauth/authentication/templates/authentication/dashboard.html:17
#: allianceauth/corputils/templates/corputils/corpstats.html:116
#: allianceauth/corputils/templates/corputils/search.html:16
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticscorpview.html:22
#: allianceauth/hrapplications/templates/hrapplications/management.html:83
#: allianceauth/hrapplications/templates/hrapplications/management.html:128
#: allianceauth/hrapplications/templates/hrapplications/searchview.html:25
#: allianceauth/hrapplications/templates/hrapplications/view.html:32
msgid "Main Character"
#: allianceauth/authentication/templates/authentication/dashboard.html:18
#, python-format
msgid ""
"\n"
" Main Character (State: %(state)s)\n"
" "
msgstr ""
#: allianceauth/authentication/templates/authentication/dashboard.html:77
#: allianceauth/authentication/templates/authentication/dashboard.html:81
msgid "No main character set."
msgstr ""
#: allianceauth/authentication/templates/authentication/dashboard.html:84
#: allianceauth/authentication/templates/authentication/dashboard.html:88
msgid "Add Character"
msgstr ""
#: allianceauth/authentication/templates/authentication/dashboard.html:88
#: allianceauth/authentication/templates/authentication/dashboard.html:92
msgid "Change Main"
msgstr ""
#: allianceauth/authentication/templates/authentication/dashboard.html:97
#: allianceauth/authentication/templates/authentication/dashboard.html:101
msgid "Group Memberships"
msgstr ""
#: allianceauth/authentication/templates/authentication/dashboard.html:117
#: allianceauth/authentication/templates/authentication/dashboard.html:121
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticscorpview.html:23
#: allianceauth/hrapplications/templates/hrapplications/view.html:41
msgid "Characters"
msgstr ""
#: allianceauth/authentication/templates/authentication/dashboard.html:125
#: allianceauth/authentication/templates/authentication/dashboard.html:129
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:73
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:22
#: allianceauth/groupmanagement/templates/groupmanagement/groups.html:15
@@ -83,13 +81,13 @@ msgstr ""
msgid "Name"
msgstr ""
#: allianceauth/authentication/templates/authentication/dashboard.html:126
#: allianceauth/authentication/templates/authentication/dashboard.html:130
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticsview.html:23
#: allianceauth/hrapplications/templates/hrapplications/view.html:46
msgid "Corp"
msgstr ""
#: allianceauth/authentication/templates/authentication/dashboard.html:127
#: allianceauth/authentication/templates/authentication/dashboard.html:131
#: allianceauth/corputils/templates/corputils/corpstats.html:77
#: allianceauth/hrapplications/templates/hrapplications/view.html:47
msgid "Alliance"
@@ -221,8 +219,8 @@ msgstr ""
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:28
#: allianceauth/groupmanagement/templates/groupmanagement/audit.html:27
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembers.html:29
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:37
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:96
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:51
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:110
msgid "Character"
msgstr ""
@@ -244,6 +242,16 @@ msgstr ""
msgid "Killboard"
msgstr ""
#: allianceauth/corputils/templates/corputils/corpstats.html:116
#: allianceauth/corputils/templates/corputils/search.html:16
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticscorpview.html:22
#: allianceauth/hrapplications/templates/hrapplications/management.html:83
#: allianceauth/hrapplications/templates/hrapplications/management.html:128
#: allianceauth/hrapplications/templates/hrapplications/searchview.html:25
#: allianceauth/hrapplications/templates/hrapplications/view.html:32
msgid "Main Character"
msgstr ""
#: allianceauth/corputils/templates/corputils/corpstats.html:117
#: allianceauth/corputils/templates/corputils/search.html:17
msgid "Main Corporation"
@@ -579,8 +587,8 @@ msgid "Portrait"
msgstr ""
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembers.html:30
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:38
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:97
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:52
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:111
#: allianceauth/permissions_tool/templates/permissions_tool/audit.html:23
msgid "Organization"
msgstr ""
@@ -599,7 +607,7 @@ msgstr ""
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:14
#: allianceauth/permissions_tool/templates/permissions_tool/overview.html:40
#: allianceauth/templates/allianceauth/side-menu.html:15
#: allianceauth/templates/allianceauth/side-menu.html:17
msgid "Groups"
msgstr ""
@@ -643,7 +651,7 @@ msgid "Audit Members"
msgstr ""
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:56
msgid "Copy Direrct Join Link"
msgid "Copy Direct Join Link"
msgstr ""
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:68
@@ -675,37 +683,37 @@ msgstr ""
msgid "Groups Management"
msgstr ""
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:23
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:25
msgid "Join Requests"
msgstr ""
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:24
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:33
msgid "Leave Requests"
msgstr ""
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:39
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:98
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:53
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:112
#: allianceauth/permissions_tool/templates/permissions_tool/audit.html:20
#: allianceauth/services/modules/openfire/forms.py:6
msgid "Group"
msgstr ""
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:71
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:130
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:85
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:144
msgid "Accept"
msgstr ""
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:74
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:133
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:88
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:147
#: allianceauth/hrapplications/templates/hrapplications/view.html:85
msgid "Reject"
msgstr ""
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:83
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:97
msgid "No group add requests."
msgstr ""
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:142
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:156
msgid "No group leave requests."
msgstr ""
@@ -714,7 +722,7 @@ msgid "Toggle navigation"
msgstr ""
#: allianceauth/groupmanagement/templates/groupmanagement/menu.html:15
#: allianceauth/templates/allianceauth/side-menu.html:23
#: allianceauth/templates/allianceauth/side-menu.html:25
msgid "Group Management"
msgstr ""
@@ -726,70 +734,70 @@ msgstr ""
msgid "Group Membership"
msgstr ""
#: allianceauth/groupmanagement/views.py:166
#: allianceauth/groupmanagement/views.py:162
#, python-format
msgid "Removed user %(user)s from group %(group)s."
msgstr ""
#: allianceauth/groupmanagement/views.py:168
#: allianceauth/groupmanagement/views.py:164
msgid "User does not exist in that group"
msgstr ""
#: allianceauth/groupmanagement/views.py:171
#: allianceauth/groupmanagement/views.py:167
msgid "Group does not exist"
msgstr ""
#: allianceauth/groupmanagement/views.py:198
#: allianceauth/groupmanagement/views.py:194
#, python-format
msgid "Accepted application from %(mainchar)s to %(group)s."
msgstr ""
#: allianceauth/groupmanagement/views.py:205
#: allianceauth/groupmanagement/views.py:238
#: allianceauth/groupmanagement/views.py:201
#: allianceauth/groupmanagement/views.py:234
#, python-format
msgid ""
"An unhandled error occurred while processing the application from "
"%(mainchar)s to %(group)s."
msgstr ""
#: allianceauth/groupmanagement/views.py:231
#: allianceauth/groupmanagement/views.py:227
#, python-format
msgid "Rejected application from %(mainchar)s to %(group)s."
msgstr ""
#: allianceauth/groupmanagement/views.py:267
#: allianceauth/groupmanagement/views.py:263
#, python-format
msgid "Accepted application from %(mainchar)s to leave %(group)s."
msgstr ""
#: allianceauth/groupmanagement/views.py:273
#: allianceauth/groupmanagement/views.py:307
#: allianceauth/groupmanagement/views.py:269
#: allianceauth/groupmanagement/views.py:303
#, python-format
msgid ""
"An unhandled error occurred while processing the application from "
"%(mainchar)s to leave %(group)s."
msgstr ""
#: allianceauth/groupmanagement/views.py:300
#: allianceauth/groupmanagement/views.py:296
#, python-format
msgid "Rejected application from %(mainchar)s to leave %(group)s."
msgstr ""
#: allianceauth/groupmanagement/views.py:346
#: allianceauth/groupmanagement/views.py:358
#: allianceauth/groupmanagement/views.py:342
#: allianceauth/groupmanagement/views.py:354
msgid "You cannot join that group"
msgstr ""
#: allianceauth/groupmanagement/views.py:352
#: allianceauth/groupmanagement/views.py:348
msgid "You are already a member of that group."
msgstr ""
#: allianceauth/groupmanagement/views.py:367
#: allianceauth/groupmanagement/views.py:363
msgid "You already have a pending application for that group."
msgstr ""
#: allianceauth/groupmanagement/views.py:370
#: allianceauth/groupmanagement/views.py:408
#: allianceauth/groupmanagement/views.py:366
#: allianceauth/groupmanagement/views.py:404
#: allianceauth/hrapplications/templates/hrapplications/management.html:37
#: allianceauth/hrapplications/templates/hrapplications/management.html:72
#: allianceauth/hrapplications/templates/hrapplications/management.html:99
@@ -801,24 +809,24 @@ msgstr ""
msgid "Pending"
msgstr ""
#: allianceauth/groupmanagement/views.py:376
#: allianceauth/groupmanagement/views.py:372
#, python-format
msgid "Applied to group %(group)s."
msgstr ""
#: allianceauth/groupmanagement/views.py:387
#: allianceauth/groupmanagement/views.py:383
msgid "You cannot leave that group"
msgstr ""
#: allianceauth/groupmanagement/views.py:392
#: allianceauth/groupmanagement/views.py:388
msgid "You are not a member of that group"
msgstr ""
#: allianceauth/groupmanagement/views.py:401
#: allianceauth/groupmanagement/views.py:397
msgid "You already have a pending leave request for that group."
msgstr ""
#: allianceauth/groupmanagement/views.py:414
#: allianceauth/groupmanagement/views.py:410
#, python-format
msgid "Applied to leave group %(group)s."
msgstr ""
@@ -1281,22 +1289,49 @@ msgstr ""
msgid "Password must be at least 8 characters long."
msgstr ""
#: allianceauth/services/modules/discord/templates/services/discord/discord_service_ctrl.html:23
#: allianceauth/services/modules/discord/models.py:224
msgid "Discord Account Disabled"
msgstr ""
#: allianceauth/services/modules/discord/models.py:226
msgid ""
"Your Discord account was disabeled automatically by Auth. If you think this "
"was a mistake, please contact an admin."
msgstr ""
#: allianceauth/services/modules/discord/templates/services/discord/discord_service_ctrl.html:18
msgid "Join the Discord server"
msgstr ""
#: allianceauth/services/modules/discord/templates/services/discord/discord_service_ctrl.html:22
msgid "Leave- and rejoin the Discord Server (Reset)"
msgstr ""
#: allianceauth/services/modules/discord/templates/services/discord/discord_service_ctrl.html:25
msgid "Leave the Discord server"
msgstr ""
#: allianceauth/services/modules/discord/templates/services/discord/discord_service_ctrl.html:32
msgid "Link Discord Server"
msgstr ""
#: allianceauth/services/modules/discord/views.py:26
#: allianceauth/services/modules/discord/views.py:30
msgid "Deactivated Discord account."
msgstr ""
#: allianceauth/services/modules/discord/views.py:29
#: allianceauth/services/modules/discord/views.py:41
#: allianceauth/services/modules/discord/views.py:65
#: allianceauth/services/modules/discord/views.py:36
#: allianceauth/services/modules/discord/views.py:59
msgid "An error occurred while processing your Discord account."
msgstr ""
#: allianceauth/services/modules/discord/views.py:62
msgid "Activated Discord account."
#: allianceauth/services/modules/discord/views.py:102
msgid "Your Discord account has been successfully activated."
msgstr ""
#: allianceauth/services/modules/discord/views.py:108
msgid ""
"An error occurred while trying to activate your Discord account. Please try "
"again."
msgstr ""
#: allianceauth/services/modules/discourse/views.py:37
@@ -1848,32 +1883,30 @@ msgid "Current"
msgstr ""
#: allianceauth/templates/allianceauth/admin-status/overview.html:40
msgid "Latest Major"
msgid "Latest Stable"
msgstr ""
#: allianceauth/templates/allianceauth/admin-status/overview.html:46
#: allianceauth/templates/allianceauth/admin-status/overview.html:56
#: allianceauth/templates/allianceauth/admin-status/overview.html:66
msgid "Update available"
msgstr ""
#: allianceauth/templates/allianceauth/admin-status/overview.html:50
msgid "Latest Minor"
#: allianceauth/templates/allianceauth/admin-status/overview.html:51
msgid "Latest Pre-Release"
msgstr ""
#: allianceauth/templates/allianceauth/admin-status/overview.html:60
msgid "Latest Patch"
#: allianceauth/templates/allianceauth/admin-status/overview.html:57
msgid "Pre-Release available"
msgstr ""
#: allianceauth/templates/allianceauth/admin-status/overview.html:73
#: allianceauth/templates/allianceauth/admin-status/overview.html:65
msgid "Task Queue"
msgstr ""
#: allianceauth/templates/allianceauth/admin-status/overview.html:90
#: allianceauth/templates/allianceauth/admin-status/overview.html:82
msgid "Error retrieving task queue length"
msgstr ""
#: allianceauth/templates/allianceauth/admin-status/overview.html:92
#: allianceauth/templates/allianceauth/admin-status/overview.html:84
#, python-format
msgid "%(tasks)s task"
msgid_plural "%(tasks)s tasks"

View File

@@ -11,7 +11,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-05-08 00:57+0000\n"
"POT-Creation-Date: 2020-07-29 04:56+0000\n"
"PO-Revision-Date: 2020-02-18 03:14+0000\n"
"Last-Translator: Alexander Gess <de.alex.gess@gmail.com>, 2020\n"
"Language-Team: Russian (https://www.transifex.com/alliance-auth/teams/107430/ru/)\n"
@@ -29,55 +29,56 @@ msgstr "Необходимо указать основного персонаж
msgid "Email"
msgstr "Email"
#: allianceauth/authentication/models.py:76
msgid "State Changed"
msgstr "Состояние заменено. "
#: allianceauth/authentication/models.py:77
#: allianceauth/authentication/models.py:78
#, python-format
msgid "Your user state has been changed to %(state)s"
msgstr "Статус вашего пользователя сменен на %(state)s"
msgid "State changed to: %s"
msgstr "Статус изменен: %s"
#: allianceauth/authentication/models.py:79
#, python-format
msgid "Your user's state is now: %(state)s"
msgstr "Статус пилота: %(state)s"
#: allianceauth/authentication/templates/authentication/dashboard.html:5
#: allianceauth/authentication/templates/authentication/dashboard.html:8
#: allianceauth/templates/allianceauth/side-menu.html:10
#: allianceauth/templates/allianceauth/side-menu.html:12
msgid "Dashboard"
msgstr "Панель показателей"
#: allianceauth/authentication/templates/authentication/dashboard.html:17
#: allianceauth/corputils/templates/corputils/corpstats.html:116
#: allianceauth/corputils/templates/corputils/search.html:16
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticscorpview.html:22
#: allianceauth/hrapplications/templates/hrapplications/management.html:83
#: allianceauth/hrapplications/templates/hrapplications/management.html:128
#: allianceauth/hrapplications/templates/hrapplications/searchview.html:25
#: allianceauth/hrapplications/templates/hrapplications/view.html:32
msgid "Main Character"
msgstr "Основной персонаж"
#: allianceauth/authentication/templates/authentication/dashboard.html:18
#, python-format
msgid ""
"\n"
" Main Character (State: %(state)s)\n"
" "
msgstr ""
"\n"
" Основной персонаж (статус: %(state)s)\n"
" "
#: allianceauth/authentication/templates/authentication/dashboard.html:77
#: allianceauth/authentication/templates/authentication/dashboard.html:81
msgid "No main character set."
msgstr "Основной персонаж не установлен."
#: allianceauth/authentication/templates/authentication/dashboard.html:84
#: allianceauth/authentication/templates/authentication/dashboard.html:88
msgid "Add Character"
msgstr "Добавить Персонажа"
#: allianceauth/authentication/templates/authentication/dashboard.html:88
#: allianceauth/authentication/templates/authentication/dashboard.html:92
msgid "Change Main"
msgstr "Сменить основного персонажа"
#: allianceauth/authentication/templates/authentication/dashboard.html:97
#: allianceauth/authentication/templates/authentication/dashboard.html:101
msgid "Group Memberships"
msgstr "Групповое участие"
#: allianceauth/authentication/templates/authentication/dashboard.html:117
#: allianceauth/authentication/templates/authentication/dashboard.html:121
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticscorpview.html:23
#: allianceauth/hrapplications/templates/hrapplications/view.html:41
msgid "Characters"
msgstr "Персонажи"
#: allianceauth/authentication/templates/authentication/dashboard.html:125
#: allianceauth/authentication/templates/authentication/dashboard.html:129
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:73
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:22
#: allianceauth/groupmanagement/templates/groupmanagement/groups.html:15
@@ -86,13 +87,13 @@ msgstr "Персонажи"
msgid "Name"
msgstr "Имя"
#: allianceauth/authentication/templates/authentication/dashboard.html:126
#: allianceauth/authentication/templates/authentication/dashboard.html:130
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticsview.html:23
#: allianceauth/hrapplications/templates/hrapplications/view.html:46
msgid "Corp"
msgstr "Корпорация"
#: allianceauth/authentication/templates/authentication/dashboard.html:127
#: allianceauth/authentication/templates/authentication/dashboard.html:131
#: allianceauth/corputils/templates/corputils/corpstats.html:77
#: allianceauth/hrapplications/templates/hrapplications/view.html:47
msgid "Alliance"
@@ -141,6 +142,7 @@ msgid ""
"Cannot change main character to %(char)s: character owned by a different "
"account."
msgstr ""
"Нельзя сменить основного персонажа на %(char)s: похоже, что Владелец не Вы. "
#: allianceauth/authentication/views.py:80
#, python-format
@@ -226,8 +228,8 @@ msgstr "Последнее обновление: "
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:28
#: allianceauth/groupmanagement/templates/groupmanagement/audit.html:27
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembers.html:29
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:37
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:96
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:51
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:110
msgid "Character"
msgstr "Персонаж"
@@ -249,6 +251,16 @@ msgstr "Корпорация"
msgid "Killboard"
msgstr "zKillBoard"
#: allianceauth/corputils/templates/corputils/corpstats.html:116
#: allianceauth/corputils/templates/corputils/search.html:16
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticscorpview.html:22
#: allianceauth/hrapplications/templates/hrapplications/management.html:83
#: allianceauth/hrapplications/templates/hrapplications/management.html:128
#: allianceauth/hrapplications/templates/hrapplications/searchview.html:25
#: allianceauth/hrapplications/templates/hrapplications/view.html:32
msgid "Main Character"
msgstr "Основной персонаж"
#: allianceauth/corputils/templates/corputils/corpstats.html:117
#: allianceauth/corputils/templates/corputils/search.html:17
msgid "Main Corporation"
@@ -588,8 +600,8 @@ msgid "Portrait"
msgstr "Портрет"
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembers.html:30
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:38
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:97
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:52
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:111
#: allianceauth/permissions_tool/templates/permissions_tool/audit.html:23
msgid "Organization"
msgstr "Корпорация"
@@ -608,7 +620,7 @@ msgstr "Участники группы"
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:14
#: allianceauth/permissions_tool/templates/permissions_tool/overview.html:40
#: allianceauth/templates/allianceauth/side-menu.html:15
#: allianceauth/templates/allianceauth/side-menu.html:17
msgid "Groups"
msgstr "Группы"
@@ -652,8 +664,8 @@ msgid "Audit Members"
msgstr "Проверить участников"
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:56
msgid "Copy Direrct Join Link"
msgstr ""
msgid "Copy Direct Join Link"
msgstr "Скопировать ссылку подключения"
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:68
msgid "No groups to list."
@@ -684,37 +696,37 @@ msgstr "Нет доступных групп."
msgid "Groups Management"
msgstr "Управление Группами"
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:23
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:25
msgid "Join Requests"
msgstr "Запрос на присоединение"
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:24
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:33
msgid "Leave Requests"
msgstr "Запрос на Выход"
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:39
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:98
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:53
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:112
#: allianceauth/permissions_tool/templates/permissions_tool/audit.html:20
#: allianceauth/services/modules/openfire/forms.py:6
msgid "Group"
msgstr "Группа"
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:71
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:130
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:85
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:144
msgid "Accept"
msgstr "Принять"
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:74
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:133
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:88
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:147
#: allianceauth/hrapplications/templates/hrapplications/view.html:85
msgid "Reject"
msgstr "Сбросить"
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:83
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:97
msgid "No group add requests."
msgstr "Нет групповых запросов на вступление"
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:142
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:156
msgid "No group leave requests."
msgstr "Нет групповых запросов на выход"
@@ -723,7 +735,7 @@ msgid "Toggle navigation"
msgstr "Проложить маршрут"
#: allianceauth/groupmanagement/templates/groupmanagement/menu.html:15
#: allianceauth/templates/allianceauth/side-menu.html:23
#: allianceauth/templates/allianceauth/side-menu.html:25
msgid "Group Management"
msgstr "Управление Группой"
@@ -735,26 +747,26 @@ msgstr "Групповой запрос"
msgid "Group Membership"
msgstr "Групповое участие"
#: allianceauth/groupmanagement/views.py:166
#: allianceauth/groupmanagement/views.py:162
#, python-format
msgid "Removed user %(user)s from group %(group)s."
msgstr "Пользователь %(user)s исключен из %(group)s."
#: allianceauth/groupmanagement/views.py:168
#: allianceauth/groupmanagement/views.py:164
msgid "User does not exist in that group"
msgstr "Пользователь не существует в этой группе."
#: allianceauth/groupmanagement/views.py:171
#: allianceauth/groupmanagement/views.py:167
msgid "Group does not exist"
msgstr "Группа не существует."
#: allianceauth/groupmanagement/views.py:198
#: allianceauth/groupmanagement/views.py:194
#, python-format
msgid "Accepted application from %(mainchar)s to %(group)s."
msgstr "Запрос от %(mainchar)sв %(group)s принят."
#: allianceauth/groupmanagement/views.py:205
#: allianceauth/groupmanagement/views.py:238
#: allianceauth/groupmanagement/views.py:201
#: allianceauth/groupmanagement/views.py:234
#, python-format
msgid ""
"An unhandled error occurred while processing the application from "
@@ -763,44 +775,46 @@ msgstr ""
"Персонаж %(mainchar)s не может быть добавлен %(group)s, из-за непредвиденной"
" ошибки. "
#: allianceauth/groupmanagement/views.py:231
#: allianceauth/groupmanagement/views.py:227
#, python-format
msgid "Rejected application from %(mainchar)s to %(group)s."
msgstr "%(mainchar)s исключен из %(group)s."
#: allianceauth/groupmanagement/views.py:267
#: allianceauth/groupmanagement/views.py:263
#, python-format
msgid "Accepted application from %(mainchar)s to leave %(group)s."
msgstr "Утвержден выход %(mainchar)s из %(group)s. "
#: allianceauth/groupmanagement/views.py:273
#: allianceauth/groupmanagement/views.py:307
#: allianceauth/groupmanagement/views.py:269
#: allianceauth/groupmanagement/views.py:303
#, python-format
msgid ""
"An unhandled error occurred while processing the application from "
"%(mainchar)s to leave %(group)s."
msgstr ""
"Возникла ошибка во время обработки %(mainchar)s на выход из группы "
"%(group)s. Повторите позже."
#: allianceauth/groupmanagement/views.py:300
#: allianceauth/groupmanagement/views.py:296
#, python-format
msgid "Rejected application from %(mainchar)s to leave %(group)s."
msgstr "Прошение об исключении %(mainchar)s из %(group)s отклонено. "
#: allianceauth/groupmanagement/views.py:346
#: allianceauth/groupmanagement/views.py:358
#: allianceauth/groupmanagement/views.py:342
#: allianceauth/groupmanagement/views.py:354
msgid "You cannot join that group"
msgstr "Вы не можете вступить"
#: allianceauth/groupmanagement/views.py:352
#: allianceauth/groupmanagement/views.py:348
msgid "You are already a member of that group."
msgstr ""
msgstr "Вы уже участник этой группы."
#: allianceauth/groupmanagement/views.py:367
#: allianceauth/groupmanagement/views.py:363
msgid "You already have a pending application for that group."
msgstr ""
msgstr "Вы уже подали заявку на вступление этой группы."
#: allianceauth/groupmanagement/views.py:370
#: allianceauth/groupmanagement/views.py:408
#: allianceauth/groupmanagement/views.py:366
#: allianceauth/groupmanagement/views.py:404
#: allianceauth/hrapplications/templates/hrapplications/management.html:37
#: allianceauth/hrapplications/templates/hrapplications/management.html:72
#: allianceauth/hrapplications/templates/hrapplications/management.html:99
@@ -812,24 +826,24 @@ msgstr ""
msgid "Pending"
msgstr "Ожидание"
#: allianceauth/groupmanagement/views.py:376
#: allianceauth/groupmanagement/views.py:372
#, python-format
msgid "Applied to group %(group)s."
msgstr "Вступить в группу %(group)s."
#: allianceauth/groupmanagement/views.py:387
#: allianceauth/groupmanagement/views.py:383
msgid "You cannot leave that group"
msgstr "Вы не можете покинуть эту группу"
#: allianceauth/groupmanagement/views.py:392
#: allianceauth/groupmanagement/views.py:388
msgid "You are not a member of that group"
msgstr "Вы не участник группыы"
#: allianceauth/groupmanagement/views.py:401
#: allianceauth/groupmanagement/views.py:397
msgid "You already have a pending leave request for that group."
msgstr ""
msgstr "Ваш запрос находится на рассмотрении"
#: allianceauth/groupmanagement/views.py:414
#: allianceauth/groupmanagement/views.py:410
#, python-format
msgid "Applied to leave group %(group)s."
msgstr "Запрос на выход из группы %(group)s."
@@ -1222,15 +1236,15 @@ msgstr "Состояния"
#: allianceauth/services/abstract.py:72
msgid "That service account already exists"
msgstr ""
msgstr "Этот сервис уже активирован"
#: allianceauth/services/abstract.py:104
msgid "Successfully set your {} password"
msgstr ""
msgstr "{} Пароль успешно обновлен."
#: allianceauth/services/auth_hooks.py:11
msgid "Services"
msgstr ""
msgstr "Сервисные услуги"
#: allianceauth/services/forms.py:6
msgid "Name of Fleet:"
@@ -1292,37 +1306,74 @@ msgstr "Пароль"
msgid "Password must be at least 8 characters long."
msgstr "Пароль должен быть не менее 8 символов."
#: allianceauth/services/modules/discord/templates/services/discord/discord_service_ctrl.html:23
#: allianceauth/services/modules/discord/models.py:224
msgid "Discord Account Disabled"
msgstr "Discord персонаж отключен"
#: allianceauth/services/modules/discord/models.py:226
msgid ""
"Your Discord account was disabeled automatically by Auth. If you think this "
"was a mistake, please contact an admin."
msgstr ""
"Ваш доступ на сервер Discord был отменен. Если Вы считаете что по ошибке, "
"пожалуйста, свяжитесь с СЕО."
#: allianceauth/services/modules/discord/templates/services/discord/discord_service_ctrl.html:18
msgid "Join the Discord server"
msgstr "Подключиться к серверу Discord"
#: allianceauth/services/modules/discord/templates/services/discord/discord_service_ctrl.html:22
msgid "Leave- and rejoin the Discord Server (Reset)"
msgstr "Переподключиться к серверу Discord. "
#: allianceauth/services/modules/discord/templates/services/discord/discord_service_ctrl.html:25
msgid "Leave the Discord server"
msgstr "Покинуть Discord сервер"
#: allianceauth/services/modules/discord/templates/services/discord/discord_service_ctrl.html:32
msgid "Link Discord Server"
msgstr "Ссылка на сервер Discord"
#: allianceauth/services/modules/discord/views.py:26
#: allianceauth/services/modules/discord/views.py:30
msgid "Deactivated Discord account."
msgstr ""
msgstr "Отменить доступ на Discord сервер."
#: allianceauth/services/modules/discord/views.py:29
#: allianceauth/services/modules/discord/views.py:41
#: allianceauth/services/modules/discord/views.py:65
#: allianceauth/services/modules/discord/views.py:36
#: allianceauth/services/modules/discord/views.py:59
msgid "An error occurred while processing your Discord account."
msgstr ""
"Во время обработки Discord аккаунта возникла ошибка. Попробуйте чуточку "
"позднее. "
#: allianceauth/services/modules/discord/views.py:62
msgid "Activated Discord account."
#: allianceauth/services/modules/discord/views.py:102
msgid "Your Discord account has been successfully activated."
msgstr "Доступ на сервер Discord успешно получен."
#: allianceauth/services/modules/discord/views.py:108
msgid ""
"An error occurred while trying to activate your Discord account. Please try "
"again."
msgstr ""
"Во время активации Discord аккаунта возникла ошибка. Попробуйте чуточку "
"позднее. "
#: allianceauth/services/modules/discourse/views.py:37
msgid "You are not authorized to access Discourse."
msgstr ""
msgstr "Вы не авторизованы в Discourse."
#: allianceauth/services/modules/discourse/views.py:42
msgid "You must have a main character set to access Discourse."
msgstr ""
"Для авторизации Discourse, необходимо получить авторизацию Вашим основным "
"аккаунтом."
#: allianceauth/services/modules/discourse/views.py:52
msgid ""
"No SSO payload or signature. Please contact support if this problem "
"persists."
msgstr ""
"Отсуствует связь SSO. Если ошибка повторяется - свяжитесь с тех. поддержкой."
" "
#: allianceauth/services/modules/discourse/views.py:62
#: allianceauth/services/modules/discourse/views.py:70
@@ -1354,7 +1405,7 @@ msgstr ""
#: allianceauth/services/modules/openfire/auth_hooks.py:26
msgid "Jabber"
msgstr ""
msgstr "Jabber"
#: allianceauth/services/modules/openfire/auth_hooks.py:78
#: allianceauth/services/modules/openfire/templates/services/openfire/broadcast.html:6
@@ -1380,50 +1431,50 @@ msgstr "Бродкаст"
#: allianceauth/services/modules/openfire/views.py:35
msgid "Activated jabber account."
msgstr ""
msgstr "Активировать доступ в jabber."
#: allianceauth/services/modules/openfire/views.py:44
#: allianceauth/services/modules/openfire/views.py:57
#: allianceauth/services/modules/openfire/views.py:78
#: allianceauth/services/modules/openfire/views.py:151
msgid "An error occurred while processing your jabber account."
msgstr ""
msgstr "Возникла ошибка во время активации jabber'а ."
#: allianceauth/services/modules/openfire/views.py:70
msgid "Reset jabber password."
msgstr ""
msgstr "Сбросить jabber пароль."
#: allianceauth/services/modules/openfire/views.py:119
#, python-format
msgid "Sent jabber broadcast to %s"
msgstr ""
msgstr "Отправить Бродкаст %s"
#: allianceauth/services/modules/openfire/views.py:148
msgid "Set jabber password."
msgstr ""
msgstr "Установить jabber пароль."
#: allianceauth/services/modules/phpbb3/views.py:34
msgid "Activated forum account."
msgstr ""
msgstr "Допустить на Форум."
#: allianceauth/services/modules/phpbb3/views.py:43
#: allianceauth/services/modules/phpbb3/views.py:57
#: allianceauth/services/modules/phpbb3/views.py:80
#: allianceauth/services/modules/phpbb3/views.py:103
msgid "An error occurred while processing your forum account."
msgstr ""
msgstr "Во время обработки Форумного аккаунта, возникла ошибка."
#: allianceauth/services/modules/phpbb3/views.py:54
msgid "Deactivated forum account."
msgstr ""
msgstr "Отменить доступ на Форум. "
#: allianceauth/services/modules/phpbb3/views.py:71
msgid "Reset forum password."
msgstr ""
msgstr "Сбросить пароль на Форум."
#: allianceauth/services/modules/phpbb3/views.py:100
msgid "Set forum password."
msgstr ""
msgstr "Установить пароль на Форум."
#: allianceauth/services/modules/smf/views.py:34
msgid "Activated SMF account."
@@ -1473,21 +1524,21 @@ msgstr "Продолжить"
#: allianceauth/services/modules/teamspeak3/views.py:34
msgid "Activated TeamSpeak3 account."
msgstr ""
msgstr "Активировать аккаунт TeamSpeak3."
#: allianceauth/services/modules/teamspeak3/views.py:37
#: allianceauth/services/modules/teamspeak3/views.py:74
#: allianceauth/services/modules/teamspeak3/views.py:100
msgid "An error occurred while processing your TeamSpeak3 account."
msgstr ""
msgstr "Во время активации TeamSpeak3 возникла ошибка, попробуйте позже."
#: allianceauth/services/modules/teamspeak3/views.py:71
msgid "Deactivated TeamSpeak3 account."
msgstr ""
msgstr "Отключить TeamSpeak3 аккаунт."
#: allianceauth/services/modules/teamspeak3/views.py:97
msgid "Reset TeamSpeak3 permission key."
msgstr ""
msgstr "Сбросить TeamSpeak3 ключ доступа."
#: allianceauth/services/modules/xenforo/views.py:30
msgid "Activated XenForo account."
@@ -1866,32 +1917,30 @@ msgid "Current"
msgstr "Текущий"
#: allianceauth/templates/allianceauth/admin-status/overview.html:40
msgid "Latest Major"
msgstr "Последняя версия"
msgid "Latest Stable"
msgstr "Стабильная Версия"
#: allianceauth/templates/allianceauth/admin-status/overview.html:46
#: allianceauth/templates/allianceauth/admin-status/overview.html:56
#: allianceauth/templates/allianceauth/admin-status/overview.html:66
msgid "Update available"
msgstr "Доступно обновление"
#: allianceauth/templates/allianceauth/admin-status/overview.html:50
msgid "Latest Minor"
msgstr "Последняя версия"
#: allianceauth/templates/allianceauth/admin-status/overview.html:51
msgid "Latest Pre-Release"
msgstr "Предрелизная Версия"
#: allianceauth/templates/allianceauth/admin-status/overview.html:60
msgid "Latest Patch"
msgstr "Последние исправления"
#: allianceauth/templates/allianceauth/admin-status/overview.html:57
msgid "Pre-Release available"
msgstr "Предрелизная Версия"
#: allianceauth/templates/allianceauth/admin-status/overview.html:73
#: allianceauth/templates/allianceauth/admin-status/overview.html:65
msgid "Task Queue"
msgstr "Список задач"
#: allianceauth/templates/allianceauth/admin-status/overview.html:90
#: allianceauth/templates/allianceauth/admin-status/overview.html:82
msgid "Error retrieving task queue length"
msgstr "Ошибка при получении списка задач. "
#: allianceauth/templates/allianceauth/admin-status/overview.html:92
#: allianceauth/templates/allianceauth/admin-status/overview.html:84
#, python-format
msgid "%(tasks)s task"
msgid_plural "%(tasks)s tasks"

View File

@@ -6,15 +6,16 @@
# Translators:
# Joel Falknau <ozirascal@gmail.com>, 2020
# Jesse . <sgeine@hotmail.com>, 2020
# Aaron BuBu <351793078@qq.com>, 2020
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-03-10 01:32+0000\n"
"POT-Creation-Date: 2020-07-29 03:24+0000\n"
"PO-Revision-Date: 2020-02-18 03:14+0000\n"
"Last-Translator: Jesse . <sgeine@hotmail.com>, 2020\n"
"Last-Translator: Aaron BuBu <351793078@qq.com>, 2020\n"
"Language-Team: Chinese Simplified (https://www.transifex.com/alliance-auth/teams/107430/zh-Hans/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
@@ -30,55 +31,53 @@ msgstr "只有主要角色才能执行这个操作。在下面添加一个"
msgid "Email"
msgstr "电子邮箱"
#: allianceauth/authentication/models.py:76
msgid "State Changed"
msgstr "状态已经更改"
#: allianceauth/authentication/models.py:77
#: allianceauth/authentication/models.py:78
#, python-format
msgid "Your user state has been changed to %(state)s"
msgstr "您的用户状态已经更改为%(state)s"
msgid "State changed to: %s"
msgstr ""
#: allianceauth/authentication/models.py:79
#, python-format
msgid "Your user's state is now: %(state)s"
msgstr ""
#: allianceauth/authentication/templates/authentication/dashboard.html:5
#: allianceauth/authentication/templates/authentication/dashboard.html:8
#: allianceauth/templates/allianceauth/side-menu.html:10
#: allianceauth/templates/allianceauth/side-menu.html:12
msgid "Dashboard"
msgstr "账户总览"
#: allianceauth/authentication/templates/authentication/dashboard.html:17
#: allianceauth/corputils/templates/corputils/corpstats.html:116
#: allianceauth/corputils/templates/corputils/search.html:16
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticscorpview.html:22
#: allianceauth/hrapplications/templates/hrapplications/management.html:83
#: allianceauth/hrapplications/templates/hrapplications/management.html:128
#: allianceauth/hrapplications/templates/hrapplications/searchview.html:25
#: allianceauth/hrapplications/templates/hrapplications/view.html:32
msgid "Main Character"
msgstr "主要角色"
#: allianceauth/authentication/templates/authentication/dashboard.html:18
#, python-format
msgid ""
"\n"
" Main Character (State: %(state)s)\n"
" "
msgstr ""
#: allianceauth/authentication/templates/authentication/dashboard.html:77
#: allianceauth/authentication/templates/authentication/dashboard.html:81
msgid "No main character set."
msgstr "没有主要角色组"
#: allianceauth/authentication/templates/authentication/dashboard.html:84
#: allianceauth/authentication/templates/authentication/dashboard.html:88
msgid "Add Character"
msgstr "添加角色"
#: allianceauth/authentication/templates/authentication/dashboard.html:88
#: allianceauth/authentication/templates/authentication/dashboard.html:92
msgid "Change Main"
msgstr "修改主要角色"
#: allianceauth/authentication/templates/authentication/dashboard.html:97
#: allianceauth/authentication/templates/authentication/dashboard.html:101
msgid "Group Memberships"
msgstr "用户组成员"
#: allianceauth/authentication/templates/authentication/dashboard.html:117
#: allianceauth/authentication/templates/authentication/dashboard.html:121
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticscorpview.html:23
#: allianceauth/hrapplications/templates/hrapplications/view.html:41
msgid "Characters"
msgstr "角色"
#: allianceauth/authentication/templates/authentication/dashboard.html:125
#: allianceauth/authentication/templates/authentication/dashboard.html:129
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:73
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:22
#: allianceauth/groupmanagement/templates/groupmanagement/groups.html:15
@@ -87,13 +86,13 @@ msgstr "角色"
msgid "Name"
msgstr "角色名"
#: allianceauth/authentication/templates/authentication/dashboard.html:126
#: allianceauth/authentication/templates/authentication/dashboard.html:130
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticsview.html:23
#: allianceauth/hrapplications/templates/hrapplications/view.html:46
msgid "Corp"
msgstr "所在公司"
#: allianceauth/authentication/templates/authentication/dashboard.html:127
#: allianceauth/authentication/templates/authentication/dashboard.html:131
#: allianceauth/corputils/templates/corputils/corpstats.html:77
#: allianceauth/hrapplications/templates/hrapplications/view.html:47
msgid "Alliance"
@@ -134,40 +133,47 @@ msgstr "您的IT团队"
msgid "Submit"
msgstr "提交"
#: allianceauth/authentication/views.py:77
#: allianceauth/authentication/views.py:74
#, python-format
msgid ""
"Cannot change main character to %(char)s: character owned by a different "
"account."
msgstr "不能修改主角色为%(char)s这个角色被另一个账户所拥有"
#: allianceauth/authentication/views.py:80
#, python-format
msgid "Changed main character to %(char)s"
msgstr "修改主要角色为%(char)s"
#: allianceauth/authentication/views.py:86
#: allianceauth/authentication/views.py:89
#, python-format
msgid "Added %(name)s to your account."
msgstr "添加%(name)s到您的账户"
#: allianceauth/authentication/views.py:88
#: allianceauth/authentication/views.py:91
#, python-format
msgid "Failed to add %(name)s to your account: they already have an account."
msgstr "添加%(name)s到您的账户失败他们已经在一个账户中了"
#: allianceauth/authentication/views.py:127
#: allianceauth/authentication/views.py:130
msgid "Unable to authenticate as the selected character."
msgstr "无法作为选定的角色进行身份验证"
#: allianceauth/authentication/views.py:145
#: allianceauth/authentication/views.py:148
msgid "Registration token has expired."
msgstr "注册令牌过期。"
#: allianceauth/authentication/views.py:197
#: allianceauth/authentication/views.py:200
msgid ""
"Sent confirmation email. Please follow the link to confirm your email "
"address."
msgstr "已经发送了确认邮件。请按照链接确定您的电邮地址"
#: allianceauth/authentication/views.py:202
#: allianceauth/authentication/views.py:205
msgid "Confirmed your email address. Please login to continue."
msgstr "已确认您的电邮地址。请登录以继续"
#: allianceauth/authentication/views.py:207
#: allianceauth/authentication/views.py:210
msgid "Registraion of new accounts it not allowed at this time."
msgstr "现在不允许注册新账户。"
@@ -218,8 +224,8 @@ msgstr "最后一次更新"
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:28
#: allianceauth/groupmanagement/templates/groupmanagement/audit.html:27
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembers.html:29
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:37
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:96
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:51
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:110
msgid "Character"
msgstr "角色"
@@ -241,6 +247,16 @@ msgstr "公司"
msgid "Killboard"
msgstr "KB板"
#: allianceauth/corputils/templates/corputils/corpstats.html:116
#: allianceauth/corputils/templates/corputils/search.html:16
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticscorpview.html:22
#: allianceauth/hrapplications/templates/hrapplications/management.html:83
#: allianceauth/hrapplications/templates/hrapplications/management.html:128
#: allianceauth/hrapplications/templates/hrapplications/searchview.html:25
#: allianceauth/hrapplications/templates/hrapplications/view.html:32
msgid "Main Character"
msgstr "主要角色"
#: allianceauth/corputils/templates/corputils/corpstats.html:117
#: allianceauth/corputils/templates/corputils/search.html:17
msgid "Main Corporation"
@@ -527,6 +543,12 @@ msgstr "PAP链接已过期"
msgid "Audit Log"
msgstr "审计日志"
#: allianceauth/groupmanagement/templates/groupmanagement/audit.html:18
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembers.html:20
#: allianceauth/permissions_tool/templates/permissions_tool/audit.html:13
msgid "Back"
msgstr "返回"
#: allianceauth/groupmanagement/templates/groupmanagement/audit.html:25
msgid "Date/Time"
msgstr "日期/时间"
@@ -568,8 +590,8 @@ msgid "Portrait"
msgstr "人物头像"
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembers.html:30
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:38
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:97
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:52
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:111
#: allianceauth/permissions_tool/templates/permissions_tool/audit.html:23
msgid "Organization"
msgstr "组织"
@@ -586,6 +608,12 @@ msgstr "用户组里没人呀,你叫我怎么列"
msgid "Groups Membership"
msgstr "用户组成员"
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:14
#: allianceauth/permissions_tool/templates/permissions_tool/overview.html:40
#: allianceauth/templates/allianceauth/side-menu.html:17
msgid "Groups"
msgstr "群组"
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:23
#: allianceauth/groupmanagement/templates/groupmanagement/groups.html:16
msgid "Description"
@@ -625,7 +653,11 @@ msgstr "查看成员"
msgid "Audit Members"
msgstr "编辑成员"
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:64
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:56
msgid "Copy Direct Join Link"
msgstr ""
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:68
msgid "No groups to list."
msgstr "无可用组"
@@ -654,37 +686,37 @@ msgstr "没有可用用户组"
msgid "Groups Management"
msgstr "用户组管理"
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:23
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:25
msgid "Join Requests"
msgstr "入组的请求"
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:24
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:33
msgid "Leave Requests"
msgstr "离组的请求"
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:39
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:98
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:53
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:112
#: allianceauth/permissions_tool/templates/permissions_tool/audit.html:20
#: allianceauth/services/modules/openfire/forms.py:6
msgid "Group"
msgstr "用户组"
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:71
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:130
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:85
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:144
msgid "Accept"
msgstr "接受"
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:74
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:133
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:88
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:147
#: allianceauth/hrapplications/templates/hrapplications/view.html:85
msgid "Reject"
msgstr "拒绝"
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:83
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:97
msgid "No group add requests."
msgstr "没有加入用户组的请求,小老弟你是不是摇不到人"
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:142
#: allianceauth/groupmanagement/templates/groupmanagement/index.html:156
msgid "No group leave requests."
msgstr "没有离开用户组的请求,小老弟你人缘可以啊?"
@@ -693,7 +725,7 @@ msgid "Toggle navigation"
msgstr "打开导航栏"
#: allianceauth/groupmanagement/templates/groupmanagement/menu.html:15
#: allianceauth/templates/allianceauth/side-menu.html:23
#: allianceauth/templates/allianceauth/side-menu.html:25
msgid "Group Management"
msgstr "用户组管理"
@@ -705,61 +737,70 @@ msgstr "用户组请求"
msgid "Group Membership"
msgstr "用户组成员"
#: allianceauth/groupmanagement/views.py:165
#: allianceauth/groupmanagement/views.py:162
#, python-format
msgid "Removed user %(user)s from group %(group)s."
msgstr "已将用户%(user)s从用户组%(group)s中移除"
#: allianceauth/groupmanagement/views.py:167
#: allianceauth/groupmanagement/views.py:164
msgid "User does not exist in that group"
msgstr "那个用户组中不存在这个用户"
#: allianceauth/groupmanagement/views.py:170
#: allianceauth/groupmanagement/views.py:167
msgid "Group does not exist"
msgstr "用户组不存在"
#: allianceauth/groupmanagement/views.py:197
#: allianceauth/groupmanagement/views.py:194
#, python-format
msgid "Accepted application from %(mainchar)s to %(group)s."
msgstr "已接受用户%(mainchar)s加入%(group)s的申请"
#: allianceauth/groupmanagement/views.py:204
#: allianceauth/groupmanagement/views.py:237
#: allianceauth/groupmanagement/views.py:201
#: allianceauth/groupmanagement/views.py:234
#, python-format
msgid ""
"An unhandled error occurred while processing the application from "
"%(mainchar)s to %(group)s."
msgstr "在处理用户%(mainchar)s加入%(group)s的申请的过程中出现了一个搞不定的错误"
#: allianceauth/groupmanagement/views.py:230
#: allianceauth/groupmanagement/views.py:227
#, python-format
msgid "Rejected application from %(mainchar)s to %(group)s."
msgstr "%(mainchar)s加入%(group)s的申请已拒绝"
#: allianceauth/groupmanagement/views.py:266
#: allianceauth/groupmanagement/views.py:263
#, python-format
msgid "Accepted application from %(mainchar)s to leave %(group)s."
msgstr "%(mainchar)s加入%(group)s的申请已通过"
#: allianceauth/groupmanagement/views.py:272
#: allianceauth/groupmanagement/views.py:306
#: allianceauth/groupmanagement/views.py:269
#: allianceauth/groupmanagement/views.py:303
#, python-format
msgid ""
"An unhandled error occured while processing the application from "
"An unhandled error occurred while processing the application from "
"%(mainchar)s to leave %(group)s."
msgstr "在处理%(mainchar)s离开%(group)s的请求时发生了搞不定的错误"
msgstr "在处理%(mainchar)s离开%(group)s的程序时发生了未知的错误"
#: allianceauth/groupmanagement/views.py:299
#: allianceauth/groupmanagement/views.py:296
#, python-format
msgid "Rejected application from %(mainchar)s to leave %(group)s."
msgstr "%(mainchar)s离开%(group)s的请求已被拒绝"
#: allianceauth/groupmanagement/views.py:346
#: allianceauth/groupmanagement/views.py:342
#: allianceauth/groupmanagement/views.py:354
msgid "You cannot join that group"
msgstr "你无法加入那个用户组"
#: allianceauth/groupmanagement/views.py:370
#: allianceauth/groupmanagement/views.py:408
#: allianceauth/groupmanagement/views.py:348
msgid "You are already a member of that group."
msgstr "你已经是那个群组的一员了。"
#: allianceauth/groupmanagement/views.py:363
msgid "You already have a pending application for that group."
msgstr "你已经有了该组的未决申请"
#: allianceauth/groupmanagement/views.py:366
#: allianceauth/groupmanagement/views.py:404
#: allianceauth/hrapplications/templates/hrapplications/management.html:37
#: allianceauth/hrapplications/templates/hrapplications/management.html:72
#: allianceauth/hrapplications/templates/hrapplications/management.html:99
@@ -771,20 +812,24 @@ msgstr "你无法加入那个用户组"
msgid "Pending"
msgstr "待定"
#: allianceauth/groupmanagement/views.py:376
#: allianceauth/groupmanagement/views.py:372
#, python-format
msgid "Applied to group %(group)s."
msgstr "修改已经应用到%(group)s啦"
#: allianceauth/groupmanagement/views.py:387
#: allianceauth/groupmanagement/views.py:383
msgid "You cannot leave that group"
msgstr "你无法离开那个用户组"
#: allianceauth/groupmanagement/views.py:392
#: allianceauth/groupmanagement/views.py:388
msgid "You are not a member of that group"
msgstr "你不是那个用户组的成员"
#: allianceauth/groupmanagement/views.py:414
#: allianceauth/groupmanagement/views.py:397
msgid "You already have a pending leave request for that group."
msgstr "你已经有了该组的未决离开请求"
#: allianceauth/groupmanagement/views.py:410
#, python-format
msgid "Applied to leave group %(group)s."
msgstr "已经离开群组%(group)s"
@@ -1130,10 +1175,6 @@ msgstr "对搞事时间节点%(opname)s的修改保存了朝令夕改你是
msgid "Permissions Audit"
msgstr "放行记录审计"
#: allianceauth/permissions_tool/templates/permissions_tool/audit.html:13
msgid "Back"
msgstr "返回"
#: allianceauth/permissions_tool/templates/permissions_tool/audit.html:22
msgid "User / Character"
msgstr "用户/角色"
@@ -1175,15 +1216,22 @@ msgstr "操作类型"
msgid "Users"
msgstr "用户"
#: allianceauth/permissions_tool/templates/permissions_tool/overview.html:40
#: allianceauth/templates/allianceauth/side-menu.html:15
msgid "Groups"
msgstr "群组"
#: allianceauth/permissions_tool/templates/permissions_tool/overview.html:43
msgid "States"
msgstr "声望"
#: allianceauth/services/abstract.py:72
msgid "That service account already exists"
msgstr "该服务账户仍然存在"
#: allianceauth/services/abstract.py:104
msgid "Successfully set your {} password"
msgstr "成功修改了你的[]密码"
#: allianceauth/services/auth_hooks.py:11
msgid "Services"
msgstr "服务"
#: allianceauth/services/forms.py:6
msgid "Name of Fleet:"
msgstr "舰队名称"
@@ -1244,19 +1292,111 @@ msgstr "密码"
msgid "Password must be at least 8 characters long."
msgstr "密码至少要有8个字符啊你也太不注重安全啦"
#: allianceauth/services/modules/discord/templates/services/discord/discord_service_ctrl.html:23
#: allianceauth/services/modules/discord/models.py:224
msgid "Discord Account Disabled"
msgstr ""
#: allianceauth/services/modules/discord/models.py:226
msgid ""
"Your Discord account was disabeled automatically by Auth. If you think this "
"was a mistake, please contact an admin."
msgstr ""
#: allianceauth/services/modules/discord/templates/services/discord/discord_service_ctrl.html:18
msgid "Join the Discord server"
msgstr ""
#: allianceauth/services/modules/discord/templates/services/discord/discord_service_ctrl.html:22
msgid "Leave- and rejoin the Discord Server (Reset)"
msgstr ""
#: allianceauth/services/modules/discord/templates/services/discord/discord_service_ctrl.html:25
msgid "Leave the Discord server"
msgstr ""
#: allianceauth/services/modules/discord/templates/services/discord/discord_service_ctrl.html:32
msgid "Link Discord Server"
msgstr "链接到Discord服务器"
#: allianceauth/services/modules/openfire/forms.py:7
msgid "Message"
msgstr "消息"
#: allianceauth/services/modules/discord/views.py:30
msgid "Deactivated Discord account."
msgstr "停用Discord账户"
#: allianceauth/services/modules/discord/views.py:36
#: allianceauth/services/modules/discord/views.py:59
msgid "An error occurred while processing your Discord account."
msgstr "在处理你的Discord账户时出错。"
#: allianceauth/services/modules/discord/views.py:102
msgid "Your Discord account has been successfully activated."
msgstr ""
#: allianceauth/services/modules/discord/views.py:108
msgid ""
"An error occurred while trying to activate your Discord account. Please try "
"again."
msgstr ""
#: allianceauth/services/modules/discourse/views.py:37
msgid "You are not authorized to access Discourse."
msgstr "你无权访问Discourse"
#: allianceauth/services/modules/discourse/views.py:42
msgid "You must have a main character set to access Discourse."
msgstr "你必须得有一个主要角色来访问Discourse"
#: allianceauth/services/modules/discourse/views.py:52
msgid ""
"No SSO payload or signature. Please contact support if this problem "
"persists."
msgstr "没有在Seat上检测到SSO。如果该问题依然存在请联系技术支持"
#: allianceauth/services/modules/discourse/views.py:62
#: allianceauth/services/modules/discourse/views.py:70
msgid "Invalid payload. Please contact support if this problem persists."
msgstr "无效的SSO验证。如果该问题依然存在请联系技术支持。"
#: allianceauth/services/modules/ips4/views.py:31
msgid "Activated IPSuite4 account."
msgstr "完成激活IPSuite4账户"
#: allianceauth/services/modules/ips4/views.py:40
#: allianceauth/services/modules/ips4/views.py:62
#: allianceauth/services/modules/ips4/views.py:83
#: allianceauth/services/modules/ips4/views.py:103
msgid "An error occurred while processing your IPSuite4 account."
msgstr "在处理你的IPSuite4账户时出错"
#: allianceauth/services/modules/ips4/views.py:53
msgid "Reset IPSuite4 password."
msgstr "重置IPSuite4密码"
#: allianceauth/services/modules/ips4/views.py:80
msgid "Set IPSuite4 password."
msgstr "修改IPSuite4密码"
#: allianceauth/services/modules/ips4/views.py:100
msgid "Deactivated IPSuite4 account."
msgstr "停用IPSuite4账户"
#: allianceauth/services/modules/openfire/auth_hooks.py:26
msgid "Jabber"
msgstr "Jabber"
#: allianceauth/services/modules/openfire/auth_hooks.py:78
#: allianceauth/services/modules/openfire/templates/services/openfire/broadcast.html:6
#: allianceauth/services/modules/openfire/templates/services/openfire/broadcast.html:11
msgid "Jabber Broadcast"
msgstr "Jabber广播"
#: allianceauth/services/modules/openfire/auth_hooks.py:91
msgid "Fleet Broadcast Formatter"
msgstr "舰队广播设置"
#: allianceauth/services/modules/openfire/forms.py:7
msgid "Message"
msgstr "消息"
#: allianceauth/services/modules/openfire/templates/services/openfire/broadcast.html:17
msgid "Broadcast Sent!!"
msgstr "广播出去了!"
@@ -1265,6 +1405,76 @@ msgstr "广播出去了!"
msgid "Broadcast"
msgstr "广播"
#: allianceauth/services/modules/openfire/views.py:35
msgid "Activated jabber account."
msgstr "成功激活jabber账户"
#: allianceauth/services/modules/openfire/views.py:44
#: allianceauth/services/modules/openfire/views.py:57
#: allianceauth/services/modules/openfire/views.py:78
#: allianceauth/services/modules/openfire/views.py:151
msgid "An error occurred while processing your jabber account."
msgstr "在处理你的jabber账户时出错"
#: allianceauth/services/modules/openfire/views.py:70
msgid "Reset jabber password."
msgstr "重置jabber密码"
#: allianceauth/services/modules/openfire/views.py:119
#, python-format
msgid "Sent jabber broadcast to %s"
msgstr "已经将jabber广播送到了%s"
#: allianceauth/services/modules/openfire/views.py:148
msgid "Set jabber password."
msgstr "修改jabber密码"
#: allianceauth/services/modules/phpbb3/views.py:34
msgid "Activated forum account."
msgstr "成功激活论坛账户"
#: allianceauth/services/modules/phpbb3/views.py:43
#: allianceauth/services/modules/phpbb3/views.py:57
#: allianceauth/services/modules/phpbb3/views.py:80
#: allianceauth/services/modules/phpbb3/views.py:103
msgid "An error occurred while processing your forum account."
msgstr "在处理你的论坛账户时发生了一个错误"
#: allianceauth/services/modules/phpbb3/views.py:54
msgid "Deactivated forum account."
msgstr "停用论坛账户"
#: allianceauth/services/modules/phpbb3/views.py:71
msgid "Reset forum password."
msgstr "重置论坛密码"
#: allianceauth/services/modules/phpbb3/views.py:100
msgid "Set forum password."
msgstr "修改论坛密码"
#: allianceauth/services/modules/smf/views.py:34
msgid "Activated SMF account."
msgstr "成功激活SMF论坛账户"
#: allianceauth/services/modules/smf/views.py:43
#: allianceauth/services/modules/smf/views.py:58
#: allianceauth/services/modules/smf/views.py:80
#: allianceauth/services/modules/smf/views.py:103
msgid "An error occurred while processing your SMF account."
msgstr "在处理你的SMF论坛账户时发生了一个错误"
#: allianceauth/services/modules/smf/views.py:55
msgid "Deactivated SMF account."
msgstr "停用SMF论坛账户"
#: allianceauth/services/modules/smf/views.py:72
msgid "Reset SMF password."
msgstr "重置SMF密码"
#: allianceauth/services/modules/smf/views.py:100
msgid "Set SMF password."
msgstr "修改SMF论坛密码"
#: allianceauth/services/modules/teamspeak3/forms.py:14
#, python-format
msgid "Unable to locate user %s on server"
@@ -1288,6 +1498,47 @@ msgstr "加入服务器"
msgid "Continue"
msgstr "下一个"
#: allianceauth/services/modules/teamspeak3/views.py:34
msgid "Activated TeamSpeak3 account."
msgstr "成功激活TeamSpeak3账户"
#: allianceauth/services/modules/teamspeak3/views.py:37
#: allianceauth/services/modules/teamspeak3/views.py:74
#: allianceauth/services/modules/teamspeak3/views.py:100
msgid "An error occurred while processing your TeamSpeak3 account."
msgstr "在处理你的TeamSpeak3账户时发生了错误"
#: allianceauth/services/modules/teamspeak3/views.py:71
msgid "Deactivated TeamSpeak3 account."
msgstr "停用TeamSpeak3账户"
#: allianceauth/services/modules/teamspeak3/views.py:97
msgid "Reset TeamSpeak3 permission key."
msgstr "重置TeamSpeak3授权秘钥"
#: allianceauth/services/modules/xenforo/views.py:30
msgid "Activated XenForo account."
msgstr "成功激活XenForo账户"
#: allianceauth/services/modules/xenforo/views.py:40
#: allianceauth/services/modules/xenforo/views.py:52
#: allianceauth/services/modules/xenforo/views.py:73
#: allianceauth/services/modules/xenforo/views.py:94
msgid "An error occurred while processing your XenForo account."
msgstr "在处理你的XenForo账户时发生了一个错误"
#: allianceauth/services/modules/xenforo/views.py:50
msgid "Deactivated XenForo account."
msgstr "停用XenForo论坛账户"
#: allianceauth/services/modules/xenforo/views.py:65
msgid "Reset XenForo account password."
msgstr "重置XenForo密码"
#: allianceauth/services/modules/xenforo/views.py:91
msgid "Changed XenForo password."
msgstr "修改XenForo密码"
#: allianceauth/services/templates/services/fleetformattertool.html:6
msgid "Fleet Formatter Tool"
msgstr "起队工具"
@@ -1638,43 +1889,35 @@ msgid "Current"
msgstr "当前版本"
#: allianceauth/templates/allianceauth/admin-status/overview.html:40
msgid "Latest Major"
msgstr "最新主版本号"
msgid "Latest Stable"
msgstr ""
#: allianceauth/templates/allianceauth/admin-status/overview.html:46
#: allianceauth/templates/allianceauth/admin-status/overview.html:56
#: allianceauth/templates/allianceauth/admin-status/overview.html:66
msgid "Update available"
msgstr "有更新!"
#: allianceauth/templates/allianceauth/admin-status/overview.html:50
msgid "Latest Minor"
msgstr "最新小版本号"
#: allianceauth/templates/allianceauth/admin-status/overview.html:51
msgid "Latest Pre-Release"
msgstr ""
#: allianceauth/templates/allianceauth/admin-status/overview.html:60
msgid "Latest Patch"
msgstr "最新补丁版本"
#: allianceauth/templates/allianceauth/admin-status/overview.html:57
msgid "Pre-Release available"
msgstr ""
#: allianceauth/templates/allianceauth/admin-status/overview.html:73
#: allianceauth/templates/allianceauth/admin-status/overview.html:65
msgid "Task Queue"
msgstr "任务队列"
#: allianceauth/templates/allianceauth/admin-status/overview.html:90
#: allianceauth/templates/allianceauth/admin-status/overview.html:82
msgid "Error retrieving task queue length"
msgstr "获取任务队列长度的时候出错啦!"
#: allianceauth/templates/allianceauth/admin-status/overview.html:92
#: allianceauth/templates/allianceauth/admin-status/overview.html:84
#, python-format
msgid "%(tasks)s task"
msgid_plural "%(tasks)s tasks"
msgstr[0] "%(tasks)s个任务"
#: allianceauth/templates/allianceauth/help.html:4
#: allianceauth/templates/allianceauth/help.html:9
#: allianceauth/templates/allianceauth/side-menu.html:34
msgid "Help"
msgstr "帮助"
#: allianceauth/templates/allianceauth/night-toggle.html:3
msgid "Night"
msgstr "暗色皮肤"

View File

@@ -1,5 +1,11 @@
from .models import Notification
from django.core.cache import cache
def user_notification_count(request):
return {'notifications': len(Notification.objects.filter(user__id=request.user.id).filter(viewed=False))}
user_id = request.user.id
notification_count = cache.get("u-note:{}".format(user_id), -1)
if notification_count<0:
notification_count = Notification.objects.filter(user__id=user_id).filter(viewed=False).count()
cache.set("u-note:{}".format(user_id),notification_count,5)
return {'notifications': notification_count}

View File

@@ -1 +0,0 @@
# Create your tests here.

View File

@@ -0,0 +1,76 @@
from unittest import mock
from django.test import TestCase
from allianceauth.notifications.context_processors import user_notification_count
from allianceauth.tests.auth_utils import AuthUtils
from django.core.cache import cache
from allianceauth.notifications.models import Notification
class TestNotificationCount(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = AuthUtils.create_user('magic_mike')
AuthUtils.add_main_character(cls.user, 'Magic Mike', '1', corp_id='2', corp_name='Pole Riders', corp_ticker='PRIDE', alliance_id='3', alliance_name='RIDERS')
cls.user.profile.refresh_from_db()
### test notifications for mike
Notification.objects.all().delete()
Notification.objects.create(user=cls.user,
level="INFO",
title="Job 1 Failed",
message="Because it was broken",
viewed=True)
Notification.objects.create(user=cls.user,
level="INFO",
title="Job 2 Failed",
message="Because it was broken")
Notification.objects.create(user=cls.user,
level="INFO",
title="Job 3 Failed",
message="Because it was broken")
Notification.objects.create(user=cls.user,
level="INFO",
title="Job 4 Failed",
message="Because it was broken")
Notification.objects.create(user=cls.user,
level="INFO",
title="Job 5 Failed",
message="Because it was broken")
Notification.objects.create(user=cls.user,
level="INFO",
title="Job 6 Failed",
message="Because it was broken")
cls.user2 = AuthUtils.create_user('teh_kid')
AuthUtils.add_main_character(cls.user, 'The Kid', '2', corp_id='2', corp_name='Pole Riders', corp_ticker='PRIDE', alliance_id='3', alliance_name='RIDERS')
cls.user2.profile.refresh_from_db()
# Noitification for kid
Notification.objects.create(user=cls.user2,
level="INFO",
title="Job 6 Failed",
message="Because it was broken")
def test_no_cache(self):
mock_req = mock.MagicMock()
mock_req.user.id = self.user.id
cache.delete("u-note:{}".format(self.user.id)) # force the db to be hit
context_dict = user_notification_count(mock_req)
self.assertIsInstance(context_dict, dict)
self.assertEqual(context_dict.get('notifications'), 5) # 5 only
@mock.patch('allianceauth.notifications.models.Notification.objects')
def test_cache(self, mock_foo):
mock_foo.filter.return_value = mock_foo
mock_foo.count.return_value = 5
mock_req = mock.MagicMock()
mock_req.user.id = self.user.id
cache.set("u-note:{}".format(self.user.id),10,5)
context_dict = user_notification_count(mock_req)
self.assertIsInstance(context_dict, dict)
self.assertEqual(context_dict.get('notifications'), 10) # cached value
self.assertEqual(mock_foo.called, 0) # ensure the DB was not hit

View File

@@ -7,7 +7,7 @@ from . import urls
class OpTimerboardMenu(MenuItemHook):
def __init__(self):
MenuItemHook.__init__(self, _('Fleet Operations'),
'fa fa-exclamation fa-fw',
'fas fa-exclamation fa-fw',
'optimer:view',
navactive=['optimer:'])

View File

@@ -36,9 +36,15 @@
{% block extra_script %}
$('#id_start').datetimepicker({
lang: '{{ LANGUAGE_CODE }}',
maskInput: true,
format: 'Y-m-d H:i',minDate:0
setlocale: '{{ LANGUAGE_CODE }}',
{% if NIGHT_MODE %}
theme: 'dark',
{% else %}
theme: 'default',
{% endif %}
mask: true,
format: 'Y-m-d H:i',
minDate: 0
});
{% endblock extra_script %}

View File

@@ -41,7 +41,7 @@
{% include 'bundles/moment-js.html' with locale=True %}
<script src="{% static 'js/timers.js' %}"></script>
<script type="text/javascript">
<script type="application/javascript">
// Data
var timers = [
{% for op in optimer %}
@@ -53,7 +53,7 @@
{% endfor %}
];
</script>
<script type="text/javascript">
<script type="application/javascript">
timedUpdate();
setAllLocalTimes();

View File

@@ -44,9 +44,15 @@
{% block extra_script %}
$('#id_start').datetimepicker({
lang: '{{ LANGUAGE_CODE }}',
maskInput: true,
format: 'Y-m-d H:i',minDate:0
setlocale: '{{ LANGUAGE_CODE }}',
{% if NIGHT_MODE %}
theme: 'dark',
{% else %}
theme: 'default',
{% endif %}
mask: true,
format: 'Y-m-d H:i',
minDate: 0
});
{% endblock extra_script %}

View File

@@ -8,7 +8,7 @@ class PermissionsTool(MenuItemHook):
def __init__(self):
MenuItemHook.__init__(self,
'Permissions Audit',
'fa fa-key fa-id-card',
'fas fa-id-card fa-fw',
'permissions_tool:overview',
order=400,
navactive=['permissions_tool:'])

View File

@@ -47,7 +47,7 @@
{% block extra_javascript %}
{% include 'bundles/datatables-js.html' %}
<script type="text/javascript" src="{% static 'js/filterDropDown/filterDropDown.min.js' %}"></script>
<script type="application/javascript" src="{% static 'js/filterDropDown/filterDropDown.min.js' %}"></script>
{% endblock %}
{% block extra_css %}

View File

@@ -80,7 +80,7 @@
{% block extra_javascript %}
{% include 'bundles/datatables-js.html' %}
<script type="text/javascript" src="{% static 'js/filterDropDown/filterDropDown.min.js' %}"></script>
<script type="application/javascript" src="{% static 'js/filterDropDown/filterDropDown.min.js' %}"></script>
{% endblock %}
{% block extra_css %}

View File

@@ -41,7 +41,7 @@ class PermissionsToolViewsTestCase(WebTest):
response_content = response.content.decode('utf-8')
self.assertInHTML('<li><a class="active" href="/permissions/overview/">'
'<i class="fa fa-key fa-id-card"></i> Permissions Audit</a></li>', response_content)
'<i class="fas fa-id-card fa-fw"></i> Permissions Audit</a></li>', response_content)
def test_permissions_overview(self):
self.app.set_user(self.member)

View File

@@ -103,8 +103,7 @@ TEMPLATES = [
'django.template.context_processors.media',
'django.template.context_processors.static',
'django.template.context_processors.tz',
'allianceauth.notifications.context_processors.user_notification_count',
'allianceauth.groupmanagement.context_processors.can_manage_groups',
'allianceauth.notifications.context_processors.user_notification_count',
'allianceauth.context_processors.auth_settings',
],
},
@@ -221,7 +220,7 @@ LOGGING = {
'backupCount': 5, # edit this line to change number of log backups
},
'extension_file': {
'level': 'DEBUG',
'level': 'INFO',
'class': 'logging.handlers.RotatingFileHandler',
'filename': os.path.join(BASE_DIR, 'log/extensions.log'),
'formatter': 'verbose',

View File

@@ -22,6 +22,10 @@ INSTALLED_APPS += [
]
# To change the logging level for extensions, uncomment the following line.
# LOGGING['handlers']['extension_file']['level'] = 'DEBUG'
# Enter credentials to use MySQL/MariaDB. Comment out to use sqlite3
DATABASES['default'] = {
'ENGINE': 'django.db.backends.mysql',

View File

@@ -1,14 +1,14 @@
from django import forms
from django.contrib import admin
from django.db.models.functions import Lower
from django.urls import reverse
from django.utils.html import format_html
from allianceauth import hooks
from allianceauth.eveonline.models import EveCharacter
from allianceauth.authentication.admin import user_profile_pic, \
user_username, user_main_organization, MainCorporationsFilter,\
from allianceauth.authentication.admin import (
user_profile_pic,
user_username,
user_main_organization,
MainCorporationsFilter,
MainAllianceFilter
)
from .models import NameFormatConfig
@@ -25,16 +25,24 @@ class ServicesUserAdmin(admin.ModelAdmin):
list_select_related = True
list_display = (
user_profile_pic,
user_username,
user_username,
'_state',
user_main_organization,
'_date_joined'
)
list_filter = (
'user__profile__state',
MainCorporationsFilter,
MainAllianceFilter,
'user__date_joined'
'user__date_joined',
)
def _state(self, obj):
return obj.user.profile.state.name
_state.short_description = 'state'
_state.admin_order_field = 'user__profile__state__name'
def _date_joined(self, obj):
return obj.user.date_joined
@@ -45,7 +53,8 @@ class ServicesUserAdmin(admin.ModelAdmin):
class NameFormatConfigForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super(NameFormatConfigForm, self).__init__(*args, **kwargs)
SERVICE_CHOICES = [(s.name, s.name) for h in hooks.get_hooks('services_hook') for s in [h()]]
SERVICE_CHOICES = \
[(s.name, s.name) for h in hooks.get_hooks('services_hook') for s in [h()]]
if self.instance.id:
current_choice = (self.instance.service_name, self.instance.service_name)
if current_choice not in SERVICE_CHOICES:

View File

@@ -9,7 +9,7 @@ class Services(MenuItemHook):
def __init__(self):
MenuItemHook.__init__(self,
_('Services'),
'fa fa-cogs fa-fw',
'fas fa-cogs fa-fw',
'services:services', 100)
def render(self, request):

View File

@@ -14,20 +14,22 @@ def get_extension_logger(name):
Takes the name of a plugin/extension and generates a child logger of the extensions logger
to be used by the extension to log events to the extensions logger.
The logging level is decided by whether or not DEBUG is set to true in the project settings. If
DEBUG is set to false, then the logging level is set to INFO.
The logging level is determined by the level defined for the parent logger.
:param: name: the name of the extension doing the logging
:return: an extensions child logger
"""
if not isinstance(name, str):
raise TypeError(f"get_extension_logger takes an argument of type string."
f"Instead received argument of type {type(name).__name__}.")
import logging
from django.conf import settings
parent_logger = logging.getLogger('extensions')
logger = logging.getLogger('extensions.' + name)
logger.name = name
logger.level = logging.INFO
if settings.DEBUG:
logger.level = logging.DEBUG
logger.level = parent_logger.level
return logger
@@ -137,6 +139,11 @@ class MenuItemHook:
self.url_name = url_name
self.template = 'public/menuitem.html'
self.order = order if order is not None else 9999
# count is an integer shown next to the menu item as badge when count != None
# apps need to set the count in their child class, e.g. in render() method
self.count = None
navactive = navactive or []
navactive.append(url_name)
self.navactive = navactive

View File

@@ -1 +1,3 @@
default_app_config = 'allianceauth.services.modules.discord.apps.DiscordServiceConfig'
default_app_config = 'allianceauth.services.modules.discord.apps.DiscordServiceConfig' # noqa
__title__ = 'Discord Service'

View File

@@ -1,13 +1,22 @@
import logging
from django.contrib import admin
from .models import DiscordUser
from . import __title__
from ...admin import ServicesUserAdmin
from .models import DiscordUser
from .utils import LoggerAddTag
logger = LoggerAddTag(logging.getLogger(__name__), __title__)
@admin.register(DiscordUser)
class DiscordUserAdmin(ServicesUserAdmin):
list_display = ServicesUserAdmin.list_display + ('_uid',)
search_fields = ServicesUserAdmin.search_fields + ('uid', )
search_fields = ServicesUserAdmin.search_fields + ('uid', 'username')
list_display = ServicesUserAdmin.list_display + ('activated', '_username', '_uid')
list_filter = ServicesUserAdmin.list_filter + ('activated',)
ordering = ('-activated',)
def _uid(self, obj):
return obj.uid
@@ -15,3 +24,11 @@ class DiscordUserAdmin(ServicesUserAdmin):
_uid.short_description = 'Discord ID (UID)'
_uid.admin_order_field = 'uid'
def _username(self, obj):
if obj.username and obj.discriminator:
return f'{obj.username}#{obj.discriminator}'
else:
return ''
_username.short_description = 'Discord Username'
_username.admin_order_field = 'username'

View File

@@ -0,0 +1,17 @@
from .utils import clean_setting
DISCORD_APP_ID = clean_setting('DISCORD_APP_ID', '')
DISCORD_APP_SECRET = clean_setting('DISCORD_APP_SECRET', '')
DISCORD_BOT_TOKEN = clean_setting('DISCORD_BOT_TOKEN', '')
DISCORD_CALLBACK_URL = clean_setting('DISCORD_CALLBACK_URL', '')
DISCORD_GUILD_ID = clean_setting('DISCORD_GUILD_ID', '')
# max retries of tasks after an error occurred
DISCORD_TASKS_MAX_RETRIES = clean_setting('DISCORD_TASKS_MAX_RETRIES', 3)
# Pause in seconds until next retry for tasks after the API returned an error
DISCORD_TASKS_RETRY_PAUSE = clean_setting('DISCORD_TASKS_RETRY_PAUSE', 60)
# automatically sync Discord users names to user's main character name when created
DISCORD_SYNC_NAMES = clean_setting('DISCORD_SYNC_NAMES', False)

View File

@@ -1,17 +1,26 @@
import logging
from django.contrib.auth.models import User
from django.template.loader import render_to_string
from django.conf import settings
from allianceauth import hooks
from allianceauth.services.hooks import ServicesHook
from .tasks import DiscordTasks
from .urls import urlpatterns
logger = logging.getLogger(__name__)
from .models import DiscordUser
from .urls import urlpatterns
from .utils import LoggerAddTag
from . import tasks, __title__
logger = LoggerAddTag(logging.getLogger(__name__), __title__)
# Default priority for single tasks like update group and sync nickname
SINGLE_TASK_PRIORITY = 3
class DiscordService(ServicesHook):
"""Service for managing a Discord server with Auth"""
def __init__(self):
ServicesHook.__init__(self)
self.urlpatterns = urlpatterns
@@ -20,36 +29,105 @@ class DiscordService(ServicesHook):
self.access_perm = 'discord.access_discord'
self.name_format = '{character_name}'
def delete_user(self, user, notify_user=False):
logger.debug('Deleting user %s %s account' % (user, self.name))
return DiscordTasks.delete_user(user, notify_user=notify_user)
def delete_user(self, user: User, notify_user: bool = False) -> None:
if self.user_has_account(user):
logger.debug('Deleting user %s %s account', user, self.name)
tasks.delete_user.apply_async(
kwargs={'user_pk': user.pk, 'notify_user': notify_user},
priority=SINGLE_TASK_PRIORITY
)
def render_services_ctrl(self, request):
if self.user_has_account(request.user):
user_has_account = True
username = request.user.discord.username
discriminator = request.user.discord.discriminator
if username and discriminator:
discord_username = f'{username}#{discriminator}'
else:
discord_username = ''
else:
discord_username = ''
user_has_account = False
def update_groups(self, user):
logger.debug('Processing %s groups for %s' % (self.name, user))
if DiscordTasks.has_account(user):
DiscordTasks.update_groups.delay(user.pk)
def validate_user(self, user):
logger.debug('Validating user %s %s account' % (user, self.name))
if DiscordTasks.has_account(user) and not self.service_active_for_user(user):
self.delete_user(user, notify_user=True)
def sync_nickname(self, user):
logger.debug('Syncing %s nickname for user %s' % (self.name, user))
DiscordTasks.update_nickname.apply_async(args=[user.pk], countdown=5)
def update_all_groups(self):
logger.debug('Update all %s groups called' % self.name)
DiscordTasks.update_all_groups.delay()
return render_to_string(
self.service_ctrl_template,
{
'server_name': DiscordUser.objects.server_name(),
'user_has_account': user_has_account,
'discord_username': discord_username
},
request=request
)
def service_active_for_user(self, user):
return user.has_perm(self.access_perm)
has_perms = user.has_perm(self.access_perm)
logger.debug("User %s has service permission: %s", user, has_perms)
return has_perms
def render_services_ctrl(self, request):
return render_to_string(self.service_ctrl_template, {
'discord_uid': request.user.discord.uid if DiscordTasks.has_account(request.user) else None,
'DISCORD_SERVER_ID': getattr(settings, 'DISCORD_GUILD_ID', ''),
}, request=request)
def sync_nickname(self, user):
logger.debug('Syncing %s nickname for user %s', self.name, user)
if self.user_has_account(user):
tasks.update_nickname.apply_async(
kwargs={
'user_pk': user.pk,
# since the new nickname is not yet in the DB we need to
# provide it manually to the task
'nickname': DiscordUser.objects.user_formatted_nick(user)
},
priority=SINGLE_TASK_PRIORITY
)
def sync_nicknames_bulk(self, users: list):
"""Sync nickname for a list of users in bulk.
Preferred over sync_nickname(), because it will not break the rate limit
"""
logger.debug(
'Syncing %s nicknames in bulk for %d users', self.name, len(users)
)
user_pks = [user.pk for user in users]
tasks.update_nicknames_bulk.delay(user_pks)
def update_all_groups(self):
logger.debug('Update all %s groups called', self.name)
tasks.update_all_groups.delay()
def update_groups(self, user):
logger.debug('Processing %s groups for %s', self.name, user)
if self.user_has_account(user):
tasks.update_groups.apply_async(
kwargs={
'user_pk': user.pk,
# since state changes may not yet be in the DB we need to
# provide the new state name manually to the task
'state_name': user.profile.state.name
},
priority=SINGLE_TASK_PRIORITY
)
def update_groups_bulk(self, users: list):
"""Updates groups for a list of users in bulk.
Preferred over update_groups(), because it will not break the rate limit
"""
logger.debug(
'Processing %s groups in bulk for %d users', self.name, len(users)
)
user_pks = [user.pk for user in users]
tasks.update_groups_bulk.delay(user_pks)
@staticmethod
def user_has_account(user: User) -> bool:
result = DiscordUser.objects.user_has_account(user)
if result:
logger.debug('User %s has a Discord account', user)
else:
logger.debug('User %s does not have a Discord account', user)
return result
def validate_user(self, user):
logger.debug('Validating user %s %s account', user, self.name)
if self.user_has_account(user) and not self.service_active_for_user(user):
self.delete_user(user, notify_user=True)
@hooks.register('services_hook')

View File

@@ -0,0 +1,3 @@
from .client import DiscordClient # noqa
from .exceptions import DiscordApiBackoff # noqa
from .helpers import DiscordRoles # noqa

View File

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

View File

@@ -0,0 +1,725 @@
from hashlib import md5
import json
import logging
from time import sleep
from urllib.parse import urljoin
from uuid import uuid1
from redis import Redis
import requests
from django.core.cache import caches
from allianceauth import __title__ as AUTH_TITLE, __url__, __version__
from .. import __title__
from .app_settings import (
DISCORD_API_BASE_URL,
DISCORD_API_TIMEOUT_CONNECT,
DISCORD_API_TIMEOUT_READ,
DISCORD_DISABLE_ROLE_CREATION,
DISCORD_GUILD_NAME_CACHE_MAX_AGE,
DISCORD_OAUTH_BASE_URL,
DISCORD_OAUTH_TOKEN_URL,
DISCORD_ROLES_CACHE_MAX_AGE,
)
from .exceptions import DiscordRateLimitExhausted, DiscordTooManyRequestsError
from .helpers import DiscordRoles
from ..utils import LoggerAddTag
logger = LoggerAddTag(logging.getLogger(__name__), __title__)
# max requests that can be executed until reset
RATE_LIMIT_MAX_REQUESTS = 5
# Time until remaining requests are reset
RATE_LIMIT_RESETS_AFTER = 5000
# Delay used for API backoff in case no info returned from API on 429s
DEFAULT_BACKOFF_DELAY = 5000
# additional duration to compensate for potential clock discrepancies
# with the Discord server
DURATION_CONTINGENCY = 500
# Client will do a blocking wait rather than throwing a backoff exception if the
# time until next reset is below this threshold
WAIT_THRESHOLD = 250
# Minimum wait duration when doing a blocking wait
MINIMUM_BLOCKING_WAIT = 50
# If the rate limit resets soon we will wait it out and then retry to
# either get a remaining request from our cached counter
# or again wait out a short reset time and retry again.
# This could happen several times within a high concurrency situation,
# but must fail after x tries to avoid an infinite loop
RATE_LIMIT_RETRIES = 1000
class DiscordClient:
"""This class provides a web client for interacting with the Discord API
The client has rate limiting that supports concurrency.
This means it is able to ensure the API rate limit is not violated,
even when used concurrently, e.g. with multiple parallel celery tasks.
In addition the client support proper API backoff.
Synchronization of rate limit infos accross multiple processes
is implemented with Redis and thus requires Redis as Django cache backend.
All durations are in milliseconds.
"""
OAUTH_BASE_URL = DISCORD_OAUTH_BASE_URL
OAUTH_TOKEN_URL = DISCORD_OAUTH_TOKEN_URL
_KEY_GLOBAL_BACKOFF_UNTIL = 'DISCORD_GLOBAL_BACKOFF_UNTIL'
_KEY_GLOBAL_RATE_LIMIT_REMAINING = 'DISCORD_GLOBAL_RATE_LIMIT_REMAINING'
_KEYPREFIX_GUILD_NAME = 'DISCORD_GUILD_NAME'
_KEYPREFIX_GUILD_ROLES = 'DISCORD_GUILD_ROLES'
_KEYPREFIX_ROLE_NAME = 'DISCORD_ROLE_NAME'
_NICK_MAX_CHARS = 32
_HTTP_STATUS_CODE_NOT_FOUND = 404
_HTTP_STATUS_CODE_RATE_LIMITED = 429
_DISCORD_STATUS_CODE_UNKNOWN_MEMBER = 10007
def __init__(
self,
access_token: str,
redis: Redis = None,
is_rate_limited: bool = True
) -> None:
"""
Params:
- access_token: Discord access token used to authenticate all calls to the API
- redis: Redis instance to be used.
- is_rate_limited: Set to False to run of rate limiting (use with care)
If not specified will try to use the Redis instance
from the default Django cache backend.
"""
self._access_token = str(access_token)
self._is_rate_limited = bool(is_rate_limited)
if not redis:
default_cache = caches['default']
self._redis = default_cache.get_master_client()
if not isinstance(self._redis, Redis):
raise RuntimeError(
'This class requires a Redis client, but none was provided '
'and the default Django cache backend is not Redis either.'
)
else:
self._redis = redis
lua_1 = """
if redis.call("exists", KEYS[1]) == 0 then
redis.call("set", KEYS[1], ARGV[1], 'px', ARGV[2])
end
return redis.call("decr", KEYS[1])
"""
self.__redis_script_decr_or_set = self._redis.register_script(lua_1)
lua_2 = """
local current_px = tonumber(redis.call("pttl", KEYS[1]))
if current_px < tonumber(ARGV[2]) then
return redis.call("set", KEYS[1], ARGV[1], 'px', ARGV[2])
else
return nil
end
"""
self.__redis_script_set_longer = self._redis.register_script(lua_2)
@property
def access_token(self):
return self._access_token
@property
def is_rate_limited(self):
return self._is_rate_limited
def __repr__(self):
return f'{type(self).__name__}(access_token=...{self.access_token[-5:]})'
def _redis_decr_or_set(self, name: str, value: str, px: int) -> bool:
"""decreases the key value if it exists and returns the result
else sets the key
Implemented as Lua script to ensure atomicity.
"""
return self.__redis_script_decr_or_set(
keys=[str(name)], args=[str(value), int(px)]
)
def _redis_set_if_longer(self, name: str, value: str, px: int) -> bool:
"""like set, but only goes through if either key doesn't exist
or px would be extended.
Implemented as Lua script to ensure atomicity.
"""
return self.__redis_script_set_longer(
keys=[str(name)], args=[str(value), int(px)]
)
# users
def current_user(self) -> dict:
"""returns the user belonging to the current access_token"""
authorization = f'Bearer {self.access_token}'
r = self._api_request(
method='get', route='users/@me', authorization=authorization
)
return r.json()
# guild
def guild_infos(self, guild_id: int) -> dict:
"""Returns all basic infos about this guild"""
route = f"guilds/{guild_id}"
r = self._api_request(method='get', route=route)
return r.json()
def guild_name(self, guild_id: int, use_cache: bool = True) -> str:
"""returns the name of this guild (cached)
or an empty string if something went wrong
Params:
- guild_id: ID of current guild
- use_cache: When set to False will force an API call to get the server name
"""
key_name = self._guild_name_cache_key(guild_id)
if use_cache:
guild_name = self._redis_decode(self._redis.get(key_name))
else:
guild_name = None
if not guild_name:
guild_infos = self.guild_infos(guild_id)
if 'name' in guild_infos:
guild_name = guild_infos['name']
self._redis.set(
name=key_name,
value=guild_name,
ex=DISCORD_GUILD_NAME_CACHE_MAX_AGE
)
else:
guild_name = ''
return guild_name
@classmethod
def _guild_name_cache_key(cls, guild_id: int) -> str:
"""Returns key for accessing role given by name in the role cache"""
gen_key = DiscordClient._generate_hash(f'{guild_id}')
return f'{cls._KEYPREFIX_GUILD_NAME}__{gen_key}'
# guild roles
def guild_roles(self, guild_id: int, use_cache: bool = True) -> list:
"""Returns the list of all roles for this guild
If use_cache is set to False it will always hit the API to retrieve
fresh data and update the cache
"""
cache_key = self._guild_roles_cache_key(guild_id)
if use_cache:
roles_raw = self._redis.get(name=cache_key)
if roles_raw:
logger.debug('Returning roles for guild %s from cache', guild_id)
return json.loads(self._redis_decode(roles_raw))
else:
logger.debug('No roles for guild %s in cache', guild_id)
route = f"guilds/{guild_id}/roles"
r = self._api_request(method='get', route=route)
roles = r.json()
if roles and isinstance(roles, list):
self._redis.set(
name=cache_key,
value=json.dumps(roles),
ex=DISCORD_ROLES_CACHE_MAX_AGE
)
return roles
def create_guild_role(self, guild_id: int, role_name: str, **kwargs) -> dict:
"""Create a new guild role with the given name.
See official documentation for additional optional parameters.
Note that Discord allows the creation of multiple roles with the same name,
so to avoid duplicates it's important to check existing roles
before creating new one
returns a new role dict on success
"""
route = f"guilds/{guild_id}/roles"
data = {'name': DiscordRoles.sanitize_role_name(role_name)}
data.update(kwargs)
r = self._api_request(method='post', route=route, data=data)
role = r.json()
if role:
self._invalidate_guild_roles_cache(guild_id)
return role
def delete_guild_role(self, guild_id: int, role_id: int) -> bool:
"""Deletes a guild role"""
route = f"guilds/{guild_id}/roles/{role_id}"
r = self._api_request(method='delete', route=route)
if r.status_code == 204:
self._invalidate_guild_roles_cache(guild_id)
return True
else:
return False
def _invalidate_guild_roles_cache(self, guild_id: int) -> None:
cache_key = self._guild_roles_cache_key(guild_id)
self._redis.delete(cache_key)
logger.debug('Guild roles cache invalidated')
@classmethod
def _guild_roles_cache_key(cls, guild_id: int) -> str:
"""Returns key for accessing cached roles for a guild"""
gen_key = cls._generate_hash(f'{guild_id}')
return f'{cls._KEYPREFIX_GUILD_ROLES}__{gen_key}'
def match_role_from_name(self, guild_id: int, role_name: str) -> dict:
"""returns Discord role matching the given name or an empty dict"""
guild_roles = DiscordRoles(self.guild_roles(guild_id))
return guild_roles.role_by_name(role_name)
def match_or_create_roles_from_names(self, guild_id: int, role_names: list) -> list:
"""returns Discord roles matching the given names
Returns as list of tuple of role and created flag
Will try to match with existing roles names
Non-existing roles will be created, then created flag will be True
Params:
- guild_id: ID of guild
- role_names: list of name strings each defining a role
"""
roles = list()
guild_roles = DiscordRoles(self.guild_roles(guild_id))
role_names_cleaned = {
DiscordRoles.sanitize_role_name(name) for name in role_names
}
for role_name in role_names_cleaned:
role, created = self.match_or_create_role_from_name(
guild_id=guild_id,
role_name=DiscordRoles.sanitize_role_name(role_name),
guild_roles=guild_roles
)
if role:
roles.append((role, created))
if created:
guild_roles = guild_roles.union(DiscordRoles([role]))
return roles
def match_or_create_role_from_name(
self, guild_id: int, role_name: str, guild_roles: DiscordRoles = None
) -> tuple:
"""returns Discord role matching the given name
Returns as tuple of role and created flag
Will try to match with existing roles names
Non-existing roles will be created, then created flag will be True
Params:
- guild_id: ID of guild
- role_name: strings defining name of a role
- guild_roles: All known guild roles as DiscordRoles object.
Helps to void redundant lookups of guild roles
when this method is used multiple times.
"""
if not isinstance(role_name, str):
raise TypeError('role_name must be of type string')
created = False
if guild_roles is None:
guild_roles = DiscordRoles(self.guild_roles(guild_id))
role = guild_roles.role_by_name(role_name)
if not role:
if not DISCORD_DISABLE_ROLE_CREATION:
logger.debug('Need to create missing role: %s', role_name)
role = self.create_guild_role(guild_id, role_name)
created = True
else:
role = None
return role, created
# guild members
def add_guild_member(
self,
guild_id: int,
user_id: int,
access_token: str,
role_ids: list = None,
nick: str = None
) -> bool:
"""Adds a user to the guilds.
Returns:
- True when a new user was added
- None if the user already existed
- False when something went wrong or raises exception
"""
route = f"guilds/{guild_id}/members/{user_id}"
data = {
'access_token': str(access_token)
}
if role_ids:
data['roles'] = self._sanitize_role_ids(role_ids)
if nick:
data['nick'] = str(nick)[:self._NICK_MAX_CHARS]
r = self._api_request(method='put', route=route, data=data)
r.raise_for_status()
if r.status_code == 201:
return True
elif r.status_code == 204:
return None
else:
return False
def guild_member(self, guild_id: int, user_id: int) -> dict:
"""returns the user info for a guild member
or None if the user is not a member of the guild
"""
route = f'guilds/{guild_id}/members/{user_id}'
r = self._api_request(method='get', route=route, raise_for_status=False)
if self._is_member_unknown_error(r):
logger.warning("Discord user ID %s could not be found on server.", user_id)
return None
else:
r.raise_for_status()
return r.json()
def modify_guild_member(
self, guild_id: int, user_id: int, role_ids: list = None, nick: str = None
) -> bool:
"""Modify attributes of a guild member.
Returns
- True when successful
- None if user is not a member of this guild
- False otherwise
"""
if not role_ids and not nick:
raise ValueError('Must specify role_ids or nick')
if role_ids and not isinstance(role_ids, list):
raise TypeError('role_ids must be a list type')
data = dict()
if role_ids:
data['roles'] = self._sanitize_role_ids(role_ids)
if nick:
data['nick'] = self._sanitize_nick(nick)
route = f"guilds/{guild_id}/members/{user_id}"
r = self._api_request(
method='patch', route=route, data=data, raise_for_status=False
)
if self._is_member_unknown_error(r):
logger.warning('User ID %s is not a member of this guild', user_id)
return None
else:
r.raise_for_status()
if r.status_code == 204:
return True
else:
return False
def remove_guild_member(self, guild_id: int, user_id: int) -> bool:
"""Remove a member from a guild
Returns:
- True when successful
- None if member does not exist
- False otherwise
"""
route = f"guilds/{guild_id}/members/{user_id}"
r = self._api_request(
method='delete', route=route, raise_for_status=False
)
if self._is_member_unknown_error(r):
logger.warning('User ID %s is not a member of this guild', user_id)
return None
else:
r.raise_for_status()
if r.status_code == 204:
return True
else:
return False
# Guild member roles
def add_guild_member_role(
self, guild_id: int, user_id: int, role_id: int
) -> bool:
"""Adds a role to a guild member
Returns:
- True when successful
- None if member does not exist
- False otherwise
"""
route = f"guilds/{guild_id}/members/{user_id}/roles/{role_id}"
r = self._api_request(method='put', route=route, raise_for_status=False)
if self._is_member_unknown_error(r):
logger.warning('User ID %s is not a member of this guild', user_id)
return None
else:
r.raise_for_status()
if r.status_code == 204:
return True
else:
return False
def remove_guild_member_role(
self, guild_id: int, user_id: int, role_id: int
) -> bool:
"""Removes a role to a guild member
Returns:
- True when successful
- None if member does not exist
- False otherwise
"""
route = f"guilds/{guild_id}/members/{user_id}/roles/{role_id}"
r = self._api_request(method='delete', route=route, raise_for_status=False)
if self._is_member_unknown_error(r):
logger.warning('User ID %s is not a member of this guild', user_id)
return None
else:
r.raise_for_status()
if r.status_code == 204:
return True
else:
return False
@classmethod
def _is_member_unknown_error(cls, r: requests.Response) -> bool:
try:
result = (
r.status_code == cls._HTTP_STATUS_CODE_NOT_FOUND
and r.json()['code'] == cls._DISCORD_STATUS_CODE_UNKNOWN_MEMBER
)
except (ValueError, KeyError):
result = False
return result
# Internal methods
def _api_request(
self,
method: str,
route: str,
data: dict = None,
authorization: str = None,
raise_for_status: bool = True
) -> requests.Response:
"""Core method for performing all API calls"""
uid = uuid1().hex
if not hasattr(requests, method):
raise ValueError('Invalid method: %s' % method)
if not authorization:
authorization = f'Bot {self.access_token}'
self._handle_ongoing_api_backoff(uid)
if self.is_rate_limited:
self._ensure_rate_limed_not_exhausted(uid)
headers = {
'User-Agent': f'{AUTH_TITLE} ({__url__}, {__version__})',
'accept': 'application/json',
'X-RateLimit-Precision': 'millisecond',
'authorization': str(authorization)
}
if data:
headers['content-type'] = 'application/json'
url = urljoin(DISCORD_API_BASE_URL, route)
args = {
'url': url,
'headers': headers,
'timeout': (DISCORD_API_TIMEOUT_CONNECT, DISCORD_API_TIMEOUT_READ)
}
if data:
args['json'] = data
logger.info('%s: sending %s request to url \'%s\'', uid, method.upper(), url)
logger.debug('%s: request headers: %s', uid, headers)
r = getattr(requests, method)(**args)
logger.debug(
'%s: returned status code %d with headers: %s',
uid,
r.status_code,
r.headers
)
logger.debug('%s: response:\n%s', uid, r.text)
if not r.ok:
logger.warning(
'%s: Discord API returned error code %d and this response: %s',
uid,
r.status_code,
r.text
)
if r.status_code == self._HTTP_STATUS_CODE_RATE_LIMITED:
self._handle_new_api_backoff(r, uid)
self._report_rate_limit_from_api(r, uid)
if raise_for_status:
r.raise_for_status()
return r
def _handle_ongoing_api_backoff(self, uid: str) -> None:
"""checks if api is currently on backoff
if on backoff: will do a blocking wait if it expires soon,
else raises exception
"""
global_backoff_duration = self._redis.pttl(self._KEY_GLOBAL_BACKOFF_UNTIL)
if global_backoff_duration > 0:
if global_backoff_duration < WAIT_THRESHOLD:
logger.info(
'%s: Global API backoff still ongoing for %s ms. Waiting.',
uid,
global_backoff_duration
)
sleep(global_backoff_duration / 1000)
else:
logger.info(
'%s: Global API backoff still ongoing for %s ms. Re-raising.',
uid,
global_backoff_duration
)
raise DiscordTooManyRequestsError(retry_after=global_backoff_duration)
def _ensure_rate_limed_not_exhausted(self, uid: str) -> int:
"""ensures that the rate limit is not exhausted
if exhausted: will do a blocking wait if rate limit resets soon,
else raises exception
returns requests remaining on success
"""
for _ in range(RATE_LIMIT_RETRIES):
requests_remaining = self._redis_decr_or_set(
name=self._KEY_GLOBAL_RATE_LIMIT_REMAINING,
value=RATE_LIMIT_MAX_REQUESTS,
px=RATE_LIMIT_RESETS_AFTER + DURATION_CONTINGENCY
)
resets_in = max(
MINIMUM_BLOCKING_WAIT,
self._redis.pttl(self._KEY_GLOBAL_RATE_LIMIT_REMAINING)
)
if requests_remaining >= 0:
logger.debug(
'%s: Got one of %d remaining requests until reset in %s ms',
uid,
requests_remaining + 1,
resets_in
)
return requests_remaining
elif resets_in < WAIT_THRESHOLD:
sleep(resets_in / 1000)
logger.debug(
'%s: No requests remaining until reset in %d ms. '
'Waiting for reset.',
uid,
resets_in
)
continue
else:
logger.debug(
'%s: No requests remaining until reset in %d ms. '
'Raising exception.',
uid,
resets_in
)
raise DiscordRateLimitExhausted(resets_in)
raise RuntimeError('Failed to handle rate limit after after too tries.')
def _handle_new_api_backoff(self, r: requests.Response, uid: str) -> None:
"""raises exception for new API backoff error"""
response = r.json()
if 'retry_after' in response:
try:
retry_after = \
int(response['retry_after']) + DURATION_CONTINGENCY
except ValueError:
retry_after = DEFAULT_BACKOFF_DELAY
else:
retry_after = DEFAULT_BACKOFF_DELAY
self._redis_set_if_longer(
name=self._KEY_GLOBAL_BACKOFF_UNTIL,
value='GLOBAL_API_BACKOFF',
px=retry_after
)
logger.warning(
"%s: Rate limit violated. Need to back off for at least %d ms",
uid,
retry_after
)
raise DiscordTooManyRequestsError(retry_after=retry_after)
def _report_rate_limit_from_api(self, r, uid):
"""Tries to log the current rate limit reported from API"""
if (
logger.getEffectiveLevel() <= logging.DEBUG
and 'x-ratelimit-limit' in r.headers
and 'x-ratelimit-remaining' in r.headers
and 'x-ratelimit-reset-after' in r.headers
):
try:
limit = int(r.headers['x-ratelimit-limit'])
remaining = int(r.headers['x-ratelimit-remaining'])
reset_after = float(r.headers['x-ratelimit-reset-after']) * 1000
if remaining + 1 == limit:
logger.debug(
'%s: Rate limit reported from API: %d requests per %s ms',
uid,
limit,
reset_after
)
except ValueError:
pass
@staticmethod
def _redis_decode(value: str) -> str:
"""Decodes a string from Redis and passes through None and Booleans"""
if value is not None and not isinstance(value, bool):
return value.decode('utf-8')
else:
return value
@staticmethod
def _generate_hash(key: str) -> str:
return md5(key.encode('utf-8')).hexdigest()
@staticmethod
def _sanitize_role_ids(role_ids: list) -> list:
"""make sure its a list of integers"""
return [int(role_id) for role_id in list(role_ids)]
@classmethod
def _sanitize_nick(cls, nick: str) -> str:
"""shortens too long strings if necessary"""
return str(nick)[:cls._NICK_MAX_CHARS]

View File

@@ -0,0 +1,33 @@
import math
class DiscordClientException(Exception):
"""Base Exception for the Discord client"""
class DiscordApiBackoff(DiscordClientException):
"""Exception signaling we need to backoff from sending requests to the API for now
"""
def __init__(self, retry_after: int):
"""
:param retry_after: int time to retry after in milliseconds
"""
super().__init__()
self.retry_after = int(retry_after)
@property
def retry_after_seconds(self):
return math.ceil(self.retry_after / 1000)
class DiscordRateLimitExhausted(DiscordApiBackoff):
"""Exception signaling that the total number of requests allowed under the
current rate limit have been exhausted and weed to wait until next reset.
"""
class DiscordTooManyRequestsError(DiscordApiBackoff):
"""API has responded with a 429 Too Many Requests Error.
Need to backoff for now.
"""

View File

@@ -0,0 +1,132 @@
from copy import copy
class DiscordRoles:
"""Container class that helps dealing with Discord roles.
Objects of this class are immutable and work in many ways like sets.
Ideally objects are initialized from raw API responses,
e.g. from DiscordClient.guild.roles()
"""
_ROLE_NAME_MAX_CHARS = 100
def __init__(self, roles_lst: list) -> None:
"""roles_lst must be a list of dict, each defining a role"""
if not isinstance(roles_lst, (list, set, tuple)):
raise TypeError('roles_lst must be of type list, set or tuple')
self._roles = dict()
self._roles_by_name = dict()
for role in list(roles_lst):
self._assert_valid_role(role)
self._roles[int(role['id'])] = role
self._roles_by_name[self.sanitize_role_name(role['name'])] = role
def __eq__(self, other):
if isinstance(other, type(self)):
return self.ids() == other.ids()
return NotImplemented
def __hash__(self):
return hash(tuple(sorted(self._roles.keys())))
def __iter__(self):
for role in self._roles.values():
yield role
def __contains__(self, item) -> bool:
return int(item) in self._roles
def __len__(self):
return len(self._roles.keys())
def has_roles(self, role_ids: set) -> bool:
"""returns true if this objects contains all roles defined by given role_ids
incl. managed roles
"""
role_ids = {int(id) for id in role_ids}
all_role_ids = self._roles.keys()
return role_ids.issubset(all_role_ids)
def ids(self) -> set:
"""return a set of all role IDs"""
return set(self._roles.keys())
def subset(self, role_ids: set = None, managed_only: bool = False) -> object:
"""returns a new object containing the subset of roles as defined
by given role IDs and/or including managed roles only
"""
if role_ids is not None:
role_ids = {int(id) for id in role_ids}
if role_ids is not None and not managed_only:
return type(self)([
role for role_id, role in self._roles.items() if role_id in role_ids
])
elif role_ids is None and managed_only:
return type(self)([
role for _, role in self._roles.items() if role['managed']
])
elif role_ids is not None and managed_only:
return type(self)([
role for role_id, role in self._roles.items()
if role_id in role_ids and role['managed']
])
else:
return copy(self)
def union(self, other: object) -> object:
"""returns a new roles object that is the union of this roles object
with other"""
return type(self)(list(self) + list(other))
def difference(self, other: object) -> object:
"""returns a new roles object that only contains the roles
that exist in the current objects, but not in other
"""
new_ids = self.ids().difference(other.ids())
return self.subset(role_ids=new_ids)
def role_by_name(self, role_name: str) -> dict:
"""returns role if one with matching name is found else an empty dict"""
role_name = self.sanitize_role_name(role_name)
if role_name in self._roles_by_name:
return self._roles_by_name[role_name]
else:
return dict()
@classmethod
def create_from_matched_roles(cls, matched_roles: list) -> None:
"""returns a new object created from the given list of matches roles
matches_roles must be a list of tuples in the form: (role, created)
"""
raw_roles = [x[0] for x in matched_roles]
return cls(raw_roles)
@staticmethod
def _assert_valid_role(role: dict):
if not isinstance(role, dict):
raise TypeError('Roles must be of type dict: %s' % role)
if 'id' not in role or 'name' not in role or 'managed' not in role:
raise ValueError('This role is not valid: %s' % role)
@classmethod
def sanitize_role_name(cls, role_name: str) -> str:
"""shortens too long strings if necessary"""
return str(role_name)[:cls._ROLE_NAME_MAX_CHARS]
def match_or_create_roles_from_names(
client: object, guild_id: int, role_names: list
) -> DiscordRoles:
"""Shortcut for getting the result of matching role names as DiscordRoles object"""
return DiscordRoles.create_from_matched_roles(
client.match_or_create_roles_from_names(
guild_id=guild_id, role_names=role_names
)
)

View File

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

View File

@@ -0,0 +1,85 @@
"""This is script is for concurrency testing the Discord client with a Discord server.
It will run multiple requests against Discord with multiple workers in parallel.
The results can be analysed in a special log file.
This script is design to be run manually as unit test, e.g. by running the following:
python manage.py test
allianceauth.services.modules.discord.discord_client.tests.piloting_concurrency
To make it work please set the below mentioned environment variables for your server.
Since this may cause lots of 429s we'd recommend NOT to use your
alliance Discord server for this.
"""
import os
from random import random
import threading
from time import sleep
from django.test import TestCase
from .. import DiscordClient, DiscordApiBackoff
from ...utils import set_logger_to_file
logger = set_logger_to_file(
'allianceauth.services.modules.discord.discord_client.client', __file__
)
# Make sure to set these environnement variables for your Discord server and user
DISCORD_GUILD_ID = os.environ['DISCORD_GUILD_ID']
DISCORD_BOT_TOKEN = os.environ['DISCORD_BOT_TOKEN']
DISCORD_USER_ID = os.environ['DISCORD_USER_ID']
NICK = 'Dummy'
# Configure these settings to adjust the load profile
NUMBER_OF_WORKERS = 5
NUMBER_OF_RUNS = 10
# max seconds a worker waits before starting a new run
# set to near 0 for max load preassure
MAX_JITTER_PER_RUN_SECS = 1.0
def worker(num: int):
"""worker function"""
worker_info = 'worker %d' % num
logger.info('%s: started', worker_info)
client = DiscordClient(DISCORD_BOT_TOKEN)
try:
runs = 0
while runs < NUMBER_OF_RUNS:
run_info = '%s: run %d' % (worker_info, runs + 1)
my_jitter_secs = random() * MAX_JITTER_PER_RUN_SECS
logger.info('%s - waiting %s secs', run_info, f'{my_jitter_secs:.3f}')
sleep(my_jitter_secs)
logger.info('%s - started', run_info)
try:
client.modify_guild_member(
DISCORD_GUILD_ID, DISCORD_USER_ID, nick=NICK
)
runs += 1
except DiscordApiBackoff as bo:
message = '%s - waiting out API backoff for %d ms' % (
run_info, bo.retry_after
)
logger.info(message)
print()
print(message)
sleep(bo.retry_after / 1000)
except Exception as ex:
logger.exception('%s: Processing aborted: %s', worker_info, ex)
logger.info('%s: finished', worker_info)
return
class TestMulti(TestCase):
def test_multi(self):
logger.info('Starting multi test')
for num in range(NUMBER_OF_WORKERS):
x = threading.Thread(target=worker, args=(num + 1,))
x.start()

View File

@@ -0,0 +1,130 @@
"""This script is for functional testing of the Discord client with a Discord server
It will run single requests of the various functions to validate
that they actually work - excluding those that require Oauth, or does not work
with a bot token. The results can be also seen in a special log file.
This script is design to be run manually as unit test, e.g. by running the following:
python manage.py test
allianceauth.services.modules.discord.discord_self.client.tests.piloting_functionality
To make it work please set the below mentioned environment variables for your server.
Since this may cause lots of 429s we'd recommend NOT to use your
alliance Discord server for this.
"""
from uuid import uuid1
import os
from unittest import TestCase
from time import sleep
from .. import DiscordClient
from ...utils import set_logger_to_file
logger = set_logger_to_file(
'allianceauth.services.modules.discord.discord_self.client.client', __file__
)
# Make sure to set these environnement variables for your Discord server and user
DISCORD_GUILD_ID = os.environ['DISCORD_GUILD_ID']
DISCORD_BOT_TOKEN = os.environ['DISCORD_BOT_TOKEN']
DISCORD_USER_ID = os.environ['DISCORD_USER_ID']
RATE_LIMIT_DELAY_SECS = 1
class TestDiscordApiLive(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
logger.info('Live demo of the Discord API Client')
cls.client = DiscordClient(DISCORD_BOT_TOKEN)
def test_run_other_features(self):
"""runs features that have not been run in any of the other tests"""
self.client.guild_infos(DISCORD_GUILD_ID)
sleep(RATE_LIMIT_DELAY_SECS)
self.client.guild_name(DISCORD_GUILD_ID)
sleep(RATE_LIMIT_DELAY_SECS)
self.client.match_or_create_role_from_name(DISCORD_GUILD_ID, 'Testrole')
sleep(RATE_LIMIT_DELAY_SECS)
self.client.match_or_create_roles_from_names(
DISCORD_GUILD_ID, ['Testrole A', 'Testrole B']
)
sleep(RATE_LIMIT_DELAY_SECS)
def test_create_and_remove_roles(self):
# get base
logger.info('guild_roles')
expected = {role['id'] for role in self.client.guild_roles(DISCORD_GUILD_ID)}
# add role
role_name = 'my test role 12345678'
logger.info('create_guild_role')
new_role = self.client.create_guild_role(
guild_id=DISCORD_GUILD_ID, role_name=role_name
)
sleep(RATE_LIMIT_DELAY_SECS)
self.assertEqual(new_role['name'], role_name)
# remove role again
logger.info('delete_guild_role')
self.client.delete_guild_role(
guild_id=DISCORD_GUILD_ID, role_id=new_role['id']
)
sleep(RATE_LIMIT_DELAY_SECS)
# verify it worked
logger.info('guild_roles')
role_ids = {role['id'] for role in self.client.guild_roles(DISCORD_GUILD_ID)}
sleep(RATE_LIMIT_DELAY_SECS)
self.assertSetEqual(role_ids, expected)
def test_change_member_nick(self):
# set new nick for user
logger.info('modify_guild_member')
new_nick = f'Testnick {uuid1().hex}'[:32]
self.assertTrue(
self.client.modify_guild_member(
guild_id=DISCORD_GUILD_ID, user_id=DISCORD_USER_ID, nick=new_nick
)
)
sleep(RATE_LIMIT_DELAY_SECS)
# verify it is saved
logger.info('guild_member')
user = self.client.guild_member(DISCORD_GUILD_ID, DISCORD_USER_ID)
sleep(RATE_LIMIT_DELAY_SECS)
self.assertEqual(user['nick'], new_nick)
def test_member_add_remove_roles(self):
# create new guild role
logger.info('create_guild_role')
new_role = self.client.create_guild_role(
guild_id=DISCORD_GUILD_ID, role_name='Special role 98765'
)
sleep(RATE_LIMIT_DELAY_SECS)
new_role_id = new_role['id']
# add to member
logger.info('add_guild_member_role')
self.assertTrue(
self.client.add_guild_member_role(
guild_id=DISCORD_GUILD_ID, user_id=DISCORD_USER_ID, role_id=new_role_id
)
)
sleep(RATE_LIMIT_DELAY_SECS)
# remove again
logger.info('remove_guild_member_role')
self.assertTrue(
self.client.remove_guild_member_role(
guild_id=DISCORD_GUILD_ID, user_id=DISCORD_USER_ID, role_id=new_role_id
)
)
sleep(RATE_LIMIT_DELAY_SECS)

View File

@@ -0,0 +1,26 @@
# Discord rate limits
The following table shows the rate limit as reported from the API for different routes.
method | limit | reset | rate / s | bucket
-- | -- | -- | -- | --
add_guild_member | 10 | 10,000 | 1 | self
create_guild_role | 250 | 180,000,000 | 0.001 | self
delete_guild_role | g | g | g | g
guild_member | 5 | 1,000 | 5 | self
guild_roles | g | g | g | g
add_guild_member_role | 10 | 10,000 | 1 | B1
remove_guild_member_role | 10 | 10,000 | 1 | B1
modify_guild_member | 10 | 10,000 | 1 | self
remove_guild_member | 5 | 1,000 | 5 | self
current_user | g | g | g | g
Legend:
- g: global rate limit. API does not provide any rate limit infos for those routes.
- reset: Values in milliseconds.
- bucket: "self" means the rate limit is only counted for that route, Bx means the same rate limit is counted for multiple routes.
- Data was collected on 2020-MAY-07 and is subject to change.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
from unittest import TestCase
from ..exceptions import (
DiscordApiBackoff,
DiscordClientException,
DiscordRateLimitExhausted,
DiscordTooManyRequestsError
)
class TestExceptions(TestCase):
def test_DiscordApiException(self):
with self.assertRaises(DiscordClientException):
raise DiscordClientException()
def test_DiscordApiBackoff_raise(self):
with self.assertRaises(DiscordApiBackoff):
raise DiscordApiBackoff(999)
def test_DiscordApiBackoff_retry_after_seconds(self):
retry_after = 999
ex = DiscordApiBackoff(retry_after)
self.assertEqual(ex.retry_after, retry_after)
self.assertEqual(ex.retry_after_seconds, 1)
def test_DiscordRateLimitedExhausted_raise(self):
with self.assertRaises(DiscordRateLimitExhausted):
raise DiscordRateLimitExhausted(999)
def test_DiscordApiBackoffError_raise(self):
with self.assertRaises(DiscordTooManyRequestsError):
raise DiscordTooManyRequestsError(999)

View File

@@ -0,0 +1,238 @@
from unittest import TestCase
from . import ROLE_ALPHA, ROLE_BRAVO, ROLE_CHARLIE, ROLE_MIKE, ALL_ROLES, create_role
from .. import DiscordRoles
MODULE_PATH = 'allianceauth.services.modules.discord.discord_client.client'
class TestDiscordRoles(TestCase):
def setUp(self):
self.all_roles = DiscordRoles(ALL_ROLES)
def test_can_create_simple(self):
roles_raw = [ROLE_ALPHA]
roles = DiscordRoles(roles_raw)
self.assertListEqual(list(roles), roles_raw)
def test_can_create_empty(self):
roles_raw = []
roles = DiscordRoles(roles_raw)
self.assertListEqual(list(roles), [])
def test_raises_exception_if_roles_raw_of_wrong_type(self):
with self.assertRaises(TypeError):
DiscordRoles({'id': 1})
def test_raises_exception_if_list_contains_non_dict(self):
roles_raw = [ROLE_ALPHA, 'not_valid']
with self.assertRaises(TypeError):
DiscordRoles(roles_raw)
def test_raises_exception_if_invalid_role_1(self):
roles_raw = [{'name': 'alpha', 'managed': False}]
with self.assertRaises(ValueError):
DiscordRoles(roles_raw)
def test_raises_exception_if_invalid_role_2(self):
roles_raw = [{'id': 1, 'managed': False}]
with self.assertRaises(ValueError):
DiscordRoles(roles_raw)
def test_raises_exception_if_invalid_role_3(self):
roles_raw = [{'id': 1, 'name': 'alpha'}]
with self.assertRaises(ValueError):
DiscordRoles(roles_raw)
def test_roles_are_equal(self):
roles_a = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO])
roles_b = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO])
self.assertEqual(roles_a, roles_b)
def test_roles_are_not_equal(self):
roles_a = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO])
roles_b = DiscordRoles([ROLE_ALPHA])
self.assertNotEqual(roles_a, roles_b)
def test_different_objects_are_not_equal(self):
roles_a = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO])
self.assertFalse(roles_a == "invalid")
def test_len(self):
self.assertEqual(len(self.all_roles), 4)
def test_contains(self):
self.assertTrue(1 in self.all_roles)
self.assertFalse(99 in self.all_roles)
def test_sanitize_role_name(self):
role_name_input = 'x' * 110
role_name_expected = 'x' * 100
result = DiscordRoles.sanitize_role_name(role_name_input)
self.assertEqual(result, role_name_expected)
def test_objects_are_hashable(self):
roles_a = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO])
roles_b = DiscordRoles([ROLE_BRAVO, ROLE_ALPHA])
roles_c = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO, ROLE_MIKE])
self.assertIsNotNone(hash(roles_a))
self.assertEqual(hash(roles_a), hash(roles_b))
self.assertNotEqual(hash(roles_a), hash(roles_c))
def test_create_from_matched_roles(self):
matched_roles = [
(ROLE_ALPHA, True),
(ROLE_BRAVO, False)
]
roles = DiscordRoles.create_from_matched_roles(matched_roles)
self.assertSetEqual(roles.ids(), {1, 2})
class TestIds(TestCase):
def setUp(self):
self.all_roles = DiscordRoles(ALL_ROLES)
def test_return_role_ids_default(self):
result = self.all_roles.ids()
expected = {1, 2, 3, 13}
self.assertSetEqual(result, expected)
def test_return_role_ids_empty(self):
roles = DiscordRoles([])
self.assertSetEqual(roles.ids(), set())
class TestSubset(TestCase):
def setUp(self):
self.all_roles = DiscordRoles(ALL_ROLES)
def test_ids_only(self):
role_ids = {1, 3}
roles_subset = self.all_roles.subset(role_ids)
expected = {1, 3}
self.assertSetEqual(roles_subset.ids(), expected)
def test_ids_as_string_work_too(self):
role_ids = {'1', '3'}
roles_subset = self.all_roles.subset(role_ids)
expected = {1, 3}
self.assertSetEqual(roles_subset.ids(), expected)
def test_managed_only(self):
roles = self.all_roles.subset(managed_only=True)
expected = {13}
self.assertSetEqual(roles.ids(), expected)
def test_ids_and_managed_only(self):
role_ids = {1, 3, 13}
roles_subset = self.all_roles.subset(role_ids, managed_only=True)
expected = {13}
self.assertSetEqual(roles_subset.ids(), expected)
def test_ids_are_empty(self):
roles = self.all_roles.subset([])
expected = set()
self.assertSetEqual(roles.ids(), expected)
def test_no_parameters(self):
roles = self.all_roles.subset()
expected = {1, 2, 3, 13}
self.assertSetEqual(roles.ids(), expected)
class TestHasRoles(TestCase):
def setUp(self):
self.all_roles = DiscordRoles(ALL_ROLES)
def test_true_if_all_roles_exit(self):
self.assertTrue(self.all_roles.has_roles([1, 2]))
def test_true_if_all_roles_exit_str(self):
self.assertTrue(self.all_roles.has_roles(['1', '2']))
def test_false_if_role_does_not_exit(self):
self.assertFalse(self.all_roles.has_roles([99]))
def test_false_if_one_role_does_not_exit(self):
self.assertFalse(self.all_roles.has_roles([1, 99]))
def test_true_for_empty_roles(self):
self.assertTrue(self.all_roles.has_roles([]))
class TestGetMatchingRolesByName(TestCase):
def setUp(self):
self.all_roles = DiscordRoles(ALL_ROLES)
def test_return_role_if_matches(self):
role_name = 'alpha'
expected = ROLE_ALPHA
result = self.all_roles.role_by_name(role_name)
self.assertEqual(result, expected)
def test_return_role_if_matches_and_limit_max_length(self):
role_name = 'x' * 120
expected = create_role(77, 'x' * 100)
roles = DiscordRoles([expected])
result = roles.role_by_name(role_name)
self.assertEqual(result, expected)
def test_return_empty_if_not_matches(self):
role_name = 'lima'
expected = {}
result = self.all_roles.role_by_name(role_name)
self.assertEqual(result, expected)
class TestUnion(TestCase):
def test_distinct_sets(self):
roles_1 = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO])
roles_2 = DiscordRoles([ROLE_CHARLIE, ROLE_MIKE])
roles_3 = roles_1.union(roles_2)
expected = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO, ROLE_CHARLIE, ROLE_MIKE])
self.assertEqual(roles_3, expected)
def test_overlapping_sets(self):
roles_1 = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO])
roles_2 = DiscordRoles([ROLE_BRAVO, ROLE_MIKE])
roles_3 = roles_1.union(roles_2)
expected = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO, ROLE_MIKE])
self.assertEqual(roles_3, expected)
def test_identical_sets(self):
roles_1 = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO])
roles_2 = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO])
roles_3 = roles_1.union(roles_2)
expected = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO])
self.assertEqual(roles_3, expected)
class TestDifference(TestCase):
def test_distinct_sets(self):
roles_1 = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO])
roles_2 = DiscordRoles([ROLE_CHARLIE, ROLE_MIKE])
roles_3 = roles_1.difference(roles_2)
expected = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO])
self.assertEqual(roles_3, expected)
def test_overlapping_sets(self):
roles_1 = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO])
roles_2 = DiscordRoles([ROLE_BRAVO, ROLE_MIKE])
roles_3 = roles_1.difference(roles_2)
expected = DiscordRoles([ROLE_ALPHA])
self.assertEqual(roles_3, expected)
def test_identical_sets(self):
roles_1 = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO])
roles_2 = DiscordRoles([ROLE_ALPHA, ROLE_BRAVO])
roles_3 = roles_1.difference(roles_2)
expected = DiscordRoles([])
self.assertEqual(roles_3, expected)

View File

@@ -1,333 +0,0 @@
import requests
import math
from django.conf import settings
from requests_oauthlib import OAuth2Session
from functools import wraps
import logging
import datetime
import time
from django.core.cache import cache
from hashlib import md5
logger = logging.getLogger(__name__)
DISCORD_URL = "https://discordapp.com/api"
AUTH_URL = "https://discordapp.com/api/oauth2/authorize"
TOKEN_URL = "https://discordapp.com/api/oauth2/token"
"""
Previously all we asked for was permission to kick members, manage roles, and manage nicknames.
Users have reported weird unauthorized errors we don't understand. So now we ask for full server admin.
It's almost fixed the problem.
"""
# kick members, manage roles, manage nicknames, create instant invite
# BOT_PERMISSIONS = 0x00000002 + 0x10000000 + 0x08000000 + 0x00000001
BOT_PERMISSIONS = 0x00000008
# get user ID, accept invite
SCOPES = [
'identify',
'guilds.join',
]
GROUP_CACHE_MAX_AGE = getattr(settings, 'DISCORD_GROUP_CACHE_MAX_AGE', 2 * 60 * 60) # 2 hours default
class DiscordApiException(Exception):
def __init__(self):
super(Exception, self).__init__()
class DiscordApiTooBusy(DiscordApiException):
def __init__(self):
super(DiscordApiException, self).__init__()
self.message = "The Discord API is too busy to process this request now, please try again later."
class DiscordApiBackoff(DiscordApiException):
def __init__(self, retry_after, global_ratelimit):
"""
:param retry_after: int time to retry after in milliseconds
:param global_ratelimit: bool Is the API under a global backoff
"""
super(DiscordApiException, self).__init__()
self.retry_after = retry_after
self.global_ratelimit = global_ratelimit
@property
def retry_after_seconds(self):
return math.ceil(self.retry_after / 1000)
cache_time_format = '%Y-%m-%d %H:%M:%S.%f'
def api_backoff(func):
"""
Decorator, Handles HTTP 429 "Too Many Requests" messages from the Discord API
If blocking=True is specified, this function will block and retry
the function up to max_retries=n times, or 3 if retries is not specified.
If the API call still recieves a backoff timer this function will raise
a <DiscordApiTooBusy> exception.
If the caller chooses blocking=False, the decorator will raise a DiscordApiBackoff
exception and the caller can choose to retry after the given timespan available in
the retry_after property in seconds.
"""
class PerformBackoff(Exception):
def __init__(self, retry_after, retry_datetime, global_ratelimit):
super(Exception, self).__init__()
self.retry_after = int(retry_after)
self.retry_datetime = retry_datetime
self.global_ratelimit = global_ratelimit
@wraps(func)
def decorated(*args, **kwargs):
blocking = kwargs.get('blocking', False)
retries = kwargs.get('max_retries', 3)
# Strip our parameters
if 'max_retries' in kwargs:
del kwargs['max_retries']
if 'blocking' in kwargs:
del kwargs['blocking']
cache_key = 'DISCORD_BACKOFF_' + func.__name__
cache_global_key = 'DISCORD_BACKOFF_GLOBAL'
while retries > 0:
try:
try:
# Check global backoff first, then route backoff
existing_global_backoff = cache.get(cache_global_key)
existing_backoff = existing_global_backoff or cache.get(cache_key)
if existing_backoff:
backoff_timer = datetime.datetime.strptime(existing_backoff, cache_time_format)
if backoff_timer > datetime.datetime.utcnow():
backoff_seconds = (backoff_timer - datetime.datetime.utcnow()).total_seconds()
logger.debug("Still under backoff for %s seconds, backing off" % backoff_seconds)
# Still under backoff
raise PerformBackoff(
retry_after=backoff_seconds,
retry_datetime=backoff_timer,
global_ratelimit=bool(existing_global_backoff)
)
logger.debug("Calling API calling function")
return func(*args, **kwargs)
except requests.HTTPError as e:
if e.response.status_code == 429:
try:
retry_after = int(e.response.headers['Retry-After'])
except (TypeError, KeyError):
# Pick some random time
retry_after = 5000
logger.info("Received backoff from API of %s seconds, handling" % retry_after)
# Store value in redis
backoff_until = (datetime.datetime.utcnow() +
datetime.timedelta(milliseconds=retry_after))
global_backoff = bool(e.response.headers.get('X-RateLimit-Global', False))
if global_backoff:
logger.info("Global backoff!!")
cache.set(cache_global_key, backoff_until.strftime(cache_time_format), retry_after)
else:
cache.set(cache_key, backoff_until.strftime(cache_time_format), retry_after)
raise PerformBackoff(retry_after=retry_after, retry_datetime=backoff_until,
global_ratelimit=global_backoff)
else:
# Not 429, re-raise
raise e
except PerformBackoff as bo:
# Sleep if we're blocking
if blocking:
logger.info("Blocking Back off from API calls for %s seconds" % bo.retry_after)
time.sleep((10 if bo.retry_after > 10 else bo.retry_after) / 1000)
else:
# Otherwise raise exception and let caller handle the backoff
raise DiscordApiBackoff(retry_after=bo.retry_after, global_ratelimit=bo.global_ratelimit)
finally:
retries -= 1
if retries == 0:
raise DiscordApiTooBusy()
return decorated
class DiscordOAuthManager:
def __init__(self):
pass
@staticmethod
def _sanitize_name(name):
return name[:32]
@staticmethod
def _sanitize_group_name(name):
return name[:100]
@staticmethod
def generate_bot_add_url():
return AUTH_URL + '?client_id=' + settings.DISCORD_APP_ID + '&scope=bot&permissions=' + str(BOT_PERMISSIONS)
@staticmethod
def generate_oauth_redirect_url():
oauth = OAuth2Session(settings.DISCORD_APP_ID, redirect_uri=settings.DISCORD_CALLBACK_URL, scope=SCOPES)
url, state = oauth.authorization_url(AUTH_URL)
return url
@staticmethod
def _process_callback_code(code):
oauth = OAuth2Session(settings.DISCORD_APP_ID, redirect_uri=settings.DISCORD_CALLBACK_URL)
token = oauth.fetch_token(TOKEN_URL, client_secret=settings.DISCORD_APP_SECRET, code=code)
return token
@staticmethod
def add_user(code, groups, nickname=None):
try:
token = DiscordOAuthManager._process_callback_code(code)['access_token']
logger.debug("Received token from OAuth")
custom_headers = {'accept': 'application/json', 'authorization': 'Bearer ' + token}
path = DISCORD_URL + "/users/@me"
r = requests.get(path, headers=custom_headers)
logger.debug("Got status code %s after retrieving Discord profile" % r.status_code)
r.raise_for_status()
user_id = r.json()['id']
path = DISCORD_URL + "/guilds/" + str(settings.DISCORD_GUILD_ID) + "/members/" + str(user_id)
group_ids = [DiscordOAuthManager._group_name_to_id(DiscordOAuthManager._sanitize_group_name(g)) for g in
groups]
data = {
'roles': group_ids,
'access_token': token,
}
if nickname:
data['nick'] = DiscordOAuthManager._sanitize_name(nickname)
custom_headers['authorization'] = 'Bot ' + settings.DISCORD_BOT_TOKEN
r = requests.put(path, headers=custom_headers, json=data)
logger.debug("Got status code %s after joining Discord server" % r.status_code)
r.raise_for_status()
logger.info("Added Discord user ID %s to server." % user_id)
return user_id
except:
logger.exception("Failed to add Discord user")
return None
@staticmethod
@api_backoff
def update_nickname(user_id, nickname):
nickname = DiscordOAuthManager._sanitize_name(nickname)
custom_headers = {'content-type': 'application/json', 'authorization': 'Bot ' + settings.DISCORD_BOT_TOKEN}
data = {'nick': nickname}
path = DISCORD_URL + "/guilds/" + str(settings.DISCORD_GUILD_ID) + "/members/" + str(user_id)
r = requests.patch(path, headers=custom_headers, json=data)
logger.debug("Got status code %s after setting nickname for Discord user ID %s (%s)" % (
r.status_code, user_id, nickname))
if r.status_code == 404:
logger.warn("Discord user ID %s could not be found in server." % user_id)
return True
r.raise_for_status()
return True
@staticmethod
def delete_user(user_id):
try:
custom_headers = {'accept': 'application/json', 'authorization': 'Bot ' + settings.DISCORD_BOT_TOKEN}
path = DISCORD_URL + "/guilds/" + str(settings.DISCORD_GUILD_ID) + "/members/" + str(user_id)
r = requests.delete(path, headers=custom_headers)
logger.debug("Got status code %s after removing Discord user ID %s" % (r.status_code, user_id))
if r.status_code == 404:
logger.warn("Discord user ID %s already left the server." % user_id)
return True
r.raise_for_status()
return True
except:
logger.exception("Failed to remove Discord user ID %s" % user_id)
return False
@staticmethod
def _get_groups():
custom_headers = {'accept': 'application/json', 'authorization': 'Bot ' + settings.DISCORD_BOT_TOKEN}
path = DISCORD_URL + "/guilds/" + str(settings.DISCORD_GUILD_ID) + "/roles"
r = requests.get(path, headers=custom_headers)
logger.debug("Got status code %s after retrieving Discord roles" % r.status_code)
r.raise_for_status()
return r.json()
@staticmethod
def _generate_cache_role_key(name):
return 'DISCORD_ROLE_NAME__%s' % md5(str(name).encode('utf-8')).hexdigest()
@staticmethod
def _group_name_to_id(name):
name = DiscordOAuthManager._sanitize_group_name(name)
def get_or_make_role():
groups = DiscordOAuthManager._get_groups()
for g in groups:
if g['name'] == name:
return g['id']
return DiscordOAuthManager._create_group(name)['id']
return cache.get_or_set(DiscordOAuthManager._generate_cache_role_key(name), get_or_make_role, GROUP_CACHE_MAX_AGE)
@staticmethod
def __generate_role(name, **kwargs):
custom_headers = {'accept': 'application/json', 'authorization': 'Bot ' + settings.DISCORD_BOT_TOKEN}
path = DISCORD_URL + "/guilds/" + str(settings.DISCORD_GUILD_ID) + "/roles"
data = {'name': name}
data.update(kwargs)
r = requests.post(path, headers=custom_headers, json=data)
logger.debug("Received status code %s after generating new role." % r.status_code)
r.raise_for_status()
return r.json()
@staticmethod
def __edit_role(role_id, **kwargs):
custom_headers = {'content-type': 'application/json', 'authorization': 'Bot ' + settings.DISCORD_BOT_TOKEN}
path = DISCORD_URL + "/guilds/" + str(settings.DISCORD_GUILD_ID) + "/roles/" + str(role_id)
r = requests.patch(path, headers=custom_headers, json=kwargs)
logger.debug("Received status code %s after editing role id %s" % (r.status_code, role_id))
r.raise_for_status()
return r.json()
@staticmethod
def _create_group(name):
return DiscordOAuthManager.__generate_role(name)
@staticmethod
def _get_user(user_id):
custom_headers = {'content-type': 'application/json', 'authorization': 'Bot ' + settings.DISCORD_BOT_TOKEN}
path = DISCORD_URL + "/guilds/" + str(settings.DISCORD_GUILD_ID) + "/members/" + str(user_id)
r = requests.get(path, headers=custom_headers)
r.raise_for_status()
return r.json()
@staticmethod
def _get_user_roles(user_id):
user = DiscordOAuthManager._get_user(user_id)
return user['roles']
@staticmethod
def _modify_user_role(user_id, role_id, method):
custom_headers = {'content-type': 'application/json', 'authorization': 'Bot ' + settings.DISCORD_BOT_TOKEN}
path = DISCORD_URL + "/guilds/" + str(settings.DISCORD_GUILD_ID) + "/members/" + str(user_id) + "/roles/" + str(
role_id)
r = getattr(requests, method)(path, headers=custom_headers)
r.raise_for_status()
logger.debug("%s role %s for user %s" % (method, role_id, user_id))
@staticmethod
@api_backoff
def update_groups(user_id, groups):
group_ids = [DiscordOAuthManager._group_name_to_id(DiscordOAuthManager._sanitize_group_name(g)) for g in groups]
user_group_ids = DiscordOAuthManager._get_user_roles(user_id)
for g in group_ids:
if g not in user_group_ids:
DiscordOAuthManager._modify_user_role(user_id, g, 'put')
time.sleep(1) # we're gonna be hammering the API here
for g in user_group_ids:
if g not in group_ids:
DiscordOAuthManager._modify_user_role(user_id, g, 'delete')
time.sleep(1)

View File

@@ -0,0 +1,216 @@
import logging
from urllib.parse import urlencode
from requests_oauthlib import OAuth2Session
from requests.exceptions import HTTPError
from django.contrib.auth.models import User, Group
from django.db import models
from django.utils.timezone import now
from allianceauth.services.hooks import NameFormatter
from . import __title__
from .app_settings import (
DISCORD_APP_ID,
DISCORD_APP_SECRET,
DISCORD_BOT_TOKEN,
DISCORD_CALLBACK_URL,
DISCORD_GUILD_ID,
DISCORD_SYNC_NAMES
)
from .discord_client import DiscordClient
from .discord_client.exceptions import DiscordClientException, DiscordApiBackoff
from .discord_client.helpers import match_or_create_roles_from_names
from .utils import LoggerAddTag
logger = LoggerAddTag(logging.getLogger(__name__), __title__)
class DiscordUserManager(models.Manager):
"""Manager for DiscordUser"""
# full server admin
BOT_PERMISSIONS = 0x00000008
# get user ID, accept invite
SCOPES = [
'identify',
'guilds.join',
]
def add_user(
self,
user: User,
authorization_code: str,
is_rate_limited: bool = True
) -> bool:
"""adds a new Discord user
Params:
- user: Auth user to join
- authorization_code: authorization code returns from oauth
- is_rate_limited: When False will disable default rate limiting (use with care)
Returns: True on success, else False or raises exception
"""
try:
nickname = self.user_formatted_nick(user) if DISCORD_SYNC_NAMES else None
group_names = self.user_group_names(user)
access_token = self._exchange_auth_code_for_token(authorization_code)
user_client = DiscordClient(access_token, is_rate_limited=is_rate_limited)
discord_user = user_client.current_user()
user_id = discord_user['id']
bot_client = self._bot_client(is_rate_limited=is_rate_limited)
if group_names:
role_ids = match_or_create_roles_from_names(
client=bot_client,
guild_id=DISCORD_GUILD_ID,
role_names=group_names
).ids()
else:
role_ids = None
created = bot_client.add_guild_member(
guild_id=DISCORD_GUILD_ID,
user_id=user_id,
access_token=access_token,
role_ids=role_ids,
nick=nickname
)
if created is not False:
if created is None:
logger.debug(
"User %s with Discord ID %s is already a member.",
user,
user_id,
)
self.update_or_create(
user=user,
defaults={
'uid': user_id,
'username': discord_user['username'][:32],
'discriminator': discord_user['discriminator'][:4],
'activated': now()
}
)
logger.info(
"Added user %s with Discord ID %s to Discord server", user, user_id
)
return True
else:
logger.warning(
"Failed to add user %s with Discord ID %s to Discord server",
user,
user_id,
)
return False
except (HTTPError, ConnectionError, DiscordApiBackoff) as ex:
logger.exception(
'Failed to add user %s to Discord server: %s', user, ex
)
return False
@staticmethod
def user_formatted_nick(user: User) -> str:
"""returns the name of the given users main character with name formatting
or None if user has no main
"""
from .auth_hooks import DiscordService
if user.profile.main_character:
return NameFormatter(DiscordService(), user).format_name()
else:
return None
@staticmethod
def user_group_names(user: User, state_name: str = None) -> list:
"""returns list of group names plus state the given user is a member of"""
if not state_name:
state_name = user.profile.state.name
group_names = (
[group.name for group in user.groups.all()] + [state_name]
)
logger.debug(
"Group names for roles updates of user %s are: %s", user, group_names
)
return group_names
def user_has_account(self, user: User) -> bool:
"""Returns True if the user has an Discord account, else False
only checks locally, does not hit the API
"""
if not isinstance(user, User):
return False
return self.filter(user=user).select_related('user').exists()
@classmethod
def generate_bot_add_url(cls) -> str:
params = urlencode({
'client_id': DISCORD_APP_ID,
'scope': 'bot',
'permissions': str(cls.BOT_PERMISSIONS)
})
return f'{DiscordClient.OAUTH_BASE_URL}?{params}'
@classmethod
def generate_oauth_redirect_url(cls) -> str:
oauth = OAuth2Session(
DISCORD_APP_ID, redirect_uri=DISCORD_CALLBACK_URL, scope=cls.SCOPES
)
url, state = oauth.authorization_url(DiscordClient.OAUTH_BASE_URL)
return url
@staticmethod
def _exchange_auth_code_for_token(authorization_code: str) -> str:
oauth = OAuth2Session(DISCORD_APP_ID, redirect_uri=DISCORD_CALLBACK_URL)
token = oauth.fetch_token(
DiscordClient.OAUTH_TOKEN_URL,
client_secret=DISCORD_APP_SECRET,
code=authorization_code
)
logger.debug("Received token from OAuth")
return token['access_token']
@classmethod
def server_name(cls, use_cache: bool = True) -> str:
"""returns the name of the current Discord server
or an empty string if the name could not be retrieved
Params:
- use_cache: When set False will force an API call to get the server name
"""
try:
server_name = cls._bot_client().guild_name(
guild_id=DISCORD_GUILD_ID, use_cache=use_cache
)
except (HTTPError, DiscordClientException):
server_name = ""
except Exception:
logger.warning(
"Unexpected error when trying to retrieve the server name from Discord",
exc_info=True
)
server_name = ""
return server_name
@classmethod
def group_to_role(cls, group: Group) -> dict:
"""returns the Discord role matching the given Django group by name
or an empty dict() if no matching role exist
"""
return cls._bot_client().match_role_from_name(
guild_id=DISCORD_GUILD_ID, role_name=group.name
)
@staticmethod
def _bot_client(is_rate_limited: bool = True) -> DiscordClient:
"""returns a bot client for access to the Discord API"""
return DiscordClient(DISCORD_BOT_TOKEN, is_rate_limited=is_rate_limited)

View File

@@ -0,0 +1,40 @@
# Generated by Django 2.2.12 on 2020-05-10 19:59
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('discord', '0002_service_permissions'),
]
operations = [
migrations.AddField(
model_name='discorduser',
name='activated',
field=models.DateTimeField(blank=True, default=None, help_text='Date & time this service account was activated', null=True),
),
migrations.AddField(
model_name='discorduser',
name='discriminator',
field=models.CharField(blank=True, default='', help_text="user's discriminator on Discord", max_length=4),
),
migrations.AddField(
model_name='discorduser',
name='username',
field=models.CharField(blank=True, db_index=True, default='', help_text="user's username on Discord", max_length=32),
),
migrations.AlterField(
model_name='discorduser',
name='uid',
field=models.BigIntegerField(db_index=True, help_text="user's ID on Discord"),
),
migrations.AlterField(
model_name='discorduser',
name='user',
field=models.OneToOneField(help_text='Auth user owning this Discord account', on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='discord', serialize=False, to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -1,18 +1,251 @@
import logging
from requests.exceptions import HTTPError
from django.contrib.auth.models import User
from django.db import models
from django.utils.translation import gettext_lazy
from allianceauth.notifications import notify
from . import __title__
from .app_settings import DISCORD_GUILD_ID
from .discord_client import DiscordApiBackoff, DiscordRoles
from .discord_client.helpers import match_or_create_roles_from_names
from .managers import DiscordUserManager
from .utils import LoggerAddTag
logger = LoggerAddTag(logging.getLogger(__name__), __title__)
class DiscordUser(models.Model):
user = models.OneToOneField(User,
primary_key=True,
on_delete=models.CASCADE,
related_name='discord')
uid = models.CharField(max_length=254)
def __str__(self):
return "{} - {}".format(self.user.username, self.uid)
USER_RELATED_NAME = 'discord'
user = models.OneToOneField(
User,
primary_key=True,
on_delete=models.CASCADE,
related_name=USER_RELATED_NAME,
help_text='Auth user owning this Discord account'
)
uid = models.BigIntegerField(
db_index=True,
help_text='user\'s ID on Discord'
)
username = models.CharField(
max_length=32,
default='',
blank=True,
db_index=True,
help_text='user\'s username on Discord'
)
discriminator = models.CharField(
max_length=4,
default='',
blank=True,
help_text='user\'s discriminator on Discord'
)
activated = models.DateTimeField(
default=None,
null=True,
blank=True,
help_text='Date & time this service account was activated'
)
objects = DiscordUserManager()
class Meta:
permissions = (
("access_discord", u"Can access the Discord service"),
("access_discord", "Can access the Discord service"),
)
def __str__(self):
return f'{self.user.username} - {self.uid}'
def __repr__(self):
return f'{type(self).__name__}(user=\'{self.user}\', uid={self.uid})'
def update_nickname(self, nickname: str = None) -> bool:
"""Update nickname with formatted name of main character
Params:
- nickname: optional nickname to be used instead of user's main
Returns:
- True on success
- None if user is no longer a member of the Discord server
- False on error or raises exception
"""
if not nickname:
nickname = DiscordUser.objects.user_formatted_nick(self.user)
if nickname:
client = DiscordUser.objects._bot_client()
success = client.modify_guild_member(
guild_id=DISCORD_GUILD_ID,
user_id=self.uid,
nick=nickname
)
if success:
logger.info('Nickname for %s has been updated', self.user)
else:
logger.warning('Failed to update nickname for %s', self.user)
return success
else:
return False
def update_groups(self, state_name: str = None) -> bool:
"""update groups for a user based on his current group memberships.
Will add or remove roles of a user as needed.
Params:
- state_name: optional state name to be used
Returns:
- True on success
- None if user is no longer a member of the Discord server
- False on error or raises exception
"""
client = DiscordUser.objects._bot_client()
member_info = client.guild_member(guild_id=DISCORD_GUILD_ID, user_id=self.uid)
if member_info is None:
# User is no longer a member
return None
guild_roles = DiscordRoles(client.guild_roles(guild_id=DISCORD_GUILD_ID))
logger.debug('Current guild roles: %s', guild_roles.ids())
if 'roles' in member_info:
if not guild_roles.has_roles(member_info['roles']):
guild_roles = DiscordRoles(
client.guild_roles(guild_id=DISCORD_GUILD_ID, use_cache=False)
)
if not guild_roles.has_roles(member_info['roles']):
raise RuntimeError(
'Member %s has unknown roles: %s' % (
self.user,
set(member_info['roles']).difference(guild_roles.ids())
)
)
member_roles = guild_roles.subset(member_info['roles'])
else:
raise RuntimeError('member_info from %s is not valid' % self.user)
requested_roles = match_or_create_roles_from_names(
client=client,
guild_id=DISCORD_GUILD_ID,
role_names=DiscordUser.objects.user_group_names(
user=self.user, state_name=state_name
)
)
logger.debug(
'Requested roles for user %s: %s', self.user, requested_roles.ids()
)
logger.debug('Current roles user %s: %s', self.user, member_roles.ids())
member_roles_managed = member_roles.subset(managed_only=True)
if requested_roles != member_roles.difference(member_roles_managed):
logger.debug('Need to update roles for user %s', self.user)
new_roles = requested_roles.union(member_roles_managed)
success = client.modify_guild_member(
guild_id=DISCORD_GUILD_ID,
user_id=self.uid,
role_ids=list(new_roles.ids())
)
if success:
logger.info('Roles for %s have been updated', self.user)
else:
logger.warning('Failed to update roles for %s', self.user)
return success
else:
logger.info('No need to update roles for user %s', self.user)
return True
def update_username(self) -> bool:
"""Updates the username incl. the discriminator
from the Discord server and saves it
Returns:
- True on success
- None if user is no longer a member of the Discord server
- False on error or raises exception
"""
client = DiscordUser.objects._bot_client()
user_info = client.guild_member(guild_id=DISCORD_GUILD_ID, user_id=self.uid)
if user_info is None:
success = None
elif (
user_info
and 'user' in user_info
and 'username' in user_info['user']
and 'discriminator' in user_info['user']
):
self.username = user_info['user']['username']
self.discriminator = user_info['user']['discriminator']
self.save()
logger.info('Username for %s has been updated', self.user)
success = True
else:
logger.warning('Failed to update username for %s', self.user)
success = False
return success
def delete_user(
self,
notify_user: bool = False,
is_rate_limited: bool = True,
handle_api_exceptions: bool = False
) -> bool:
"""Deletes the Discount user both on the server and locally
Params:
- notify_user: When True will sent a notification to the user
informing him about the deleting of his account
- is_rate_limited: When False will disable default rate limiting (use with care)
- handle_api_exceptions: When True method will return False
when an API exception occurs
Returns True when successful, otherwise False or raises exceptions
Return None if user does no longer exist
"""
try:
client = DiscordUser.objects._bot_client(is_rate_limited=is_rate_limited)
success = client.remove_guild_member(
guild_id=DISCORD_GUILD_ID, user_id=self.uid
)
if success is not False:
deleted_count, _ = self.delete()
if deleted_count > 0:
if notify_user:
notify(
user=self.user,
title=gettext_lazy('Discord Account Disabled'),
message=gettext_lazy(
'Your Discord account was disabeled automatically '
'by Auth. If you think this was a mistake, '
'please contact an admin.'
),
level='warning'
)
logger.info('Account for user %s was deleted.', self.user)
return True
else:
logger.debug('Account for user %s was already deleted.', self.user)
return None
else:
logger.warning(
'Failed to remove user %s from the Discord server', self.user
)
return False
except (HTTPError, ConnectionError, DiscordApiBackoff) as ex:
if handle_api_exceptions:
logger.exception(
'Failed to remove user %s from Discord server: %s', self.user, ex
)
return False
else:
raise ex

View File

@@ -1,148 +1,281 @@
import logging
from typing import Any
from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ObjectDoesNotExist
from allianceauth.notifications import notify
from celery import shared_task
from celery import shared_task, chain
from requests.exceptions import HTTPError
from allianceauth.services.hooks import NameFormatter
from .manager import DiscordOAuthManager, DiscordApiBackoff
from .models import DiscordUser
from django.contrib.auth.models import User
from django.db.models.query import QuerySet
from allianceauth.services.tasks import QueueOnce
logger = logging.getLogger(__name__)
from . import __title__
from .app_settings import (
DISCORD_TASKS_MAX_RETRIES, DISCORD_TASKS_RETRY_PAUSE, DISCORD_SYNC_NAMES
)
from .discord_client import DiscordApiBackoff
from .models import DiscordUser
from .utils import LoggerAddTag
class DiscordTasks:
def __init__(self):
pass
logger = LoggerAddTag(logging.getLogger(__name__), __title__)
@classmethod
def add_user(cls, user, code):
groups = DiscordTasks.get_groups(user)
nickname = None
if settings.DISCORD_SYNC_NAMES:
nickname = DiscordTasks.get_nickname(user)
user_id = DiscordOAuthManager.add_user(code, groups, nickname=nickname)
if user_id:
discord_user = DiscordUser()
discord_user.user = user
discord_user.uid = user_id
discord_user.save()
return True
return False
# task priority of bulk tasks
BULK_TASK_PRIORITY = 6
@classmethod
def delete_user(cls, user, notify_user=False):
if cls.has_account(user):
logger.debug("User %s has discord account %s. Deleting." % (user, user.discord.uid))
if DiscordOAuthManager.delete_user(user.discord.uid):
user.discord.delete()
if notify_user:
notify(user, 'Discord Account Disabled', level='danger')
return True
return False
@classmethod
def has_account(cls, user):
"""
Check if the user has an account (has a DiscordUser record)
:param user: django.contrib.auth.models.User
:return: bool
"""
@shared_task(
bind=True, name='discord.update_groups', base=QueueOnce, max_retries=None
)
def update_groups(self, user_pk: int, state_name: str = None) -> None:
"""Update roles on Discord for given user according to his current groups
Params:
- user_pk: PK of given user
- state_name: optional state name to be used
"""
_task_perform_user_action(self, user_pk, 'update_groups', state_name=state_name)
@shared_task(
bind=True, name='discord.update_nickname', base=QueueOnce, max_retries=None
)
def update_nickname(self, user_pk: int, nickname: str = None) -> None:
"""Set nickname on Discord for given user to his main character name
Params:
- user_pk: PK of given user
- nickname: optional nickname to be used instead of user's main
"""
_task_perform_user_action(self, user_pk, 'update_nickname', nickname=nickname)
@shared_task(
bind=True, name='discord.update_username', base=QueueOnce, max_retries=None
)
def update_username(self, user_pk: int) -> None:
"""Update locally stored Discord username from Discord server for given user
Params:
- user_pk: PK of given user
"""
_task_perform_user_action(self, user_pk, 'update_username')
@shared_task(
bind=True, name='discord.delete_user', base=QueueOnce, max_retries=None
)
def delete_user(self, user_pk: int, notify_user: bool = False) -> None:
"""Delete Discord user
Params:
- user_pk: PK of given user
"""
_task_perform_user_action(self, user_pk, 'delete_user', notify_user=notify_user)
def _task_perform_user_action(self, user_pk: int, method: str, **kwargs) -> None:
"""perform a user related action incl. managing all exceptions"""
logger.debug("Starting %s for user with pk %s", method, user_pk)
user = User.objects.get(pk=user_pk)
# logger.debug("user %s has state %s", user, user.profile.state)
if DiscordUser.objects.user_has_account(user):
logger.info("Running %s for user %s", method, user)
try:
user.discord
except ObjectDoesNotExist:
return False
success = getattr(user.discord, method)(**kwargs)
except DiscordApiBackoff as bo:
logger.info(
"API back off for %s wth user %s due to %r, retrying in %s seconds",
method,
user,
bo,
bo.retry_after_seconds
)
raise self.retry(countdown=bo.retry_after_seconds)
except AttributeError:
raise ValueError(f'{method} not a valid method for DiscordUser')
except (HTTPError, ConnectionError):
logger.warning(
'%s failed for user %s, retrying in %d secs',
method,
user,
DISCORD_TASKS_RETRY_PAUSE,
exc_info=True
)
if self.request.retries < DISCORD_TASKS_MAX_RETRIES:
raise self.retry(countdown=DISCORD_TASKS_RETRY_PAUSE)
else:
logger.error(
'%s failed for user %s after max retries',
method,
user,
exc_info=True
)
except Exception:
logger.error(
'%s for user %s failed due to unexpected exception',
method,
user,
exc_info=True
)
else:
return True
if success is None and method != 'delete_user':
delete_user.delay(user.pk, notify_user=True)
@staticmethod
@shared_task(bind=True, name='discord.update_groups', base=QueueOnce)
def update_groups(self, pk):
user = User.objects.get(pk=pk)
logger.debug("Updating discord groups for user %s" % user)
if DiscordTasks.has_account(user):
groups = DiscordTasks.get_groups(user)
logger.debug("Updating user %s discord groups to %s" % (user, groups))
try:
DiscordOAuthManager.update_groups(user.discord.uid, groups)
except DiscordApiBackoff as bo:
logger.info("Discord group sync API back off for %s, "
"retrying in %s seconds" % (user, bo.retry_after_seconds))
raise self.retry(countdown=bo.retry_after_seconds)
except HTTPError as e:
if e.response.status_code == 404:
try:
if e.response.json()['code'] == 10007:
# user has left the server
DiscordTasks.delete_user(user)
return
finally:
raise e
except Exception as e:
if self:
logger.exception("Discord group sync failed for %s, retrying in 10 mins" % user)
raise self.retry(countdown=60 * 10)
else:
# Rethrow
raise e
logger.debug("Updated user %s discord groups." % user)
else:
logger.debug("User does not have a discord account, skipping")
else:
logger.debug(
'User %s does not have a discord account, skipping %s', user, method
)
@staticmethod
@shared_task(name='discord.update_all_groups')
def update_all_groups():
logger.debug("Updating ALL discord groups")
for discord_user in DiscordUser.objects.exclude(uid__exact=''):
DiscordTasks.update_groups.delay(discord_user.user.pk)
@staticmethod
@shared_task(bind=True, name='discord.update_nickname', base=QueueOnce)
def update_nickname(self, pk):
user = User.objects.get(pk=pk)
logger.debug("Updating discord nickname for user %s" % user)
if DiscordTasks.has_account(user):
if user.profile.main_character:
character = user.profile.main_character
logger.debug("Updating user %s discord nickname to %s" % (user, character.character_name))
try:
DiscordOAuthManager.update_nickname(user.discord.uid, DiscordTasks.get_nickname(user))
except DiscordApiBackoff as bo:
logger.info("Discord nickname update API back off for %s, "
"retrying in %s seconds" % (user, bo.retry_after_seconds))
raise self.retry(countdown=bo.retry_after_seconds)
except Exception as e:
if self:
logger.exception("Discord nickname sync failed for %s, retrying in 10 mins" % user)
raise self.retry(countdown=60 * 10)
else:
# Rethrow
raise e
logger.debug("Updated user %s discord nickname." % user)
else:
logger.debug("User %s does not have a main character" % user)
else:
logger.debug("User %s does not have a discord account" % user)
@shared_task(name='discord.update_all_groups')
def update_all_groups() -> None:
"""Update roles for all known users with a Discord account."""
discord_users_qs = DiscordUser.objects.all()
_bulk_update_groups_for_users(discord_users_qs)
@staticmethod
@shared_task(name='discord.update_all_nicknames')
def update_all_nicknames():
logger.debug("Updating ALL discord nicknames")
for discord_user in DiscordUser.objects.exclude(uid__exact=''):
DiscordTasks.update_nickname.delay(discord_user.user.pk)
@classmethod
def disable(cls):
DiscordUser.objects.all().delete()
@shared_task(name='discord.update_groups_bulk')
def update_groups_bulk(user_pks: list) -> None:
"""Update roles for list of users with a Discord account in bulk."""
discord_users_qs = DiscordUser.objects\
.filter(user__pk__in=user_pks)\
.select_related()
_bulk_update_groups_for_users(discord_users_qs)
@staticmethod
def get_nickname(user):
from .auth_hooks import DiscordService
return NameFormatter(DiscordService(), user).format_name()
@staticmethod
def get_groups(user):
return [g.name for g in user.groups.all()] + [user.profile.state.name]
def _bulk_update_groups_for_users(discord_users_qs: QuerySet) -> None:
logger.info(
"Starting to bulk update discord roles for %d users", discord_users_qs.count()
)
update_groups_chain = list()
for discord_user in discord_users_qs:
update_groups_chain.append(update_groups.si(discord_user.user.pk))
chain(update_groups_chain).apply_async(priority=BULK_TASK_PRIORITY)
@shared_task(name='discord.update_all_nicknames')
def update_all_nicknames() -> None:
"""Update nicknames for all known users with a Discord account."""
discord_users_qs = DiscordUser.objects.all()
_bulk_update_nicknames_for_users(discord_users_qs)
@shared_task(name='discord.update_nicknames_bulk')
def update_nicknames_bulk(user_pks: list) -> None:
"""Update nicknames for list of users with a Discord account in bulk."""
discord_users_qs = DiscordUser.objects\
.filter(user__pk__in=user_pks)\
.select_related()
_bulk_update_nicknames_for_users(discord_users_qs)
def _bulk_update_nicknames_for_users(discord_users_qs: QuerySet) -> None:
logger.info(
"Starting to bulk update discord nicknames for %d users",
discord_users_qs.count()
)
update_nicknames_chain = list()
for discord_user in discord_users_qs:
update_nicknames_chain.append(update_nickname.si(discord_user.user.pk))
chain(update_nicknames_chain).apply_async(priority=BULK_TASK_PRIORITY)
def _task_perform_users_action(self, method: str, **kwargs) -> Any:
"""Perform an action that concerns a group of users or the whole server
and that hits the API
"""
result = None
try:
result = getattr(DiscordUser.objects, method)(**kwargs)
except AttributeError:
raise ValueError(f'{method} not a valid method for DiscordUser.objects')
except DiscordApiBackoff as bo:
logger.info(
"API back off for %s due to %r, retrying in %s seconds",
method,
bo,
bo.retry_after_seconds
)
raise self.retry(countdown=bo.retry_after_seconds)
except (HTTPError, ConnectionError):
logger.warning(
'%s failed, retrying in %d secs',
method,
DISCORD_TASKS_RETRY_PAUSE,
exc_info=True
)
if self.request.retries < DISCORD_TASKS_MAX_RETRIES:
raise self.retry(countdown=DISCORD_TASKS_RETRY_PAUSE)
else:
logger.error('%s failed after max retries', method, exc_info=True)
except Exception:
logger.error('%s failed due to unexpected exception', method, exc_info=True)
return result
@shared_task(
bind=True, name='discord.update_servername', base=QueueOnce, max_retries=None
)
def update_servername(self) -> None:
"""Updates the Discord server name"""
_task_perform_users_action(self, method="server_name", use_cache=False)
@shared_task(name='discord.update_all_usernames')
def update_all_usernames() -> None:
"""Update all usernames for all known users with a Discord account.
Also updates the server name
"""
update_servername.delay()
discord_users_qs = DiscordUser.objects.all()
_bulk_update_usernames_for_users(discord_users_qs)
@shared_task(name='discord.update_usernames_bulk')
def update_usernames_bulk(user_pks: list) -> None:
"""Update usernames for list of users with a Discord account in bulk."""
discord_users_qs = DiscordUser.objects\
.filter(user__pk__in=user_pks)\
.select_related()
_bulk_update_usernames_for_users(discord_users_qs)
def _bulk_update_usernames_for_users(discord_users_qs: QuerySet) -> None:
logger.info(
"Starting to bulk update discord usernames for %d users",
discord_users_qs.count()
)
update_usernames_chain = list()
for discord_user in discord_users_qs:
update_usernames_chain.append(update_username.si(discord_user.user.pk))
chain(update_usernames_chain).apply_async(priority=BULK_TASK_PRIORITY)
@shared_task(name='discord.update_all')
def update_all() -> None:
"""Updates groups and nicknames (when activated) for all users."""
discord_users_qs = DiscordUser.objects.all()
logger.info(
'Starting to bulk update all for %s Discord users', discord_users_qs.count()
)
update_all_chain = list()
for discord_user in discord_users_qs:
update_all_chain.append(update_groups.si(discord_user.user.pk))
update_all_chain.append(update_username.si(discord_user.user.pk))
if DISCORD_SYNC_NAMES:
update_all_chain.append(update_nickname.si(discord_user.user.pk))
chain(update_all_chain).apply_async(priority=BULK_TASK_PRIORITY)

View File

@@ -3,24 +3,34 @@
<tr>
<td class="text-center">Discord</td>
<td class="text-center"></td>
<td class="text-center"><a href="https://discordapp.com/channels/{{ DISCORD_SERVER_ID }}/{{ DISCORD_SERVER_ID}}">https://discordapp.com</a></td>
<td class="text-center">
{% if not discord_uid %}
<a href="{% url 'discord:activate' %}" title="Activate" class="btn btn-warning">
{% if not user_has_account %}
(not activated)
{% else %}
{{discord_username}}
{% endif %}
</td>
<td class="text-center">
{{server_name}}
</td>
<td class="text-center">
{% if not user_has_account %}
<a href="{% url 'discord:activate' %}" title="{% trans 'Join the Discord server' %}" class="btn btn-primary">
<span class="glyphicon glyphicon-ok"></span>
</a>
{% else %}
<a href="{% url 'discord:reset' %}" title="Reset" class="btn btn-primary">
<a href="{% url 'discord:reset' %}" title="{% trans 'Leave- and rejoin the Discord Server (Reset)' %}" class="btn btn-warning">
<span class="glyphicon glyphicon-refresh"></span>
</a>
<a href="{% url 'discord:deactivate' %}" title="Deactivate" class="btn btn-danger">
<a href="{% url 'discord:deactivate' %}" title="{% trans 'Leave the Discord server' %}" class="btn btn-danger">
<span class="glyphicon glyphicon-remove"></span>
</a>
{% endif %}
{% if request.user.is_superuser %}
<div class="text-center" style="padding-top:5px;">
<a type="button" class="btn btn-success" href="{% url 'discord:add_bot' %}">{% trans "Link Discord Server" %}</a>
<a type="button" id="btnLinkDiscordServer" class="btn btn-default" href="{% url 'discord:add_bot' %}">
{% trans "Link Discord Server" %}
</a>
</div>
{% endif %}
</td>

View File

@@ -1,10 +1,27 @@
from django.contrib.auth.models import User, Group, Permission
from django.contrib.auth.models import Group
from allianceauth.tests.auth_utils import AuthUtils
from ..discord_client.tests import ( # noqa
TEST_GUILD_ID,
TEST_USER_ID,
TEST_USER_NAME,
TEST_USER_DISCRIMINATOR,
create_role,
ROLE_ALPHA,
ROLE_BRAVO,
ROLE_CHARLIE,
ROLE_MIKE,
ALL_ROLES,
create_user_info
)
DEFAULT_AUTH_GROUP = 'Member'
MODULE_PATH = 'allianceauth.services.modules.discord'
def add_permissions():
permission = Permission.objects.get(codename='access_discord')
TEST_MAIN_NAME = 'Spiderman'
TEST_MAIN_ID = 1005
def add_permissions_to_members():
permission = AuthUtils.get_permission_by_name('discord.access_discord')
members = Group.objects.get_or_create(name=DEFAULT_AUTH_GROUP)[0]
AuthUtils.add_permissions_to_groups([permission], [members])

View File

@@ -0,0 +1,83 @@
# flake8: noqa
"""Concurrency testing Discord service tasks
This script will run many Discord service tasks in parallel to test concurrency
Note that it will run against your main Auth database and not test!
Check allianceauth.log for the results.
To run this test start a bunch of celery workers and then run this script directly.
Make sure to also set the environment variable AUTH_PROJECT_PATH to your Auth path
and DJANGO_SETTINGS_MODULE to the relative location of your settings:
Example:
export AUTH_PROJECT_PATH="/home/erik997/dev/python/aa/allianceauth-dev/myauth"
export DJANGO_SETTINGS_MODULE="myauth.settings.local"
Careful: This script will utilize all existing Discord users and make changes!
"""
# start django project
import os
import sys
if not 'AUTH_PROJECT_PATH' in os.environ:
print('AUTH_PROJECT_PATH is not set')
exit(1)
if not 'DJANGO_SETTINGS_MODULE' in os.environ:
print('DJANGO_SETTINGS_MODULE is not set')
exit(1)
sys.path.insert(0, os.environ['AUTH_PROJECT_PATH'])
import django
django.setup()
# normal imports
import logging
from uuid import uuid1
import random
from django.core.cache import caches
from django.contrib.auth.models import User, Group
from allianceauth.services.modules.discord.models import DiscordUser
logger = logging.getLogger('allianceauth')
MAX_RUNS = 3
def clear_cache():
default_cache = caches['default']
redis = default_cache.get_master_client()
redis.flushall()
logger.info('Cache flushed')
def run_many_updates(runs):
logger.info('Starting piloting_tasks for %d runs', runs)
users = list()
all_groups = Group.objects.all()
for i in range(runs):
if not users:
users = list(User.objects.filter(discord__isnull=False))
user = users.pop()
logger.info('%d/%d: Starting run with user %s', i + 1, runs, user)
# force change of nick
new_nick = f'Testnick {uuid1().hex}'[:32]
logger.info(
'%d/%d: Changing nickname of %s to "%s"', i + 1, runs, user, new_nick
)
user.profile.main_character.character_name = new_nick
user.profile.main_character.save()
# force change of groups
user_groups = user.groups.all()
user.groups.remove(random.choice(user_groups))
while True:
new_group = random.choice(all_groups)
if new_group not in user_groups:
break
logger.info('%d/%d: Adding group "%s" to user %s', i + 1, runs, new_group, user)
user.groups.add(new_group)
logger.info('All %d runs have been started', runs)
if __name__ == "__main__":
clear_cache()
run_many_updates(MAX_RUNS)

View File

@@ -1,9 +1,7 @@
from unittest.mock import patch
from django.test import TestCase, RequestFactory
from django.contrib import admin
from django.contrib.admin.sites import AdminSite
from django.contrib.auth.models import User
from django.utils.timezone import now
from allianceauth.authentication.models import CharacterOwnership
from allianceauth.eveonline.models import (
@@ -18,47 +16,51 @@ from ....admin import (
MainCorporationsFilter,
MainAllianceFilter
)
from ..admin import (
DiscordUser,
DiscordUserAdmin
)
from ..admin import DiscordUserAdmin
from ..models import DiscordUser
class TestDiscordUserAdmin(TestCase):
class TestDataMixin(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
EveCharacter.objects.all().delete()
EveCorporationInfo.objects.all().delete()
EveAllianceInfo.objects.all().delete()
User.objects.all().delete()
DiscordUser.objects.all().delete()
# user 1 - corp and alliance, normal user
cls.character_1 = EveCharacter.objects.create(
character_id='1001',
character_id=1001,
character_name='Bruce Wayne',
corporation_id='2001',
corporation_id=2001,
corporation_name='Wayne Technologies',
corporation_ticker='WT',
alliance_id='3001',
alliance_id=3001,
alliance_name='Wayne Enterprises',
alliance_ticker='WE',
)
cls.character_1a = EveCharacter.objects.create(
character_id='1002',
character_id=1002,
character_name='Batman',
corporation_id='2001',
corporation_id=2001,
corporation_name='Wayne Technologies',
corporation_ticker='WT',
alliance_id='3001',
alliance_id=3001,
alliance_name='Wayne Enterprises',
alliance_ticker='WE',
)
alliance = EveAllianceInfo.objects.create(
alliance_id='3001',
alliance_id=3001,
alliance_name='Wayne Enterprises',
alliance_ticker='WE',
executor_corp_id='2001'
executor_corp_id=2001
)
EveCorporationInfo.objects.create(
corporation_id='2001',
corporation_id=2001,
corporation_name='Wayne Technologies',
corporation_ticker='WT',
member_count=42,
@@ -83,7 +85,10 @@ class TestDiscordUserAdmin(TestCase):
cls.user_1.profile.save()
DiscordUser.objects.create(
user=cls.user_1,
uid=1001
uid=1001,
username='Bruce Wayne',
discriminator='1234',
activated=now()
)
# user 2 - corp only, staff
@@ -136,10 +141,10 @@ class TestDiscordUserAdmin(TestCase):
alliance=None
)
EveAllianceInfo.objects.create(
alliance_id='3101',
alliance_id=3101,
alliance_name='Lex World Domination',
alliance_ticker='LWD',
executor_corp_id=''
executor_corp_id=2101
)
cls.user_3 = User.objects.create_user(
cls.character_3.character_name.replace(' ', '_'),
@@ -156,18 +161,20 @@ class TestDiscordUserAdmin(TestCase):
uid=1003
)
def setUp(self):
self.factory = RequestFactory()
self.modeladmin = DiscordUserAdmin(
model=DiscordUser, admin_site=AdminSite()
)
# column rendering
class TestColumnRendering(TestDataMixin, TestCase):
def test_user_profile_pic_u1(self):
expected = ('<img src="https://images.evetech.net/characters/1001/'
'portrait?size=32" class="img-circle">')
expected = (
'<img src="https://images.evetech.net/characters/1001/'
'portrait?size=32" class="img-circle">'
)
self.assertEqual(user_profile_pic(self.user_1.discord), expected)
def test_user_profile_pic_u3(self):
@@ -204,9 +211,26 @@ class TestDiscordUserAdmin(TestCase):
result = user_main_organization(self.user_3.discord)
self.assertEqual(result, expected)
def test_uid(self):
expected = 1001
result = self.modeladmin._uid(self.user_1.discord)
self.assertEqual(result, expected)
def test_username_when_defined(self):
expected = 'Bruce Wayne#1234'
result = self.modeladmin._username(self.user_1.discord)
self.assertEqual(result, expected)
def test_username_when_not_defined(self):
expected = ''
result = self.modeladmin._username(self.user_2.discord)
self.assertEqual(result, expected)
# actions
# filters
class TestFilters(TestDataMixin, TestCase):
def test_filter_main_corporations(self):
class DiscordUserAdminTest(ServicesUserAdmin):
@@ -221,15 +245,14 @@ class TestDiscordUserAdmin(TestCase):
filters = changelist.get_filters(request)
filterspec = filters[0][0]
expected = [
('2002', 'Daily Planet'),
('2001', 'Wayne Technologies'),
(2002, 'Daily Planet'),
(2001, 'Wayne Technologies'),
]
self.assertEqual(filterspec.lookup_choices, expected)
# Make sure the correct queryset is returned
request = self.factory.get(
'/',
{'main_corporation_id__exact': self.character_1.corporation_id}
'/', {'main_corporation_id__exact': self.character_1.corporation_id}
)
request.user = self.user_1
changelist = my_modeladmin.get_changelist_instance(request)
@@ -250,19 +273,17 @@ class TestDiscordUserAdmin(TestCase):
changelist = my_modeladmin.get_changelist_instance(request)
filters = changelist.get_filters(request)
filterspec = filters[0][0]
expected = [
('3001', 'Wayne Enterprises'),
expected = [
(3001, 'Wayne Enterprises'),
]
self.assertEqual(filterspec.lookup_choices, expected)
# Make sure the correct queryset is returned
request = self.factory.get(
'/',
{'main_alliance_id__exact': self.character_1.alliance_id}
'/', {'main_alliance_id__exact': self.character_1.alliance_id}
)
request.user = self.user_1
changelist = my_modeladmin.get_changelist_instance(request)
queryset = changelist.get_queryset(request)
expected = [self.user_1.discord]
self.assertSetEqual(set(queryset), set(expected))

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