mirror of
https://gitlab.com/allianceauth/allianceauth.git
synced 2026-02-04 06:06:19 +01:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
def6431052 | ||
|
|
22a270aedb | ||
|
|
c930f7bbeb | ||
|
|
64ee273953 | ||
|
|
3706a1aedf | ||
|
|
47f1b77320 | ||
|
|
8dec242a93 | ||
|
|
2ff200c566 | ||
|
|
091a2637ea | ||
|
|
113977b19f | ||
|
|
8f39b50b6d | ||
|
|
95b309c358 | ||
|
|
cf3df3b715 | ||
|
|
d815028c4d | ||
|
|
ac5570abe2 | ||
|
|
84ad571aa4 | ||
|
|
38e7705ae7 | ||
|
|
0b6af014fa | ||
|
|
2401f2299d | ||
|
|
919768c8bb | ||
|
|
24db21463b | ||
|
|
1e029af83a | ||
|
|
2b31be789d | ||
|
|
bf1b4bb549 | ||
|
|
dd42b807f0 | ||
|
|
542fbafd98 | ||
|
|
37b9f5c882 | ||
|
|
5bde9a6952 | ||
|
|
23ad9d02d3 | ||
|
|
f99878cc29 | ||
|
|
e64431b06c | ||
|
|
0b2993c1c3 | ||
|
|
75bccf1b0f | ||
|
|
945bc92898 | ||
|
|
ec7d14a839 | ||
|
|
dd1a368ff6 | ||
|
|
54085617dc | ||
|
|
8cdc5af453 | ||
|
|
da93940e13 | ||
|
|
f53b43d9dc | ||
|
|
497a167ca7 | ||
|
|
852c5a3037 | ||
|
|
90f6777a7a | ||
|
|
a8d890abaf | ||
|
|
79379b444c | ||
|
|
ace1de5c68 | ||
|
|
5d6128e9ea |
@@ -54,7 +54,9 @@ test-3.7-core:
|
||||
artifacts:
|
||||
when: always
|
||||
reports:
|
||||
cobertura: coverage.xml
|
||||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: coverage.xml
|
||||
|
||||
test-3.8-core:
|
||||
<<: *only-default
|
||||
@@ -64,7 +66,9 @@ test-3.8-core:
|
||||
artifacts:
|
||||
when: always
|
||||
reports:
|
||||
cobertura: coverage.xml
|
||||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: coverage.xml
|
||||
|
||||
test-3.9-core:
|
||||
<<: *only-default
|
||||
@@ -74,7 +78,9 @@ test-3.9-core:
|
||||
artifacts:
|
||||
when: always
|
||||
reports:
|
||||
cobertura: coverage.xml
|
||||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: coverage.xml
|
||||
|
||||
test-3.10-core:
|
||||
<<: *only-default
|
||||
@@ -84,7 +90,9 @@ test-3.10-core:
|
||||
artifacts:
|
||||
when: always
|
||||
reports:
|
||||
cobertura: coverage.xml
|
||||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: coverage.xml
|
||||
|
||||
test-3.11-core:
|
||||
<<: *only-default
|
||||
@@ -94,7 +102,9 @@ test-3.11-core:
|
||||
artifacts:
|
||||
when: always
|
||||
reports:
|
||||
cobertura: coverage.xml
|
||||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: coverage.xml
|
||||
allow_failure: true
|
||||
|
||||
test-3.7-all:
|
||||
@@ -105,7 +115,9 @@ test-3.7-all:
|
||||
artifacts:
|
||||
when: always
|
||||
reports:
|
||||
cobertura: coverage.xml
|
||||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: coverage.xml
|
||||
|
||||
test-3.8-all:
|
||||
<<: *only-default
|
||||
@@ -115,7 +127,9 @@ test-3.8-all:
|
||||
artifacts:
|
||||
when: always
|
||||
reports:
|
||||
cobertura: coverage.xml
|
||||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: coverage.xml
|
||||
|
||||
test-3.9-all:
|
||||
<<: *only-default
|
||||
@@ -125,7 +139,9 @@ test-3.9-all:
|
||||
artifacts:
|
||||
when: always
|
||||
reports:
|
||||
cobertura: coverage.xml
|
||||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: coverage.xml
|
||||
|
||||
test-3.10-all:
|
||||
<<: *only-default
|
||||
@@ -135,7 +151,9 @@ test-3.10-all:
|
||||
artifacts:
|
||||
when: always
|
||||
reports:
|
||||
cobertura: coverage.xml
|
||||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: coverage.xml
|
||||
|
||||
test-3.11-all:
|
||||
<<: *only-default
|
||||
@@ -145,9 +163,17 @@ test-3.11-all:
|
||||
artifacts:
|
||||
when: always
|
||||
reports:
|
||||
cobertura: coverage.xml
|
||||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: coverage.xml
|
||||
allow_failure: true
|
||||
|
||||
test-docs:
|
||||
<<: *only-default
|
||||
image: python:3.9-bullseye
|
||||
script:
|
||||
- tox -e docs
|
||||
|
||||
deploy_production:
|
||||
stage: deploy
|
||||
image: python:3.10-bullseye
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# This will make sure the app is always imported when
|
||||
# Django starts so that shared_task will use this app.
|
||||
|
||||
__version__ = '2.11.0'
|
||||
__version__ = '2.14.0'
|
||||
__title__ = 'Alliance Auth'
|
||||
__url__ = 'https://gitlab.com/allianceauth/allianceauth'
|
||||
NAME = f'{__title__} v{__version__}'
|
||||
|
||||
@@ -1,26 +1,44 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||
from django.contrib.auth.models import User as BaseUser, \
|
||||
Permission as BasePermission, Group
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import Permission as BasePermission
|
||||
from django.contrib.auth.models import User as BaseUser
|
||||
from django.db.models import Count, Q
|
||||
from allianceauth.services.hooks import ServicesHook
|
||||
from django.db.models.signals import pre_save, post_save, pre_delete, \
|
||||
post_delete, m2m_changed
|
||||
from django.db.models.functions import Lower
|
||||
from django.db.models.signals import (
|
||||
m2m_changed,
|
||||
post_delete,
|
||||
post_save,
|
||||
pre_delete,
|
||||
pre_save
|
||||
)
|
||||
from django.dispatch import receiver
|
||||
from django.forms import ModelForm
|
||||
from django.utils.html import format_html
|
||||
from django.urls import reverse
|
||||
from django.utils.html import format_html
|
||||
from django.utils.text import slugify
|
||||
|
||||
from allianceauth.authentication.models import State, get_guest_state,\
|
||||
CharacterOwnership, UserProfile, OwnershipRecord
|
||||
from allianceauth.hooks import get_hooks
|
||||
from allianceauth.eveonline.models import EveCharacter, EveCorporationInfo,\
|
||||
EveAllianceInfo, EveFactionInfo
|
||||
from allianceauth.authentication.models import (
|
||||
CharacterOwnership,
|
||||
OwnershipRecord,
|
||||
State,
|
||||
UserProfile,
|
||||
get_guest_state
|
||||
)
|
||||
from allianceauth.eveonline.models import (
|
||||
EveAllianceInfo,
|
||||
EveCharacter,
|
||||
EveCorporationInfo,
|
||||
EveFactionInfo
|
||||
)
|
||||
from allianceauth.eveonline.tasks import update_character
|
||||
from .app_settings import AUTHENTICATION_ADMIN_USERS_MAX_GROUPS, \
|
||||
AUTHENTICATION_ADMIN_USERS_MAX_CHARS
|
||||
from allianceauth.hooks import get_hooks
|
||||
from allianceauth.services.hooks import ServicesHook
|
||||
|
||||
from .app_settings import (
|
||||
AUTHENTICATION_ADMIN_USERS_MAX_CHARS,
|
||||
AUTHENTICATION_ADMIN_USERS_MAX_GROUPS
|
||||
)
|
||||
from .forms import UserChangeForm, UserProfileForm
|
||||
|
||||
|
||||
def make_service_hooks_update_groups_action(service):
|
||||
@@ -59,19 +77,10 @@ def make_service_hooks_sync_nickname_action(service):
|
||||
return sync_nickname
|
||||
|
||||
|
||||
class QuerysetModelForm(ModelForm):
|
||||
# allows specifying FK querysets through kwarg
|
||||
def __init__(self, querysets=None, *args, **kwargs):
|
||||
querysets = querysets or {}
|
||||
super().__init__(*args, **kwargs)
|
||||
for field, qs in querysets.items():
|
||||
self.fields[field].queryset = qs
|
||||
|
||||
|
||||
class UserProfileInline(admin.StackedInline):
|
||||
model = UserProfile
|
||||
readonly_fields = ('state',)
|
||||
form = QuerysetModelForm
|
||||
form = UserProfileForm
|
||||
verbose_name = ''
|
||||
verbose_name_plural = 'Profile'
|
||||
|
||||
@@ -99,6 +108,7 @@ class UserProfileInline(admin.StackedInline):
|
||||
return False
|
||||
|
||||
|
||||
@admin.display(description="")
|
||||
def user_profile_pic(obj):
|
||||
"""profile pic column data for user objects
|
||||
|
||||
@@ -111,13 +121,10 @@ def user_profile_pic(obj):
|
||||
'<img src="{}" class="img-circle">',
|
||||
user_obj.profile.main_character.portrait_url(size=32)
|
||||
)
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
user_profile_pic.short_description = ''
|
||||
return None
|
||||
|
||||
|
||||
@admin.display(description="user / main", ordering="username")
|
||||
def user_username(obj):
|
||||
"""user column data for user objects
|
||||
|
||||
@@ -139,18 +146,17 @@ def user_username(obj):
|
||||
user_obj.username,
|
||||
user_obj.profile.main_character.character_name
|
||||
)
|
||||
else:
|
||||
return format_html(
|
||||
'<strong><a href="{}">{}</a></strong>',
|
||||
link,
|
||||
user_obj.username,
|
||||
)
|
||||
|
||||
|
||||
user_username.short_description = 'user / main'
|
||||
user_username.admin_order_field = 'username'
|
||||
return format_html(
|
||||
'<strong><a href="{}">{}</a></strong>',
|
||||
link,
|
||||
user_obj.username,
|
||||
)
|
||||
|
||||
|
||||
@admin.display(
|
||||
description="Corporation / Alliance (Main)",
|
||||
ordering="profile__main_character__corporation_name"
|
||||
)
|
||||
def user_main_organization(obj):
|
||||
"""main organization column data for user objects
|
||||
|
||||
@@ -159,21 +165,15 @@ def user_main_organization(obj):
|
||||
"""
|
||||
user_obj = obj.user if hasattr(obj, 'user') else obj
|
||||
if not user_obj.profile.main_character:
|
||||
result = ''
|
||||
else:
|
||||
result = user_obj.profile.main_character.corporation_name
|
||||
if user_obj.profile.main_character.alliance_id:
|
||||
result += f'<br>{user_obj.profile.main_character.alliance_name}'
|
||||
elif user_obj.profile.main_character.faction_name:
|
||||
result += f'<br>{user_obj.profile.main_character.faction_name}'
|
||||
return ''
|
||||
result = user_obj.profile.main_character.corporation_name
|
||||
if user_obj.profile.main_character.alliance_id:
|
||||
result += f'<br>{user_obj.profile.main_character.alliance_name}'
|
||||
elif user_obj.profile.main_character.faction_name:
|
||||
result += f'<br>{user_obj.profile.main_character.faction_name}'
|
||||
return format_html(result)
|
||||
|
||||
|
||||
user_main_organization.short_description = 'Corporation / Alliance (Main)'
|
||||
user_main_organization.admin_order_field = \
|
||||
'profile__main_character__corporation_name'
|
||||
|
||||
|
||||
class MainCorporationsFilter(admin.SimpleListFilter):
|
||||
"""Custom filter to filter on corporations from mains only
|
||||
|
||||
@@ -196,15 +196,13 @@ class MainCorporationsFilter(admin.SimpleListFilter):
|
||||
def queryset(self, request, qs):
|
||||
if self.value() is None:
|
||||
return qs.all()
|
||||
else:
|
||||
if qs.model == User:
|
||||
return qs.filter(
|
||||
profile__main_character__corporation_id=self.value()
|
||||
)
|
||||
else:
|
||||
return qs.filter(
|
||||
user__profile__main_character__corporation_id=self.value()
|
||||
)
|
||||
if qs.model == User:
|
||||
return qs.filter(
|
||||
profile__main_character__corporation_id=self.value()
|
||||
)
|
||||
return qs.filter(
|
||||
user__profile__main_character__corporation_id=self.value()
|
||||
)
|
||||
|
||||
|
||||
class MainAllianceFilter(admin.SimpleListFilter):
|
||||
@@ -217,12 +215,14 @@ class MainAllianceFilter(admin.SimpleListFilter):
|
||||
parameter_name = 'main_alliance_id__exact'
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
qs = EveCharacter.objects\
|
||||
.exclude(alliance_id=None)\
|
||||
.exclude(userprofile=None)\
|
||||
.values('alliance_id', 'alliance_name')\
|
||||
.distinct()\
|
||||
qs = (
|
||||
EveCharacter.objects
|
||||
.exclude(alliance_id=None)
|
||||
.exclude(userprofile=None)
|
||||
.values('alliance_id', 'alliance_name')
|
||||
.distinct()
|
||||
.order_by(Lower('alliance_name'))
|
||||
)
|
||||
return tuple(
|
||||
(x['alliance_id'], x['alliance_name']) for x in qs
|
||||
)
|
||||
@@ -230,13 +230,11 @@ class MainAllianceFilter(admin.SimpleListFilter):
|
||||
def queryset(self, request, qs):
|
||||
if self.value() is None:
|
||||
return qs.all()
|
||||
else:
|
||||
if qs.model == User:
|
||||
return qs.filter(profile__main_character__alliance_id=self.value())
|
||||
else:
|
||||
return qs.filter(
|
||||
user__profile__main_character__alliance_id=self.value()
|
||||
)
|
||||
if qs.model == User:
|
||||
return qs.filter(profile__main_character__alliance_id=self.value())
|
||||
return qs.filter(
|
||||
user__profile__main_character__alliance_id=self.value()
|
||||
)
|
||||
|
||||
|
||||
class MainFactionFilter(admin.SimpleListFilter):
|
||||
@@ -249,12 +247,14 @@ class MainFactionFilter(admin.SimpleListFilter):
|
||||
parameter_name = 'main_faction_id__exact'
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
qs = EveCharacter.objects\
|
||||
.exclude(faction_id=None)\
|
||||
.exclude(userprofile=None)\
|
||||
.values('faction_id', 'faction_name')\
|
||||
.distinct()\
|
||||
qs = (
|
||||
EveCharacter.objects
|
||||
.exclude(faction_id=None)
|
||||
.exclude(userprofile=None)
|
||||
.values('faction_id', 'faction_name')
|
||||
.distinct()
|
||||
.order_by(Lower('faction_name'))
|
||||
)
|
||||
return tuple(
|
||||
(x['faction_id'], x['faction_name']) for x in qs
|
||||
)
|
||||
@@ -262,15 +262,14 @@ class MainFactionFilter(admin.SimpleListFilter):
|
||||
def queryset(self, request, qs):
|
||||
if self.value() is None:
|
||||
return qs.all()
|
||||
else:
|
||||
if qs.model == User:
|
||||
return qs.filter(profile__main_character__faction_id=self.value())
|
||||
else:
|
||||
return qs.filter(
|
||||
user__profile__main_character__faction_id=self.value()
|
||||
)
|
||||
if qs.model == User:
|
||||
return qs.filter(profile__main_character__faction_id=self.value())
|
||||
return qs.filter(
|
||||
user__profile__main_character__faction_id=self.value()
|
||||
)
|
||||
|
||||
|
||||
@admin.display(description="Update main character model from ESI")
|
||||
def update_main_character_model(modeladmin, request, queryset):
|
||||
tasks_count = 0
|
||||
for obj in queryset:
|
||||
@@ -279,21 +278,48 @@ def update_main_character_model(modeladmin, request, queryset):
|
||||
tasks_count += 1
|
||||
|
||||
modeladmin.message_user(
|
||||
request,
|
||||
f'Update from ESI started for {tasks_count} characters'
|
||||
request, f'Update from ESI started for {tasks_count} characters'
|
||||
)
|
||||
|
||||
|
||||
update_main_character_model.short_description = \
|
||||
'Update main character model from ESI'
|
||||
|
||||
|
||||
class UserAdmin(BaseUserAdmin):
|
||||
"""Extending Django's UserAdmin model
|
||||
|
||||
Behavior of groups and characters columns can be configured via settings
|
||||
"""
|
||||
|
||||
inlines = BaseUserAdmin.inlines + [UserProfileInline]
|
||||
ordering = ('username', )
|
||||
list_select_related = ('profile__state', 'profile__main_character')
|
||||
show_full_result_count = True
|
||||
list_display = (
|
||||
user_profile_pic,
|
||||
user_username,
|
||||
'_state',
|
||||
'_groups',
|
||||
user_main_organization,
|
||||
'_characters',
|
||||
'is_active',
|
||||
'date_joined',
|
||||
'_role'
|
||||
)
|
||||
list_display_links = None
|
||||
list_filter = (
|
||||
'profile__state',
|
||||
'groups',
|
||||
MainCorporationsFilter,
|
||||
MainAllianceFilter,
|
||||
MainFactionFilter,
|
||||
'is_active',
|
||||
'date_joined',
|
||||
'is_staff',
|
||||
'is_superuser'
|
||||
)
|
||||
search_fields = ('username', 'character_ownerships__character__character_name')
|
||||
readonly_fields = ('date_joined', 'last_login')
|
||||
filter_horizontal = ('groups', 'user_permissions',)
|
||||
form = UserChangeForm
|
||||
|
||||
class Media:
|
||||
css = {
|
||||
"all": ("authentication/css/admin.css",)
|
||||
@@ -303,9 +329,21 @@ class UserAdmin(BaseUserAdmin):
|
||||
qs = super().get_queryset(request)
|
||||
return qs.prefetch_related("character_ownerships__character", "groups")
|
||||
|
||||
def get_actions(self, request):
|
||||
actions = super(BaseUserAdmin, self).get_actions(request)
|
||||
def get_form(self, request, obj=None, **kwargs):
|
||||
"""Inject current request into change form object."""
|
||||
|
||||
MyForm = super().get_form(request, obj, **kwargs)
|
||||
if obj:
|
||||
class MyFormInjected(MyForm):
|
||||
def __new__(cls, *args, **kwargs):
|
||||
kwargs['request'] = request
|
||||
return MyForm(*args, **kwargs)
|
||||
|
||||
return MyFormInjected
|
||||
return MyForm
|
||||
|
||||
def get_actions(self, request):
|
||||
actions = super().get_actions(request)
|
||||
actions[update_main_character_model.__name__] = (
|
||||
update_main_character_model,
|
||||
update_main_character_model.__name__,
|
||||
@@ -349,39 +387,6 @@ class UserAdmin(BaseUserAdmin):
|
||||
)
|
||||
return result
|
||||
|
||||
inlines = BaseUserAdmin.inlines + [UserProfileInline]
|
||||
ordering = ('username', )
|
||||
list_select_related = ('profile__state', 'profile__main_character')
|
||||
show_full_result_count = True
|
||||
list_display = (
|
||||
user_profile_pic,
|
||||
user_username,
|
||||
'_state',
|
||||
'_groups',
|
||||
user_main_organization,
|
||||
'_characters',
|
||||
'is_active',
|
||||
'date_joined',
|
||||
'_role'
|
||||
)
|
||||
list_display_links = None
|
||||
list_filter = (
|
||||
'profile__state',
|
||||
'groups',
|
||||
MainCorporationsFilter,
|
||||
MainAllianceFilter,
|
||||
MainFactionFilter,
|
||||
'is_active',
|
||||
'date_joined',
|
||||
'is_staff',
|
||||
'is_superuser'
|
||||
)
|
||||
search_fields = (
|
||||
'username',
|
||||
'character_ownerships__character__character_name'
|
||||
)
|
||||
readonly_fields = ('date_joined', 'last_login')
|
||||
|
||||
def _characters(self, obj):
|
||||
character_ownerships = list(obj.character_ownerships.all())
|
||||
characters = [obj.character.character_name for obj in character_ownerships]
|
||||
@@ -390,22 +395,16 @@ class UserAdmin(BaseUserAdmin):
|
||||
AUTHENTICATION_ADMIN_USERS_MAX_CHARS
|
||||
)
|
||||
|
||||
_characters.short_description = 'characters'
|
||||
|
||||
@admin.display(ordering="profile__state")
|
||||
def _state(self, obj):
|
||||
return obj.profile.state.name
|
||||
|
||||
_state.short_description = 'state'
|
||||
_state.admin_order_field = 'profile__state'
|
||||
|
||||
def _groups(self, obj):
|
||||
my_groups = sorted(group.name for group in list(obj.groups.all()))
|
||||
return self._list_2_html_w_tooltips(
|
||||
my_groups, AUTHENTICATION_ADMIN_USERS_MAX_GROUPS
|
||||
)
|
||||
|
||||
_groups.short_description = 'groups'
|
||||
|
||||
def _role(self, obj):
|
||||
if obj.is_superuser:
|
||||
role = 'Superuser'
|
||||
@@ -415,8 +414,6 @@ class UserAdmin(BaseUserAdmin):
|
||||
role = 'User'
|
||||
return role
|
||||
|
||||
_role.short_description = 'role'
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return request.user.has_perm('auth.change_user')
|
||||
|
||||
@@ -438,9 +435,16 @@ class UserAdmin(BaseUserAdmin):
|
||||
if obj_state:
|
||||
matching_groups_qs = Group.objects.filter(authgroup__states=obj_state)
|
||||
groups_qs = groups_qs | matching_groups_qs
|
||||
kwargs["queryset"] = groups_qs.order_by(Lower('name'))
|
||||
kwargs["queryset"] = groups_qs.order_by(Lower("name"))
|
||||
return super().formfield_for_manytomany(db_field, request, **kwargs)
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
if obj and not request.user.is_superuser:
|
||||
return self.readonly_fields + (
|
||||
"is_staff", "is_superuser", "user_permissions"
|
||||
)
|
||||
return self.readonly_fields
|
||||
|
||||
|
||||
@admin.register(State)
|
||||
class StateAdmin(admin.ModelAdmin):
|
||||
@@ -451,10 +455,9 @@ class StateAdmin(admin.ModelAdmin):
|
||||
qs = super().get_queryset(request)
|
||||
return qs.annotate(user_count=Count("userprofile__id"))
|
||||
|
||||
@admin.display(description="Users", ordering="user_count")
|
||||
def _user_count(self, obj):
|
||||
return obj.user_count
|
||||
_user_count.short_description = 'Users'
|
||||
_user_count.admin_order_field = 'user_count'
|
||||
|
||||
fieldsets = (
|
||||
(None, {
|
||||
@@ -510,13 +513,13 @@ class StateAdmin(admin.ModelAdmin):
|
||||
)
|
||||
return super().get_fieldsets(request, obj=obj)
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
if not request.user.is_superuser:
|
||||
return self.readonly_fields + ("permissions",)
|
||||
return self.readonly_fields
|
||||
|
||||
|
||||
class BaseOwnershipAdmin(admin.ModelAdmin):
|
||||
class Media:
|
||||
css = {
|
||||
"all": ("authentication/css/admin.css",)
|
||||
}
|
||||
|
||||
list_select_related = (
|
||||
'user__profile__state', 'user__profile__main_character', 'character')
|
||||
list_display = (
|
||||
@@ -537,6 +540,11 @@ class BaseOwnershipAdmin(admin.ModelAdmin):
|
||||
MainAllianceFilter,
|
||||
)
|
||||
|
||||
class Media:
|
||||
css = {
|
||||
"all": ("authentication/css/admin.css",)
|
||||
}
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
if obj and obj.pk:
|
||||
return 'owner_hash', 'character'
|
||||
|
||||
@@ -1,8 +1,66 @@
|
||||
from django import forms
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.contrib.auth.forms import UserChangeForm as BaseUserChangeForm
|
||||
from django.contrib.auth.models import Group
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.forms import ModelForm
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from allianceauth.authentication.models import User
|
||||
|
||||
|
||||
class RegistrationForm(forms.Form):
|
||||
email = forms.EmailField(label=_('Email'), max_length=254, required=True)
|
||||
|
||||
class _meta:
|
||||
model = User
|
||||
|
||||
|
||||
class UserProfileForm(ModelForm):
|
||||
"""Allows specifying FK querysets through kwarg"""
|
||||
|
||||
def __init__(self, querysets=None, *args, **kwargs):
|
||||
querysets = querysets or {}
|
||||
super().__init__(*args, **kwargs)
|
||||
for field, qs in querysets.items():
|
||||
self.fields[field].queryset = qs
|
||||
|
||||
|
||||
class UserChangeForm(BaseUserChangeForm):
|
||||
"""Add custom cleaning to UserChangeForm"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.request = kwargs.pop("request") # Inject current request into form object
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
if not self.request.user.is_superuser:
|
||||
if self.instance:
|
||||
current_restricted = set(
|
||||
self.instance.groups.filter(
|
||||
authgroup__restricted=True
|
||||
).values_list("pk", flat=True)
|
||||
)
|
||||
else:
|
||||
current_restricted = set()
|
||||
new_restricted = set(
|
||||
cleaned_data["groups"].filter(
|
||||
authgroup__restricted=True
|
||||
).values_list("pk", flat=True)
|
||||
)
|
||||
if current_restricted != new_restricted:
|
||||
restricted_removed = current_restricted - new_restricted
|
||||
restricted_added = new_restricted - current_restricted
|
||||
restricted_changed = restricted_removed | restricted_added
|
||||
restricted_names_qs = Group.objects.filter(
|
||||
pk__in=restricted_changed
|
||||
).values_list("name", flat=True)
|
||||
restricted_names = ",".join(list(restricted_names_qs))
|
||||
raise ValidationError(
|
||||
{
|
||||
"groups": _(
|
||||
"You are not allowed to add or remove these "
|
||||
"restricted groups: %s" % restricted_names
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
40
allianceauth/authentication/task_statistics/counters.py
Normal file
40
allianceauth/authentication/task_statistics/counters.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from collections import namedtuple
|
||||
import datetime as dt
|
||||
|
||||
from .event_series import EventSeries
|
||||
|
||||
|
||||
"""Global series for counting task events."""
|
||||
succeeded_tasks = EventSeries("SUCCEEDED_TASKS")
|
||||
retried_tasks = EventSeries("RETRIED_TASKS")
|
||||
failed_tasks = EventSeries("FAILED_TASKS")
|
||||
|
||||
|
||||
_TaskCounts = namedtuple(
|
||||
"_TaskCounts", ["succeeded", "retried", "failed", "total", "earliest_task", "hours"]
|
||||
)
|
||||
|
||||
|
||||
def dashboard_results(hours: int) -> _TaskCounts:
|
||||
"""Counts of all task events within the given timeframe."""
|
||||
|
||||
def earliest_if_exists(events: EventSeries, earliest: dt.datetime) -> list:
|
||||
my_earliest = events.first_event(earliest=earliest)
|
||||
return [my_earliest] if my_earliest else []
|
||||
|
||||
earliest = dt.datetime.utcnow() - dt.timedelta(hours=hours)
|
||||
earliest_events = list()
|
||||
succeeded_count = succeeded_tasks.count(earliest=earliest)
|
||||
earliest_events += earliest_if_exists(succeeded_tasks, earliest)
|
||||
retried_count = retried_tasks.count(earliest=earliest)
|
||||
earliest_events += earliest_if_exists(retried_tasks, earliest)
|
||||
failed_count = failed_tasks.count(earliest=earliest)
|
||||
earliest_events += earliest_if_exists(failed_tasks, earliest)
|
||||
return _TaskCounts(
|
||||
succeeded=succeeded_count,
|
||||
retried=retried_count,
|
||||
failed=failed_count,
|
||||
total=succeeded_count + retried_count + failed_count,
|
||||
earliest_task=min(earliest_events) if earliest_events else None,
|
||||
hours=hours,
|
||||
)
|
||||
@@ -1,74 +1,69 @@
|
||||
import datetime as dt
|
||||
from collections import namedtuple
|
||||
from typing import Optional, List
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
from redis import Redis
|
||||
from pytz import utc
|
||||
from redis import Redis, RedisError
|
||||
|
||||
from django.core.cache import cache
|
||||
from allianceauth.utils.cache import get_redis_client
|
||||
|
||||
_TaskCounts = namedtuple(
|
||||
"_TaskCounts", ["succeeded", "retried", "failed", "total", "earliest_task", "hours"]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def dashboard_results(hours: int) -> _TaskCounts:
|
||||
"""Counts of all task events within the given timeframe."""
|
||||
def earliest_if_exists(events: EventSeries, earliest: dt.datetime) -> list:
|
||||
my_earliest = events.first_event(earliest=earliest)
|
||||
return [my_earliest] if my_earliest else []
|
||||
class _RedisStub:
|
||||
"""Stub of a Redis client.
|
||||
|
||||
earliest = dt.datetime.utcnow() - dt.timedelta(hours=hours)
|
||||
earliest_events = list()
|
||||
succeeded = SucceededTaskSeries()
|
||||
succeeded_count = succeeded.count(earliest=earliest)
|
||||
earliest_events += earliest_if_exists(succeeded, earliest)
|
||||
retried = RetriedTaskSeries()
|
||||
retried_count = retried.count(earliest=earliest)
|
||||
earliest_events += earliest_if_exists(retried, earliest)
|
||||
failed = FailedTaskSeries()
|
||||
failed_count = failed.count(earliest=earliest)
|
||||
earliest_events += earliest_if_exists(failed, earliest)
|
||||
return _TaskCounts(
|
||||
succeeded=succeeded_count,
|
||||
retried=retried_count,
|
||||
failed=failed_count,
|
||||
total=succeeded_count + retried_count + failed_count,
|
||||
earliest_task=min(earliest_events) if earliest_events else None,
|
||||
hours=hours,
|
||||
)
|
||||
It's purpose is to prevent EventSeries objects from trying to access Redis
|
||||
when it is not available. e.g. when the Sphinx docs are rendered by readthedocs.org.
|
||||
"""
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def incr(self, *args, **kwargs):
|
||||
return 0
|
||||
|
||||
def zadd(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def zcount(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def zrangebyscore(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
class EventSeries:
|
||||
"""Base class for recording and analysing a series of events.
|
||||
"""API for recording and analyzing a series of events."""
|
||||
|
||||
This class must be inherited from and the child class must define KEY_ID.
|
||||
"""
|
||||
_ROOT_KEY = "ALLIANCEAUTH_EVENT_SERIES"
|
||||
|
||||
_ROOT_KEY = "ALLIANCEAUTH_TASK_SERIES"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
redis: Redis = None,
|
||||
) -> None:
|
||||
if type(self) == EventSeries:
|
||||
raise TypeError("Can not instantiate base class.")
|
||||
if not hasattr(self, "KEY_ID"):
|
||||
raise ValueError("KEY_ID not defined")
|
||||
self._redis = cache.get_master_client() if not redis else redis
|
||||
if not isinstance(self._redis, Redis):
|
||||
raise TypeError(
|
||||
"This class requires a Redis client, but none was provided "
|
||||
"and the default Django cache backend is not Redis either."
|
||||
def __init__(self, key_id: str, redis: Redis = None) -> None:
|
||||
self._redis = get_redis_client() if not redis else redis
|
||||
try:
|
||||
if not self._redis.ping():
|
||||
raise RuntimeError()
|
||||
except (AttributeError, RedisError, RuntimeError):
|
||||
logger.exception(
|
||||
"Failed to establish a connection with Redis. "
|
||||
"This EventSeries object is disabled.",
|
||||
)
|
||||
self._redis = _RedisStub()
|
||||
self._key_id = str(key_id)
|
||||
self.clear()
|
||||
|
||||
@property
|
||||
def is_disabled(self):
|
||||
"""True when this object is disabled, e.g. Redis was not available at startup."""
|
||||
return isinstance(self._redis, _RedisStub)
|
||||
|
||||
@property
|
||||
def _key_counter(self):
|
||||
return f"{self._ROOT_KEY}_{self.KEY_ID}_COUNTER"
|
||||
return f"{self._ROOT_KEY}_{self._key_id}_COUNTER"
|
||||
|
||||
@property
|
||||
def _key_sorted_set(self):
|
||||
return f"{self._ROOT_KEY}_{self.KEY_ID}_SORTED_SET"
|
||||
return f"{self._ROOT_KEY}_{self._key_id}_SORTED_SET"
|
||||
|
||||
def add(self, event_time: dt.datetime = None) -> None:
|
||||
"""Add event.
|
||||
@@ -133,21 +128,3 @@ class EventSeries:
|
||||
@staticmethod
|
||||
def _cast_scores_to_dt(score) -> dt.datetime:
|
||||
return dt.datetime.fromtimestamp(float(score), tz=utc)
|
||||
|
||||
|
||||
class SucceededTaskSeries(EventSeries):
|
||||
"""A task has succeeded."""
|
||||
|
||||
KEY_ID = "SUCCEEDED"
|
||||
|
||||
|
||||
class RetriedTaskSeries(EventSeries):
|
||||
"""A task has been retried."""
|
||||
|
||||
KEY_ID = "RETRIED"
|
||||
|
||||
|
||||
class FailedTaskSeries(EventSeries):
|
||||
"""A task has failed."""
|
||||
|
||||
KEY_ID = "FAILED"
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
from celery.signals import task_failure, task_retry, task_success, worker_ready
|
||||
from celery.signals import (
|
||||
task_failure,
|
||||
task_internal_error,
|
||||
task_retry,
|
||||
task_success,
|
||||
worker_ready
|
||||
)
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from .event_series import FailedTaskSeries, RetriedTaskSeries, SucceededTaskSeries
|
||||
from .counters import failed_tasks, retried_tasks, succeeded_tasks
|
||||
|
||||
|
||||
def reset_counters():
|
||||
"""Reset all counters for the celery status."""
|
||||
SucceededTaskSeries().clear()
|
||||
FailedTaskSeries().clear()
|
||||
RetriedTaskSeries().clear()
|
||||
succeeded_tasks.clear()
|
||||
failed_tasks.clear()
|
||||
retried_tasks.clear()
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
@@ -27,16 +33,22 @@ def reset_counters_when_celery_restarted(*args, **kwargs):
|
||||
@task_success.connect
|
||||
def record_task_succeeded(*args, **kwargs):
|
||||
if is_enabled():
|
||||
SucceededTaskSeries().add()
|
||||
succeeded_tasks.add()
|
||||
|
||||
|
||||
@task_retry.connect
|
||||
def record_task_retried(*args, **kwargs):
|
||||
if is_enabled():
|
||||
RetriedTaskSeries().add()
|
||||
retried_tasks.add()
|
||||
|
||||
|
||||
@task_failure.connect
|
||||
def record_task_failed(*args, **kwargs):
|
||||
if is_enabled():
|
||||
FailedTaskSeries().add()
|
||||
failed_tasks.add()
|
||||
|
||||
|
||||
@task_internal_error.connect
|
||||
def record_task_internal_error(*args, **kwargs):
|
||||
if is_enabled():
|
||||
failed_tasks.add()
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import datetime as dt
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils.timezone import now
|
||||
|
||||
from allianceauth.authentication.task_statistics.counters import (
|
||||
dashboard_results,
|
||||
succeeded_tasks,
|
||||
retried_tasks,
|
||||
failed_tasks,
|
||||
)
|
||||
|
||||
|
||||
class TestDashboardResults(TestCase):
|
||||
def test_should_return_counts_for_given_timeframe_only(self):
|
||||
# given
|
||||
earliest_task = now() - dt.timedelta(minutes=15)
|
||||
succeeded_tasks.clear()
|
||||
succeeded_tasks.add(now() - dt.timedelta(hours=1, seconds=1))
|
||||
succeeded_tasks.add(earliest_task)
|
||||
succeeded_tasks.add()
|
||||
succeeded_tasks.add()
|
||||
retried_tasks.clear()
|
||||
retried_tasks.add(now() - dt.timedelta(hours=1, seconds=1))
|
||||
retried_tasks.add(now() - dt.timedelta(seconds=30))
|
||||
retried_tasks.add()
|
||||
failed_tasks.clear()
|
||||
failed_tasks.add(now() - dt.timedelta(hours=1, seconds=1))
|
||||
failed_tasks.add()
|
||||
# when
|
||||
results = dashboard_results(hours=1)
|
||||
# then
|
||||
self.assertEqual(results.succeeded, 3)
|
||||
self.assertEqual(results.retried, 2)
|
||||
self.assertEqual(results.failed, 1)
|
||||
self.assertEqual(results.total, 6)
|
||||
self.assertEqual(results.earliest_task, earliest_task)
|
||||
|
||||
def test_should_work_with_no_data(self):
|
||||
# given
|
||||
succeeded_tasks.clear()
|
||||
retried_tasks.clear()
|
||||
failed_tasks.clear()
|
||||
# when
|
||||
results = dashboard_results(hours=1)
|
||||
# then
|
||||
self.assertEqual(results.succeeded, 0)
|
||||
self.assertEqual(results.retried, 0)
|
||||
self.assertEqual(results.failed, 0)
|
||||
self.assertEqual(results.total, 0)
|
||||
self.assertIsNone(results.earliest_task)
|
||||
@@ -1,49 +1,51 @@
|
||||
import datetime as dt
|
||||
from unittest.mock import patch
|
||||
|
||||
from pytz import utc
|
||||
from redis import RedisError
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils.timezone import now
|
||||
|
||||
from allianceauth.authentication.task_statistics.event_series import (
|
||||
EventSeries,
|
||||
FailedTaskSeries,
|
||||
RetriedTaskSeries,
|
||||
SucceededTaskSeries,
|
||||
dashboard_results,
|
||||
_RedisStub,
|
||||
)
|
||||
|
||||
MODULE_PATH = "allianceauth.authentication.task_statistics.event_series"
|
||||
|
||||
|
||||
class TestEventSeries(TestCase):
|
||||
"""Testing EventSeries class."""
|
||||
|
||||
class IncompleteEvents(EventSeries):
|
||||
"""Child class without KEY ID"""
|
||||
|
||||
class MyEventSeries(EventSeries):
|
||||
KEY_ID = "TEST"
|
||||
|
||||
def test_should_create_object(self):
|
||||
def test_should_abort_without_redis_client(self):
|
||||
# when
|
||||
events = self.MyEventSeries()
|
||||
with patch(MODULE_PATH + ".get_redis_client") as mock:
|
||||
mock.return_value = None
|
||||
events = EventSeries("dummy")
|
||||
# then
|
||||
self.assertIsInstance(events, self.MyEventSeries)
|
||||
self.assertTrue(events._redis, _RedisStub)
|
||||
self.assertTrue(events.is_disabled)
|
||||
|
||||
def test_should_abort_when_redis_client_invalid(self):
|
||||
with self.assertRaises(TypeError):
|
||||
self.MyEventSeries(redis="invalid")
|
||||
def test_should_disable_itself_if_redis_not_available_1(self):
|
||||
# when
|
||||
with patch(MODULE_PATH + ".get_redis_client") as mock_get_master_client:
|
||||
mock_get_master_client.return_value.ping.side_effect = RedisError
|
||||
events = EventSeries("dummy")
|
||||
# then
|
||||
self.assertIsInstance(events._redis, _RedisStub)
|
||||
self.assertTrue(events.is_disabled)
|
||||
|
||||
def test_should_not_allow_instantiation_of_base_class(self):
|
||||
with self.assertRaises(TypeError):
|
||||
EventSeries()
|
||||
|
||||
def test_should_not_allow_creating_child_class_without_key_id(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.IncompleteEvents()
|
||||
def test_should_disable_itself_if_redis_not_available_2(self):
|
||||
# when
|
||||
with patch(MODULE_PATH + ".get_redis_client") as mock_get_master_client:
|
||||
mock_get_master_client.return_value.ping.return_value = False
|
||||
events = EventSeries("dummy")
|
||||
# then
|
||||
self.assertIsInstance(events._redis, _RedisStub)
|
||||
self.assertTrue(events.is_disabled)
|
||||
|
||||
def test_should_add_event(self):
|
||||
# given
|
||||
events = self.MyEventSeries()
|
||||
events.clear()
|
||||
events = EventSeries("dummy")
|
||||
# when
|
||||
events.add()
|
||||
# then
|
||||
@@ -53,8 +55,7 @@ class TestEventSeries(TestCase):
|
||||
|
||||
def test_should_add_event_with_specified_time(self):
|
||||
# given
|
||||
events = self.MyEventSeries()
|
||||
events.clear()
|
||||
events = EventSeries("dummy")
|
||||
my_time = dt.datetime(2021, 11, 1, 12, 15, tzinfo=utc)
|
||||
# when
|
||||
events.add(my_time)
|
||||
@@ -65,8 +66,7 @@ class TestEventSeries(TestCase):
|
||||
|
||||
def test_should_count_events(self):
|
||||
# given
|
||||
events = self.MyEventSeries()
|
||||
events.clear()
|
||||
events = EventSeries("dummy")
|
||||
events.add()
|
||||
events.add()
|
||||
# when
|
||||
@@ -76,8 +76,7 @@ class TestEventSeries(TestCase):
|
||||
|
||||
def test_should_count_zero(self):
|
||||
# given
|
||||
events = self.MyEventSeries()
|
||||
events.clear()
|
||||
events = EventSeries("dummy")
|
||||
# when
|
||||
result = events.count()
|
||||
# then
|
||||
@@ -85,8 +84,7 @@ class TestEventSeries(TestCase):
|
||||
|
||||
def test_should_count_events_within_timeframe_1(self):
|
||||
# given
|
||||
events = self.MyEventSeries()
|
||||
events.clear()
|
||||
events = EventSeries("dummy")
|
||||
events.add(dt.datetime(2021, 12, 1, 12, 0, tzinfo=utc))
|
||||
events.add(dt.datetime(2021, 12, 1, 12, 10, tzinfo=utc))
|
||||
events.add(dt.datetime(2021, 12, 1, 12, 15, tzinfo=utc))
|
||||
@@ -101,8 +99,7 @@ class TestEventSeries(TestCase):
|
||||
|
||||
def test_should_count_events_within_timeframe_2(self):
|
||||
# given
|
||||
events = self.MyEventSeries()
|
||||
events.clear()
|
||||
events = EventSeries("dummy")
|
||||
events.add(dt.datetime(2021, 12, 1, 12, 0, tzinfo=utc))
|
||||
events.add(dt.datetime(2021, 12, 1, 12, 10, tzinfo=utc))
|
||||
events.add(dt.datetime(2021, 12, 1, 12, 15, tzinfo=utc))
|
||||
@@ -114,8 +111,7 @@ class TestEventSeries(TestCase):
|
||||
|
||||
def test_should_count_events_within_timeframe_3(self):
|
||||
# given
|
||||
events = self.MyEventSeries()
|
||||
events.clear()
|
||||
events = EventSeries("dummy")
|
||||
events.add(dt.datetime(2021, 12, 1, 12, 0, tzinfo=utc))
|
||||
events.add(dt.datetime(2021, 12, 1, 12, 10, tzinfo=utc))
|
||||
events.add(dt.datetime(2021, 12, 1, 12, 15, tzinfo=utc))
|
||||
@@ -127,8 +123,7 @@ class TestEventSeries(TestCase):
|
||||
|
||||
def test_should_clear_events(self):
|
||||
# given
|
||||
events = self.MyEventSeries()
|
||||
events.clear()
|
||||
events = EventSeries("dummy")
|
||||
events.add()
|
||||
events.add()
|
||||
# when
|
||||
@@ -138,8 +133,7 @@ class TestEventSeries(TestCase):
|
||||
|
||||
def test_should_return_date_of_first_event(self):
|
||||
# given
|
||||
events = self.MyEventSeries()
|
||||
events.clear()
|
||||
events = EventSeries("dummy")
|
||||
events.add(dt.datetime(2021, 12, 1, 12, 0, tzinfo=utc))
|
||||
events.add(dt.datetime(2021, 12, 1, 12, 10, tzinfo=utc))
|
||||
events.add(dt.datetime(2021, 12, 1, 12, 15, tzinfo=utc))
|
||||
@@ -151,8 +145,7 @@ class TestEventSeries(TestCase):
|
||||
|
||||
def test_should_return_date_of_first_event_with_range(self):
|
||||
# given
|
||||
events = self.MyEventSeries()
|
||||
events.clear()
|
||||
events = EventSeries("dummy")
|
||||
events.add(dt.datetime(2021, 12, 1, 12, 0, tzinfo=utc))
|
||||
events.add(dt.datetime(2021, 12, 1, 12, 10, tzinfo=utc))
|
||||
events.add(dt.datetime(2021, 12, 1, 12, 15, tzinfo=utc))
|
||||
@@ -166,57 +159,10 @@ class TestEventSeries(TestCase):
|
||||
|
||||
def test_should_return_all_events(self):
|
||||
# given
|
||||
events = self.MyEventSeries()
|
||||
events.clear()
|
||||
events = EventSeries("dummy")
|
||||
events.add()
|
||||
events.add()
|
||||
# when
|
||||
results = events.all()
|
||||
# then
|
||||
self.assertEqual(len(results), 2)
|
||||
|
||||
|
||||
class TestDashboardResults(TestCase):
|
||||
def test_should_return_counts_for_given_timeframe_only(self):
|
||||
# given
|
||||
earliest_task = now() - dt.timedelta(minutes=15)
|
||||
succeeded = SucceededTaskSeries()
|
||||
succeeded.clear()
|
||||
succeeded.add(now() - dt.timedelta(hours=1, seconds=1))
|
||||
succeeded.add(earliest_task)
|
||||
succeeded.add()
|
||||
succeeded.add()
|
||||
retried = RetriedTaskSeries()
|
||||
retried.clear()
|
||||
retried.add(now() - dt.timedelta(hours=1, seconds=1))
|
||||
retried.add(now() - dt.timedelta(seconds=30))
|
||||
retried.add()
|
||||
failed = FailedTaskSeries()
|
||||
failed.clear()
|
||||
failed.add(now() - dt.timedelta(hours=1, seconds=1))
|
||||
failed.add()
|
||||
# when
|
||||
results = dashboard_results(hours=1)
|
||||
# then
|
||||
self.assertEqual(results.succeeded, 3)
|
||||
self.assertEqual(results.retried, 2)
|
||||
self.assertEqual(results.failed, 1)
|
||||
self.assertEqual(results.total, 6)
|
||||
self.assertEqual(results.earliest_task, earliest_task)
|
||||
|
||||
def test_should_work_with_no_data(self):
|
||||
# given
|
||||
succeeded = SucceededTaskSeries()
|
||||
succeeded.clear()
|
||||
retried = RetriedTaskSeries()
|
||||
retried.clear()
|
||||
failed = FailedTaskSeries()
|
||||
failed.clear()
|
||||
# when
|
||||
results = dashboard_results(hours=1)
|
||||
# then
|
||||
self.assertEqual(results.succeeded, 0)
|
||||
self.assertEqual(results.retried, 0)
|
||||
self.assertEqual(results.failed, 0)
|
||||
self.assertEqual(results.total, 0)
|
||||
self.assertIsNone(results.earliest_task)
|
||||
|
||||
@@ -4,10 +4,10 @@ from celery.exceptions import Retry
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from allianceauth.authentication.task_statistics.event_series import (
|
||||
FailedTaskSeries,
|
||||
RetriedTaskSeries,
|
||||
SucceededTaskSeries,
|
||||
from allianceauth.authentication.task_statistics.counters import (
|
||||
failed_tasks,
|
||||
retried_tasks,
|
||||
succeeded_tasks,
|
||||
)
|
||||
from allianceauth.authentication.task_statistics.signals import (
|
||||
reset_counters,
|
||||
@@ -17,15 +17,16 @@ from allianceauth.eveonline.tasks import update_character
|
||||
|
||||
|
||||
@override_settings(
|
||||
CELERY_ALWAYS_EAGER=True, ALLIANCEAUTH_DASHBOARD_TASK_STATISTICS_DISABLED=False
|
||||
CELERY_ALWAYS_EAGER=True,ALLIANCEAUTH_DASHBOARD_TASK_STATISTICS_DISABLED=False
|
||||
)
|
||||
class TestTaskSignals(TestCase):
|
||||
fixtures = ["disable_analytics"]
|
||||
|
||||
def test_should_record_successful_task(self):
|
||||
# given
|
||||
events = SucceededTaskSeries()
|
||||
events.clear()
|
||||
succeeded_tasks.clear()
|
||||
retried_tasks.clear()
|
||||
failed_tasks.clear()
|
||||
# when
|
||||
with patch(
|
||||
"allianceauth.eveonline.tasks.EveCharacter.objects.update_character"
|
||||
@@ -33,12 +34,15 @@ class TestTaskSignals(TestCase):
|
||||
mock_update.return_value = None
|
||||
update_character.delay(1)
|
||||
# then
|
||||
self.assertEqual(events.count(), 1)
|
||||
self.assertEqual(succeeded_tasks.count(), 1)
|
||||
self.assertEqual(retried_tasks.count(), 0)
|
||||
self.assertEqual(failed_tasks.count(), 0)
|
||||
|
||||
def test_should_record_retried_task(self):
|
||||
# given
|
||||
events = RetriedTaskSeries()
|
||||
events.clear()
|
||||
succeeded_tasks.clear()
|
||||
retried_tasks.clear()
|
||||
failed_tasks.clear()
|
||||
# when
|
||||
with patch(
|
||||
"allianceauth.eveonline.tasks.EveCharacter.objects.update_character"
|
||||
@@ -46,12 +50,15 @@ class TestTaskSignals(TestCase):
|
||||
mock_update.side_effect = Retry
|
||||
update_character.delay(1)
|
||||
# then
|
||||
self.assertEqual(events.count(), 1)
|
||||
self.assertEqual(succeeded_tasks.count(), 0)
|
||||
self.assertEqual(failed_tasks.count(), 0)
|
||||
self.assertEqual(retried_tasks.count(), 1)
|
||||
|
||||
def test_should_record_failed_task(self):
|
||||
# given
|
||||
events = FailedTaskSeries()
|
||||
events.clear()
|
||||
succeeded_tasks.clear()
|
||||
retried_tasks.clear()
|
||||
failed_tasks.clear()
|
||||
# when
|
||||
with patch(
|
||||
"allianceauth.eveonline.tasks.EveCharacter.objects.update_character"
|
||||
@@ -59,28 +66,21 @@ class TestTaskSignals(TestCase):
|
||||
mock_update.side_effect = RuntimeError
|
||||
update_character.delay(1)
|
||||
# then
|
||||
self.assertEqual(events.count(), 1)
|
||||
self.assertEqual(succeeded_tasks.count(), 0)
|
||||
self.assertEqual(retried_tasks.count(), 0)
|
||||
self.assertEqual(failed_tasks.count(), 1)
|
||||
|
||||
|
||||
@override_settings(ALLIANCEAUTH_DASHBOARD_TASK_STATISTICS_DISABLED=False)
|
||||
class TestResetCounters(TestCase):
|
||||
def test_should_reset_counters(self):
|
||||
# given
|
||||
succeeded = SucceededTaskSeries()
|
||||
succeeded.clear()
|
||||
succeeded.add()
|
||||
retried = RetriedTaskSeries()
|
||||
retried.clear()
|
||||
retried.add()
|
||||
failed = FailedTaskSeries()
|
||||
failed.clear()
|
||||
failed.add()
|
||||
succeeded_tasks.add()
|
||||
retried_tasks.add()
|
||||
failed_tasks.add()
|
||||
# when
|
||||
reset_counters()
|
||||
# then
|
||||
self.assertEqual(succeeded.count(), 0)
|
||||
self.assertEqual(retried.count(), 0)
|
||||
self.assertEqual(failed.count(), 0)
|
||||
self.assertEqual(succeeded_tasks.count(), 0)
|
||||
self.assertEqual(retried_tasks.count(), 0)
|
||||
self.assertEqual(failed_tasks.count(), 0)
|
||||
|
||||
|
||||
class TestIsEnabled(TestCase):
|
||||
|
||||
@@ -2,6 +2,8 @@ from bs4 import BeautifulSoup
|
||||
from urllib.parse import quote
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from django_webtest import WebTest
|
||||
|
||||
from django.contrib.admin.sites import AdminSite
|
||||
from django.contrib.auth.models import Group
|
||||
from django.test import TestCase, RequestFactory, Client
|
||||
@@ -276,10 +278,10 @@ class TestOwnershipRecordAdmin(TestCaseWithTestData):
|
||||
class TestStateAdmin(TestCaseWithTestData):
|
||||
fixtures = ["disable_analytics"]
|
||||
|
||||
def setUp(self):
|
||||
self.modeladmin = StateAdmin(
|
||||
model=User, admin_site=AdminSite()
|
||||
)
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
super().setUpClass()
|
||||
cls.modeladmin = StateAdmin(model=User, admin_site=AdminSite())
|
||||
|
||||
def test_change_view_loads_normally(self):
|
||||
User.objects.create_superuser(
|
||||
@@ -543,7 +545,74 @@ class TestUserAdmin(TestCaseWithTestData):
|
||||
self.assertEqual(response.status_code, expected)
|
||||
|
||||
|
||||
class TestStateAdminChangeFormSuperuserExclusiveEdits(WebTest):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
super().setUpClass()
|
||||
cls.super_admin = User.objects.create_superuser("super_admin")
|
||||
cls.staff_admin = User.objects.create_user("staff_admin")
|
||||
cls.staff_admin.is_staff = True
|
||||
cls.staff_admin.save()
|
||||
cls.staff_admin = AuthUtils.add_permissions_to_user_by_name(
|
||||
[
|
||||
"authentication.add_state",
|
||||
"authentication.change_state",
|
||||
"authentication.view_state",
|
||||
],
|
||||
cls.staff_admin
|
||||
)
|
||||
cls.superuser_exclusive_fields = ["permissions",]
|
||||
|
||||
def test_should_show_all_fields_to_superuser_for_add(self):
|
||||
# given
|
||||
self.app.set_user(self.super_admin)
|
||||
page = self.app.get("/admin/authentication/state/add/")
|
||||
# when
|
||||
form = page.forms["state_form"]
|
||||
# then
|
||||
for field in self.superuser_exclusive_fields:
|
||||
with self.subTest(field=field):
|
||||
self.assertIn(field, form.fields)
|
||||
|
||||
def test_should_not_show_all_fields_to_staff_admins_for_add(self):
|
||||
# given
|
||||
self.app.set_user(self.staff_admin)
|
||||
page = self.app.get("/admin/authentication/state/add/")
|
||||
# when
|
||||
form = page.forms["state_form"]
|
||||
# then
|
||||
for field in self.superuser_exclusive_fields:
|
||||
with self.subTest(field=field):
|
||||
self.assertNotIn(field, form.fields)
|
||||
|
||||
def test_should_show_all_fields_to_superuser_for_change(self):
|
||||
# given
|
||||
self.app.set_user(self.super_admin)
|
||||
state = AuthUtils.get_member_state()
|
||||
page = self.app.get(f"/admin/authentication/state/{state.pk}/change/")
|
||||
# when
|
||||
form = page.forms["state_form"]
|
||||
# then
|
||||
for field in self.superuser_exclusive_fields:
|
||||
with self.subTest(field=field):
|
||||
self.assertIn(field, form.fields)
|
||||
|
||||
def test_should_not_show_all_fields_to_staff_admin_for_change(self):
|
||||
# given
|
||||
self.app.set_user(self.staff_admin)
|
||||
state = AuthUtils.get_member_state()
|
||||
page = self.app.get(f"/admin/authentication/state/{state.pk}/change/")
|
||||
# when
|
||||
form = page.forms["state_form"]
|
||||
# then
|
||||
for field in self.superuser_exclusive_fields:
|
||||
with self.subTest(field=field):
|
||||
self.assertNotIn(field, form.fields)
|
||||
|
||||
|
||||
class TestUserAdminChangeForm(TestCase):
|
||||
fixtures = ["disable_analytics"]
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
super().setUpClass()
|
||||
@@ -552,7 +621,7 @@ class TestUserAdminChangeForm(TestCase):
|
||||
def test_should_show_groups_available_to_user_with_blue_state_only(self):
|
||||
# given
|
||||
superuser = User.objects.create_superuser("Super")
|
||||
user = AuthUtils.create_user("Bruce Wayne")
|
||||
user = AuthUtils.create_user("bruce_wayne")
|
||||
character = AuthUtils.add_main_character_2(
|
||||
user,
|
||||
name="Bruce Wayne",
|
||||
@@ -579,6 +648,126 @@ class TestUserAdminChangeForm(TestCase):
|
||||
self.assertSetEqual(group_ids, {group_1.pk, group_2.pk})
|
||||
|
||||
|
||||
class TestUserAdminChangeFormSuperuserExclusiveEdits(WebTest):
|
||||
fixtures = ["disable_analytics"]
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
super().setUpClass()
|
||||
cls.super_admin = User.objects.create_superuser("super_admin")
|
||||
cls.staff_admin = User.objects.create_user("staff_admin")
|
||||
cls.staff_admin.is_staff = True
|
||||
cls.staff_admin.save()
|
||||
cls.staff_admin = AuthUtils.add_permissions_to_user_by_name(
|
||||
[
|
||||
"auth.change_user",
|
||||
"auth.view_user",
|
||||
"authentication.change_user",
|
||||
"authentication.change_userprofile",
|
||||
"authentication.view_user"
|
||||
],
|
||||
cls.staff_admin
|
||||
)
|
||||
cls.superuser_exclusive_fields = [
|
||||
"is_staff", "is_superuser", "user_permissions"
|
||||
]
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.user = AuthUtils.create_user("bruce_wayne")
|
||||
|
||||
def test_should_show_all_fields_to_superuser_for_change(self):
|
||||
# given
|
||||
self.app.set_user(self.super_admin)
|
||||
|
||||
page = self.app.get(f"/admin/authentication/user/{self.user.pk}/change/")
|
||||
# when
|
||||
form = page.forms["user_form"]
|
||||
# then
|
||||
for field in self.superuser_exclusive_fields:
|
||||
with self.subTest(field=field):
|
||||
self.assertIn(field, form.fields)
|
||||
|
||||
def test_should_not_show_all_fields_to_staff_admin_for_change(self):
|
||||
# given
|
||||
self.app.set_user(self.staff_admin)
|
||||
page = self.app.get(f"/admin/authentication/user/{self.user.pk}/change/")
|
||||
# when
|
||||
form = page.forms["user_form"]
|
||||
# then
|
||||
for field in self.superuser_exclusive_fields:
|
||||
with self.subTest(field=field):
|
||||
self.assertNotIn(field, form.fields)
|
||||
|
||||
def test_should_allow_super_admin_to_add_restricted_group_to_user(self):
|
||||
# given
|
||||
self.app.set_user(self.super_admin)
|
||||
group_restricted = Group.objects.create(name="restricted group")
|
||||
group_restricted.authgroup.restricted = True
|
||||
group_restricted.authgroup.save()
|
||||
page = self.app.get(f"/admin/authentication/user/{self.user.pk}/change/")
|
||||
form = page.forms["user_form"]
|
||||
# when
|
||||
form["groups"].select_multiple(texts=["restricted group"])
|
||||
response = form.submit("save")
|
||||
# then
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.user.refresh_from_db()
|
||||
self.assertIn(
|
||||
"restricted group", self.user.groups.values_list("name", flat=True)
|
||||
)
|
||||
|
||||
def test_should_not_allow_staff_admin_to_add_restricted_group_to_user(self):
|
||||
# given
|
||||
self.app.set_user(self.staff_admin)
|
||||
group_restricted = Group.objects.create(name="restricted group")
|
||||
group_restricted.authgroup.restricted = True
|
||||
group_restricted.authgroup.save()
|
||||
page = self.app.get(f"/admin/authentication/user/{self.user.pk}/change/")
|
||||
form = page.forms["user_form"]
|
||||
# when
|
||||
form["groups"].select_multiple(texts=["restricted group"])
|
||||
response = form.submit("save")
|
||||
# then
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(
|
||||
"You are not allowed to add or remove these restricted groups",
|
||||
response.text
|
||||
)
|
||||
|
||||
def test_should_not_allow_staff_admin_to_remove_restricted_group_from_user(self):
|
||||
# given
|
||||
self.app.set_user(self.staff_admin)
|
||||
group_restricted = Group.objects.create(name="restricted group")
|
||||
group_restricted.authgroup.restricted = True
|
||||
group_restricted.authgroup.save()
|
||||
self.user.groups.add(group_restricted)
|
||||
page = self.app.get(f"/admin/authentication/user/{self.user.pk}/change/")
|
||||
form = page.forms["user_form"]
|
||||
# when
|
||||
form["groups"].select_multiple(texts=[])
|
||||
response = form.submit("save")
|
||||
# then
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(
|
||||
"You are not allowed to add or remove these restricted groups",
|
||||
response.text
|
||||
)
|
||||
|
||||
def test_should_allow_staff_admin_to_add_normal_group_to_user(self):
|
||||
# given
|
||||
self.app.set_user(self.super_admin)
|
||||
Group.objects.create(name="normal group")
|
||||
page = self.app.get(f"/admin/authentication/user/{self.user.pk}/change/")
|
||||
form = page.forms["user_form"]
|
||||
# when
|
||||
form["groups"].select_multiple(texts=["normal group"])
|
||||
response = form.submit("save")
|
||||
# then
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.user.refresh_from_db()
|
||||
self.assertIn("normal group", self.user.groups.values_list("name", flat=True))
|
||||
|
||||
|
||||
class TestMakeServicesHooksActions(TestCaseWithTestData):
|
||||
|
||||
class MyServicesHookTypeA(ServicesHook):
|
||||
|
||||
@@ -212,7 +212,14 @@ def fatlink_monthly_personal_statistics_view(request, year, month, char_id=None)
|
||||
start_of_previous_month = first_day_of_previous_month(year, month)
|
||||
|
||||
if request.user.has_perm('auth.fleetactivitytracking_statistics') and char_id:
|
||||
user = EveCharacter.objects.get(character_id=char_id).user
|
||||
try:
|
||||
user = EveCharacter.objects.get(character_id=char_id).character_ownership.user
|
||||
except EveCharacter.DoesNotExist:
|
||||
messages.error(request, _('Character does not exist'))
|
||||
return redirect('fatlink:view')
|
||||
except AttributeError:
|
||||
messages.error(request, _('User does not exist'))
|
||||
return redirect('fatlink:view')
|
||||
else:
|
||||
user = request.user
|
||||
logger.debug(f"Personal monthly statistics view for user {user} called by {request.user}")
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
from django import forms
|
||||
from django.apps import apps
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.models import Group as BaseGroup, User
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Count
|
||||
from django.db.models.functions import Lower
|
||||
from django.db.models.signals import pre_save, post_save, pre_delete, \
|
||||
post_delete, m2m_changed
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .models import AuthGroup, ReservedGroupName
|
||||
from .models import GroupRequest
|
||||
from django.contrib.auth.models import Group as BaseGroup, Permission, User
|
||||
from django.db.models import Count, Exists, OuterRef
|
||||
from django.db.models.functions import Lower
|
||||
from django.db.models.signals import (
|
||||
m2m_changed,
|
||||
post_delete,
|
||||
post_save,
|
||||
pre_delete,
|
||||
pre_save
|
||||
)
|
||||
from django.dispatch import receiver
|
||||
|
||||
from .forms import GroupAdminForm, ReservedGroupNameAdminForm
|
||||
from .models import AuthGroup, GroupRequest, ReservedGroupName
|
||||
from .tasks import remove_users_not_matching_states_from_group
|
||||
|
||||
if 'eve_autogroups' in apps.app_configs:
|
||||
_has_auto_groups = True
|
||||
@@ -28,10 +30,12 @@ class AuthGroupInlineAdmin(admin.StackedInline):
|
||||
'description',
|
||||
'group_leaders',
|
||||
'group_leader_groups',
|
||||
'states', 'internal',
|
||||
'states',
|
||||
'internal',
|
||||
'hidden',
|
||||
'open',
|
||||
'public'
|
||||
'public',
|
||||
'restricted',
|
||||
)
|
||||
verbose_name_plural = 'Auth Settings'
|
||||
verbose_name = ''
|
||||
@@ -50,6 +54,11 @@ class AuthGroupInlineAdmin(admin.StackedInline):
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return request.user.has_perm('auth.change_group')
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
if not request.user.is_superuser:
|
||||
return self.readonly_fields + ("restricted",)
|
||||
return self.readonly_fields
|
||||
|
||||
|
||||
if _has_auto_groups:
|
||||
class IsAutoGroupFilter(admin.SimpleListFilter):
|
||||
@@ -96,27 +105,15 @@ class HasLeaderFilter(admin.SimpleListFilter):
|
||||
return queryset
|
||||
|
||||
|
||||
class GroupAdminForm(forms.ModelForm):
|
||||
def clean_name(self):
|
||||
my_name = self.cleaned_data['name']
|
||||
if ReservedGroupName.objects.filter(name__iexact=my_name).exists():
|
||||
raise ValidationError(
|
||||
_("This name has been reserved and can not be used for groups."),
|
||||
code='reserved_name'
|
||||
)
|
||||
return my_name
|
||||
|
||||
|
||||
class GroupAdmin(admin.ModelAdmin):
|
||||
form = GroupAdminForm
|
||||
list_select_related = ('authgroup',)
|
||||
ordering = ('name',)
|
||||
list_display = (
|
||||
'name',
|
||||
'_description',
|
||||
'_properties',
|
||||
'_member_count',
|
||||
'has_leader'
|
||||
'has_leader',
|
||||
)
|
||||
list_filter = [
|
||||
'authgroup__internal',
|
||||
@@ -132,34 +129,51 @@ class GroupAdmin(admin.ModelAdmin):
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
if _has_auto_groups:
|
||||
qs = qs.prefetch_related('managedalliancegroup_set', 'managedcorpgroup_set')
|
||||
qs = qs.prefetch_related('authgroup__group_leaders').select_related('authgroup')
|
||||
qs = qs.annotate(
|
||||
member_count=Count('user', distinct=True),
|
||||
has_leader_qs = (
|
||||
AuthGroup.objects.filter(group=OuterRef('pk'), group_leaders__isnull=False)
|
||||
)
|
||||
has_leader_groups_qs = (
|
||||
AuthGroup.objects.filter(
|
||||
group=OuterRef('pk'), group_leader_groups__isnull=False
|
||||
)
|
||||
)
|
||||
qs = (
|
||||
qs.select_related('authgroup')
|
||||
.annotate(member_count=Count('user', distinct=True))
|
||||
.annotate(has_leader=Exists(has_leader_qs))
|
||||
.annotate(has_leader_groups=Exists(has_leader_groups_qs))
|
||||
)
|
||||
if _has_auto_groups:
|
||||
is_autogroup_corp = (
|
||||
Group.objects.filter(
|
||||
pk=OuterRef('pk'), managedcorpgroup__isnull=False
|
||||
)
|
||||
)
|
||||
is_autogroup_alliance = (
|
||||
Group.objects.filter(
|
||||
pk=OuterRef('pk'), managedalliancegroup__isnull=False
|
||||
)
|
||||
)
|
||||
qs = (
|
||||
qs.annotate(is_autogroup_corp=Exists(is_autogroup_corp))
|
||||
.annotate(is_autogroup_alliance=Exists(is_autogroup_alliance))
|
||||
)
|
||||
return qs
|
||||
|
||||
def _description(self, obj):
|
||||
return obj.authgroup.description
|
||||
|
||||
@admin.display(description='Members', ordering='member_count')
|
||||
def _member_count(self, obj):
|
||||
return obj.member_count
|
||||
|
||||
_member_count.short_description = 'Members'
|
||||
_member_count.admin_order_field = 'member_count'
|
||||
|
||||
@admin.display(boolean=True)
|
||||
def has_leader(self, obj):
|
||||
return obj.authgroup.group_leaders.exists() or obj.authgroup.group_leader_groups.exists()
|
||||
|
||||
has_leader.boolean = True
|
||||
return obj.has_leader or obj.has_leader_groups
|
||||
|
||||
def _properties(self, obj):
|
||||
properties = list()
|
||||
if _has_auto_groups and (
|
||||
obj.managedalliancegroup_set.exists()
|
||||
or obj.managedcorpgroup_set.exists()
|
||||
):
|
||||
if _has_auto_groups and (obj.is_autogroup_corp or obj.is_autogroup_alliance):
|
||||
properties.append('Auto Group')
|
||||
elif obj.authgroup.internal:
|
||||
properties.append('Internal')
|
||||
@@ -172,11 +186,10 @@ class GroupAdmin(admin.ModelAdmin):
|
||||
properties.append('Public')
|
||||
if not properties:
|
||||
properties.append('Default')
|
||||
|
||||
if obj.authgroup.restricted:
|
||||
properties.append('Restricted')
|
||||
return properties
|
||||
|
||||
_properties.short_description = "properties"
|
||||
|
||||
filter_horizontal = ('permissions',)
|
||||
inlines = (AuthGroupInlineAdmin,)
|
||||
|
||||
@@ -190,8 +203,15 @@ class GroupAdmin(admin.ModelAdmin):
|
||||
ag_instance = inline_form.save(commit=False)
|
||||
ag_instance.group = form.instance
|
||||
ag_instance.save()
|
||||
if ag_instance.states.exists():
|
||||
remove_users_not_matching_states_from_group.delay(ag_instance.group.pk)
|
||||
formset.save()
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
if not request.user.is_superuser:
|
||||
return self.readonly_fields + ("permissions",)
|
||||
return self.readonly_fields
|
||||
|
||||
|
||||
class Group(BaseGroup):
|
||||
class Meta:
|
||||
@@ -216,33 +236,10 @@ class GroupRequestAdmin(admin.ModelAdmin):
|
||||
'leave_request',
|
||||
)
|
||||
|
||||
@admin.display(boolean=True, description="is leave request")
|
||||
def _leave_request(self, obj) -> True:
|
||||
return obj.leave_request
|
||||
|
||||
_leave_request.short_description = 'is leave request'
|
||||
_leave_request.boolean = True
|
||||
|
||||
|
||||
class ReservedGroupNameAdminForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['created_by'].initial = self.current_user.username
|
||||
self.fields['created_at'].initial = _("(auto)")
|
||||
|
||||
created_by = forms.CharField(disabled=True)
|
||||
created_at = forms.CharField(disabled=True)
|
||||
|
||||
def clean_name(self):
|
||||
my_name = self.cleaned_data['name'].lower()
|
||||
if Group.objects.filter(name__iexact=my_name).exists():
|
||||
raise ValidationError(
|
||||
_("There already exists a group with that name."), code='already_exists'
|
||||
)
|
||||
return my_name
|
||||
|
||||
def clean_created_at(self):
|
||||
return now()
|
||||
|
||||
|
||||
@admin.register(ReservedGroupName)
|
||||
class ReservedGroupNameAdmin(admin.ModelAdmin):
|
||||
|
||||
39
allianceauth/groupmanagement/forms.py
Normal file
39
allianceauth/groupmanagement/forms.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from django import forms
|
||||
from django.contrib.auth.models import Group
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .models import ReservedGroupName
|
||||
|
||||
|
||||
class GroupAdminForm(forms.ModelForm):
|
||||
def clean_name(self):
|
||||
my_name = self.cleaned_data['name']
|
||||
if ReservedGroupName.objects.filter(name__iexact=my_name).exists():
|
||||
raise ValidationError(
|
||||
_("This name has been reserved and can not be used for groups."),
|
||||
code='reserved_name'
|
||||
)
|
||||
return my_name
|
||||
|
||||
|
||||
class ReservedGroupNameAdminForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['created_by'].initial = self.current_user.username
|
||||
self.fields['created_at'].initial = _("(auto)")
|
||||
|
||||
created_by = forms.CharField(disabled=True)
|
||||
created_at = forms.CharField(disabled=True)
|
||||
|
||||
def clean_name(self):
|
||||
my_name = self.cleaned_data['name'].lower()
|
||||
if Group.objects.filter(name__iexact=my_name).exists():
|
||||
raise ValidationError(
|
||||
_("There already exists a group with that name."), code='already_exists'
|
||||
)
|
||||
return my_name
|
||||
|
||||
def clean_created_at(self):
|
||||
return now()
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.10 on 2022-04-08 19:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('groupmanagement', '0018_reservedgroupname'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='authgroup',
|
||||
name='restricted',
|
||||
field=models.BooleanField(default=False, help_text='Group is restricted. This means that adding or removing users for this group requires a superuser admin.'),
|
||||
),
|
||||
]
|
||||
@@ -13,6 +13,7 @@ from allianceauth.notifications import notify
|
||||
|
||||
class GroupRequest(models.Model):
|
||||
"""Request from a user for joining or leaving a group."""
|
||||
|
||||
leave_request = models.BooleanField(default=0)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
group = models.ForeignKey(Group, on_delete=models.CASCADE)
|
||||
@@ -44,6 +45,7 @@ class GroupRequest(models.Model):
|
||||
|
||||
class RequestLog(models.Model):
|
||||
"""Log entry about who joined and left a group and who approved it."""
|
||||
|
||||
request_type = models.BooleanField(null=True)
|
||||
group = models.ForeignKey(Group, on_delete=models.CASCADE)
|
||||
request_info = models.CharField(max_length=254)
|
||||
@@ -95,6 +97,7 @@ class AuthGroup(models.Model):
|
||||
Open - Users are automatically accepted into the group
|
||||
Not Open - Users requests must be approved before they are added to the group
|
||||
"""
|
||||
|
||||
group = models.OneToOneField(Group, on_delete=models.CASCADE, primary_key=True)
|
||||
internal = models.BooleanField(
|
||||
default=True,
|
||||
@@ -126,6 +129,13 @@ class AuthGroup(models.Model):
|
||||
"are no longer authenticated."
|
||||
)
|
||||
)
|
||||
restricted = models.BooleanField(
|
||||
default=False,
|
||||
help_text=_(
|
||||
"Group is restricted. This means that adding or removing users "
|
||||
"for this group requires a superuser admin."
|
||||
)
|
||||
)
|
||||
group_leaders = models.ManyToManyField(
|
||||
User,
|
||||
related_name='leads_groups',
|
||||
@@ -179,12 +189,22 @@ class AuthGroup(models.Model):
|
||||
| User.objects.filter(groups__in=list(self.group_leader_groups.all()))
|
||||
)
|
||||
|
||||
def remove_users_not_matching_states(self):
|
||||
"""Remove users not matching defined states from related group."""
|
||||
states_qs = self.states.all()
|
||||
if states_qs.exists():
|
||||
states = list(states_qs)
|
||||
non_compliant_users = self.group.user_set.exclude(profile__state__in=states)
|
||||
for user in non_compliant_users:
|
||||
self.group.user_set.remove(user)
|
||||
|
||||
|
||||
class ReservedGroupName(models.Model):
|
||||
"""Name that can not be used for groups.
|
||||
|
||||
This enables AA to ignore groups on other services (e.g. Discord) with that name.
|
||||
"""
|
||||
|
||||
name = models.CharField(
|
||||
_('name'),
|
||||
max_length=150,
|
||||
|
||||
10
allianceauth/groupmanagement/tasks.py
Normal file
10
allianceauth/groupmanagement/tasks.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from celery import shared_task
|
||||
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
|
||||
@shared_task
|
||||
def remove_users_not_matching_states_from_group(group_pk: int) -> None:
|
||||
"""Remove users not matching defined states from related group."""
|
||||
group = Group.objects.get(pk=group_pk)
|
||||
group.authgroup.remove_users_not_matching_states()
|
||||
@@ -1,17 +1,21 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django_webtest import WebTest
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.contrib.admin.sites import AdminSite
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase, RequestFactory, Client
|
||||
from django.test import TestCase, RequestFactory, Client, override_settings
|
||||
|
||||
from allianceauth.authentication.models import CharacterOwnership, State
|
||||
from allianceauth.eveonline.models import (
|
||||
EveCharacter, EveCorporationInfo, EveAllianceInfo
|
||||
)
|
||||
from ..admin import HasLeaderFilter, GroupAdmin, Group
|
||||
from allianceauth.tests.auth_utils import AuthUtils
|
||||
|
||||
from . import get_admin_change_view_url
|
||||
from ..admin import HasLeaderFilter, GroupAdmin, Group
|
||||
from ..models import ReservedGroupName
|
||||
|
||||
|
||||
@@ -33,7 +37,6 @@ class MockRequest:
|
||||
|
||||
|
||||
class TestGroupAdmin(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
@@ -233,60 +236,104 @@ class TestGroupAdmin(TestCase):
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
def test_member_count(self):
|
||||
expected = 1
|
||||
obj = self.modeladmin.get_queryset(MockRequest(user=self.user_1))\
|
||||
.get(pk=self.group_1.pk)
|
||||
# given
|
||||
request = MockRequest(user=self.user_1)
|
||||
obj = self.modeladmin.get_queryset(request).get(pk=self.group_1.pk)
|
||||
# when
|
||||
result = self.modeladmin._member_count(obj)
|
||||
self.assertEqual(result, expected)
|
||||
# then
|
||||
self.assertEqual(result, 1)
|
||||
|
||||
def test_has_leader_user(self):
|
||||
result = self.modeladmin.has_leader(self.group_1)
|
||||
# given
|
||||
request = MockRequest(user=self.user_1)
|
||||
obj = self.modeladmin.get_queryset(request).get(pk=self.group_1.pk)
|
||||
# when
|
||||
result = self.modeladmin.has_leader(obj)
|
||||
# then
|
||||
self.assertTrue(result)
|
||||
|
||||
def test_has_leader_group(self):
|
||||
result = self.modeladmin.has_leader(self.group_2)
|
||||
# given
|
||||
request = MockRequest(user=self.user_1)
|
||||
obj = self.modeladmin.get_queryset(request).get(pk=self.group_2.pk)
|
||||
# when
|
||||
result = self.modeladmin.has_leader(obj)
|
||||
# then
|
||||
self.assertTrue(result)
|
||||
|
||||
def test_properties_1(self):
|
||||
expected = ['Default']
|
||||
result = self.modeladmin._properties(self.group_1)
|
||||
self.assertListEqual(result, expected)
|
||||
# given
|
||||
request = MockRequest(user=self.user_1)
|
||||
obj = self.modeladmin.get_queryset(request).get(pk=self.group_1.pk)
|
||||
# when
|
||||
result = self.modeladmin._properties(obj)
|
||||
self.assertListEqual(result, ['Default'])
|
||||
|
||||
def test_properties_2(self):
|
||||
expected = ['Internal']
|
||||
result = self.modeladmin._properties(self.group_2)
|
||||
self.assertListEqual(result, expected)
|
||||
# given
|
||||
request = MockRequest(user=self.user_1)
|
||||
obj = self.modeladmin.get_queryset(request).get(pk=self.group_2.pk)
|
||||
# when
|
||||
result = self.modeladmin._properties(obj)
|
||||
self.assertListEqual(result, ['Internal'])
|
||||
|
||||
def test_properties_3(self):
|
||||
expected = ['Hidden']
|
||||
result = self.modeladmin._properties(self.group_3)
|
||||
self.assertListEqual(result, expected)
|
||||
# given
|
||||
request = MockRequest(user=self.user_1)
|
||||
obj = self.modeladmin.get_queryset(request).get(pk=self.group_3.pk)
|
||||
# when
|
||||
result = self.modeladmin._properties(obj)
|
||||
self.assertListEqual(result, ['Hidden'])
|
||||
|
||||
def test_properties_4(self):
|
||||
expected = ['Open']
|
||||
result = self.modeladmin._properties(self.group_4)
|
||||
self.assertListEqual(result, expected)
|
||||
# given
|
||||
request = MockRequest(user=self.user_1)
|
||||
obj = self.modeladmin.get_queryset(request).get(pk=self.group_4.pk)
|
||||
# when
|
||||
result = self.modeladmin._properties(obj)
|
||||
self.assertListEqual(result, ['Open'])
|
||||
|
||||
def test_properties_5(self):
|
||||
expected = ['Public']
|
||||
result = self.modeladmin._properties(self.group_5)
|
||||
self.assertListEqual(result, expected)
|
||||
# given
|
||||
request = MockRequest(user=self.user_1)
|
||||
obj = self.modeladmin.get_queryset(request).get(pk=self.group_5.pk)
|
||||
# when
|
||||
result = self.modeladmin._properties(obj)
|
||||
self.assertListEqual(result, ['Public'])
|
||||
|
||||
def test_properties_6(self):
|
||||
expected = ['Hidden', 'Open', 'Public']
|
||||
result = self.modeladmin._properties(self.group_6)
|
||||
self.assertListEqual(result, expected)
|
||||
# given
|
||||
request = MockRequest(user=self.user_1)
|
||||
obj = self.modeladmin.get_queryset(request).get(pk=self.group_6.pk)
|
||||
# when
|
||||
result = self.modeladmin._properties(obj)
|
||||
self.assertListEqual(result, ['Hidden', 'Open', 'Public'])
|
||||
|
||||
if _has_auto_groups:
|
||||
@patch(MODULE_PATH + '._has_auto_groups', True)
|
||||
def test_properties_7(self):
|
||||
def test_should_show_autogroup_for_corporation(self):
|
||||
# given
|
||||
self._create_autogroups()
|
||||
expected = ['Auto Group']
|
||||
my_group = Group.objects\
|
||||
.filter(managedcorpgroup__isnull=False)\
|
||||
.first()
|
||||
result = self.modeladmin._properties(my_group)
|
||||
self.assertListEqual(result, expected)
|
||||
request = MockRequest(user=self.user_1)
|
||||
queryset = self.modeladmin.get_queryset(request)
|
||||
obj = queryset.filter(managedcorpgroup__isnull=False).first()
|
||||
# when
|
||||
result = self.modeladmin._properties(obj)
|
||||
# then
|
||||
self.assertListEqual(result, ['Auto Group'])
|
||||
|
||||
@patch(MODULE_PATH + '._has_auto_groups', True)
|
||||
def test_should_show_autogroup_for_alliance(self):
|
||||
# given
|
||||
self._create_autogroups()
|
||||
request = MockRequest(user=self.user_1)
|
||||
queryset = self.modeladmin.get_queryset(request)
|
||||
obj = queryset.filter(managedalliancegroup__isnull=False).first()
|
||||
# when
|
||||
result = self.modeladmin._properties(obj)
|
||||
# then
|
||||
self.assertListEqual(result, ['Auto Group'])
|
||||
|
||||
# actions
|
||||
|
||||
@@ -468,6 +515,136 @@ class TestGroupAdmin(TestCase):
|
||||
self.assertFalse(Group.objects.filter(name="new group").exists())
|
||||
|
||||
|
||||
class TestGroupAdminChangeFormSuperuserExclusiveEdits(WebTest):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
super().setUpClass()
|
||||
cls.super_admin = User.objects.create_superuser("super_admin")
|
||||
cls.staff_admin = User.objects.create_user("staff_admin")
|
||||
cls.staff_admin.is_staff = True
|
||||
cls.staff_admin.save()
|
||||
cls.staff_admin = AuthUtils.add_permissions_to_user_by_name(
|
||||
[
|
||||
"auth.add_group",
|
||||
"auth.change_group",
|
||||
"auth.view_group",
|
||||
"groupmanagement.add_group",
|
||||
"groupmanagement.change_group",
|
||||
"groupmanagement.view_group",
|
||||
],
|
||||
cls.staff_admin
|
||||
)
|
||||
cls.superuser_exclusive_fields = ["permissions", "authgroup-0-restricted"]
|
||||
|
||||
def test_should_show_all_fields_to_superuser_for_add(self):
|
||||
# given
|
||||
self.app.set_user(self.super_admin)
|
||||
page = self.app.get("/admin/groupmanagement/group/add/")
|
||||
# when
|
||||
form = page.forms["group_form"]
|
||||
# then
|
||||
for field in self.superuser_exclusive_fields:
|
||||
with self.subTest(field=field):
|
||||
self.assertIn(field, form.fields)
|
||||
|
||||
def test_should_not_show_all_fields_to_staff_admins_for_add(self):
|
||||
# given
|
||||
self.app.set_user(self.staff_admin)
|
||||
page = self.app.get("/admin/groupmanagement/group/add/")
|
||||
# when
|
||||
form = page.forms["group_form"]
|
||||
# then
|
||||
for field in self.superuser_exclusive_fields:
|
||||
with self.subTest(field=field):
|
||||
self.assertNotIn(field, form.fields)
|
||||
|
||||
def test_should_show_all_fields_to_superuser_for_change(self):
|
||||
# given
|
||||
self.app.set_user(self.super_admin)
|
||||
group = Group.objects.create(name="Dummy group")
|
||||
page = self.app.get(f"/admin/groupmanagement/group/{group.pk}/change/")
|
||||
# when
|
||||
form = page.forms["group_form"]
|
||||
# then
|
||||
for field in self.superuser_exclusive_fields:
|
||||
with self.subTest(field=field):
|
||||
self.assertIn(field, form.fields)
|
||||
|
||||
def test_should_not_show_all_fields_to_staff_admin_for_change(self):
|
||||
# given
|
||||
self.app.set_user(self.staff_admin)
|
||||
group = Group.objects.create(name="Dummy group")
|
||||
page = self.app.get(f"/admin/groupmanagement/group/{group.pk}/change/")
|
||||
# when
|
||||
form = page.forms["group_form"]
|
||||
# then
|
||||
for field in self.superuser_exclusive_fields:
|
||||
with self.subTest(field=field):
|
||||
self.assertNotIn(field, form.fields)
|
||||
|
||||
|
||||
@override_settings(CELERY_ALWAYS_EAGER=True, CELERY_EAGER_PROPAGATES_EXCEPTIONS=True)
|
||||
class TestGroupAdmin2(TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.superuser = User.objects.create_superuser("super")
|
||||
|
||||
def test_should_remove_users_from_state_groups(self):
|
||||
# given
|
||||
user_member = AuthUtils.create_user("Bruce Wayne")
|
||||
character_member = AuthUtils.add_main_character_2(
|
||||
user_member,
|
||||
name="Bruce Wayne",
|
||||
character_id=1001,
|
||||
corp_id=2001,
|
||||
corp_name="Wayne Technologies",
|
||||
)
|
||||
user_guest = AuthUtils.create_user("Lex Luthor")
|
||||
AuthUtils.add_main_character_2(
|
||||
user_guest,
|
||||
name="Lex Luthor",
|
||||
character_id=1011,
|
||||
corp_id=2011,
|
||||
corp_name="Luthor Corp",
|
||||
)
|
||||
member_state = AuthUtils.get_member_state()
|
||||
member_state.member_characters.add(character_member)
|
||||
user_member.refresh_from_db()
|
||||
user_guest.refresh_from_db()
|
||||
group = Group.objects.create(name="dummy")
|
||||
user_member.groups.add(group)
|
||||
user_guest.groups.add(group)
|
||||
group.authgroup.states.add(member_state)
|
||||
self.client.force_login(self.superuser)
|
||||
# when
|
||||
response = self.client.post(
|
||||
f"/admin/groupmanagement/group/{group.pk}/change/",
|
||||
data={
|
||||
"name": f"{group.name}",
|
||||
"authgroup-TOTAL_FORMS": "1",
|
||||
"authgroup-INITIAL_FORMS": "1",
|
||||
"authgroup-MIN_NUM_FORMS": "0",
|
||||
"authgroup-MAX_NUM_FORMS": "1",
|
||||
"authgroup-0-description": "",
|
||||
"authgroup-0-states": f"{member_state.pk}",
|
||||
"authgroup-0-internal": "on",
|
||||
"authgroup-0-hidden": "on",
|
||||
"authgroup-0-group": f"{group.pk}",
|
||||
"authgroup-__prefix__-description": "",
|
||||
"authgroup-__prefix__-internal": "on",
|
||||
"authgroup-__prefix__-hidden": "on",
|
||||
"authgroup-__prefix__-group": f"{group.pk}",
|
||||
"_save": "Save"
|
||||
}
|
||||
)
|
||||
# then
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, "/admin/groupmanagement/group/")
|
||||
self.assertIn(group, user_member.groups.all())
|
||||
self.assertNotIn(group, user_guest.groups.all())
|
||||
|
||||
|
||||
class TestReservedGroupNameAdmin(TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
|
||||
@@ -232,6 +232,38 @@ class TestAuthGroup(TestCase):
|
||||
expected = 'Superheros'
|
||||
self.assertEqual(str(group.authgroup), expected)
|
||||
|
||||
def test_should_remove_guests_from_group_when_restricted_to_members_only(self):
|
||||
# given
|
||||
user_member = AuthUtils.create_user("Bruce Wayne")
|
||||
character_member = AuthUtils.add_main_character_2(
|
||||
user_member,
|
||||
name="Bruce Wayne",
|
||||
character_id=1001,
|
||||
corp_id=2001,
|
||||
corp_name="Wayne Technologies",
|
||||
)
|
||||
user_guest = AuthUtils.create_user("Lex Luthor")
|
||||
AuthUtils.add_main_character_2(
|
||||
user_guest,
|
||||
name="Lex Luthor",
|
||||
character_id=1011,
|
||||
corp_id=2011,
|
||||
corp_name="Luthor Corp",
|
||||
)
|
||||
member_state = AuthUtils.get_member_state()
|
||||
member_state.member_characters.add(character_member)
|
||||
user_member.refresh_from_db()
|
||||
user_guest.refresh_from_db()
|
||||
group = Group.objects.create(name="dummy")
|
||||
user_member.groups.add(group)
|
||||
user_guest.groups.add(group)
|
||||
group.authgroup.states.add(member_state)
|
||||
# when
|
||||
group.authgroup.remove_users_not_matching_states()
|
||||
# then
|
||||
self.assertIn(group, user_member.groups.all())
|
||||
self.assertNotIn(group, user_guest.groups.all())
|
||||
|
||||
|
||||
class TestAuthGroupRequestApprovers(TestCase):
|
||||
def setUp(self) -> None:
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
from .core import notify # noqa: F401
|
||||
|
||||
default_app_config = 'allianceauth.notifications.apps.NotificationsConfig'
|
||||
|
||||
|
||||
def notify(
|
||||
user: object, title: str, message: str = None, level: str = 'info'
|
||||
) -> None:
|
||||
"""Sends a new notification to user. Convenience function to manager pendant."""
|
||||
from .models import Notification
|
||||
Notification.objects.notify_user(user, title, message, level)
|
||||
|
||||
33
allianceauth/notifications/core.py
Normal file
33
allianceauth/notifications/core.py
Normal file
@@ -0,0 +1,33 @@
|
||||
class NotifyApiWrapper:
|
||||
"""Wrapper to create notify API."""
|
||||
|
||||
def __call__(self, *args, **kwargs): # provide old API for backwards compatibility
|
||||
return self._add_notification(*args, **kwargs)
|
||||
|
||||
def danger(self, user: object, title: str, message: str = None) -> None:
|
||||
"""Add danger notification for user."""
|
||||
self._add_notification(user, title, message, level="danger")
|
||||
|
||||
def info(self, user: object, title: str, message: str = None) -> None:
|
||||
"""Add info notification for user."""
|
||||
self._add_notification(user=user, title=title, message=message, level="info")
|
||||
|
||||
def success(self, user: object, title: str, message: str = None) -> None:
|
||||
"""Add success notification for user."""
|
||||
self._add_notification(user, title, message, level="success")
|
||||
|
||||
def warning(self, user: object, title: str, message: str = None) -> None:
|
||||
"""Add warning notification for user."""
|
||||
self._add_notification(user, title, message, level="warning")
|
||||
|
||||
def _add_notification(
|
||||
self, user: object, title: str, message: str = None, level: str = "info"
|
||||
) -> None:
|
||||
from .models import Notification
|
||||
|
||||
Notification.objects.notify_user(
|
||||
user=user, title=title, message=message, level=level
|
||||
)
|
||||
|
||||
|
||||
notify = NotifyApiWrapper()
|
||||
@@ -5,91 +5,34 @@
|
||||
{% block page_title %}{% translate "Notifications" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="col-lg-12">
|
||||
<h1 class="page-header text-center">{% translate "Notifications" %}</h1>
|
||||
<div class="col-lg-12 container" id="example">
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<ul class="nav nav-pills">
|
||||
<li class="active"><a data-toggle="pill" href="#unread">{% translate "Unread" %}
|
||||
<b>({{ unread|length }})</b></a></li>
|
||||
<li><a data-toggle="pill" href="#read">{% translate "Read" %} <b>({{ read|length }})</b></a>
|
||||
</li>
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'notifications:mark_all_read' %}" class="btn btn-primary">{% translate "Mark All Read" %}</a>
|
||||
<a href="{% url 'notifications:delete_all_read' %}" class="btn btn-danger">{% translate "Delete All Read" %}</a>
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="tab-content">
|
||||
<div id="unread" class="tab-pane fade in active">
|
||||
<div class="table-responsive">
|
||||
{% if unread %}
|
||||
<table class="table table-condensed table-hover table-striped">
|
||||
<tr>
|
||||
<th class="text-center">{% translate "Timestamp" %}</th>
|
||||
<th class="text-center">{% translate "Title" %}</th>
|
||||
<th class="text-center">{% translate "Action" %}</th>
|
||||
</tr>
|
||||
{% for notif in unread %}
|
||||
<tr class="{{ notif.level }}">
|
||||
<td class="text-center">{{ notif.timestamp }}</td>
|
||||
<td class="text-center">{{ notif.title }}</td>
|
||||
<td class="text-center">
|
||||
<a href="{% url 'notifications:view' notif.id %}" class="btn btn-success" title="View">
|
||||
<span class="glyphicon glyphicon-eye-open"></span>
|
||||
</a>
|
||||
<a href="{% url 'notifications:remove' notif.id %}" class="btn btn-danger" title="Remove">
|
||||
<span class="glyphicon glyphicon-remove"></span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="alert alert-warning text-center">{% translate "No unread notifications." %}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div id="read" class="tab-pane fade">
|
||||
<div class="panel-body">
|
||||
<div class="table-responsive">
|
||||
{% if read %}
|
||||
<table class="table table-condensed table-hover table-striped">
|
||||
<tr>
|
||||
<th class="text-center">{% translate "Timestamp" %}</th>
|
||||
<th class="text-center">{% translate "Title" %}</th>
|
||||
<th class="text-center">{% translate "Action" %}</th>
|
||||
</tr>
|
||||
{% for notif in read %}
|
||||
<tr class="{{ notif.level }}">
|
||||
<td class="text-center">{{ notif.timestamp }}</td>
|
||||
<td class="text-center">{{ notif.title }}</td>
|
||||
<td class="text-center">
|
||||
<a href="{% url 'notifications:view' notif.id %}" class="btn btn-success" title="View">
|
||||
<span class="glyphicon glyphicon-eye-open"></span>
|
||||
</a>
|
||||
<a href="{% url 'notifications:remove' notif.id %}" class="btn btn-danger" title="remove">
|
||||
<span class="glyphicon glyphicon-remove"></span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="alert alert-warning text-center">{% translate "No read notifications." %}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="page-header text-center">{% translate "Notifications" %}</h1>
|
||||
|
||||
<div class="panel panel-default">
|
||||
|
||||
<div class="panel-heading">
|
||||
<ul class="nav nav-pills">
|
||||
<li class="active"><a data-toggle="tab" href="#unread">{% translate "Unread" %}<b>({{ unread|length }})</b></a></li>
|
||||
<li><a data-toggle="tab" href="#read">{% translate "Read" %} <b>({{ read|length }})</b></a></li>
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'notifications:mark_all_read' %}" class="btn btn-warning">{% translate "Mark All Read" %}</a>
|
||||
<a href="{% url 'notifications:delete_all_read' %}" class="btn btn-danger">{% translate "Delete All Read" %}</a>
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<div class="tab-content">
|
||||
|
||||
<div id="unread" class="tab-pane fade in active">
|
||||
{% include "notifications/list_partial.html" with notifications=unread %}
|
||||
</div>
|
||||
|
||||
<div id="read" class="tab-pane fade">
|
||||
{% include "notifications/list_partial.html" with notifications=read %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% if notifications %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-condensed table-hover table-striped">
|
||||
<tr>
|
||||
<th class="text-center">{% translate "Timestamp" %}</th>
|
||||
<th class="text-center">{% translate "Title" %}</th>
|
||||
<th class="text-center">{% translate "Action" %}</th>
|
||||
</tr>
|
||||
{% for notif in notifications %}
|
||||
<tr class="{{ notif.level }}">
|
||||
<td class="text-center">{{ notif.timestamp }}</td>
|
||||
<td class="text-center">{{ notif.title }}</td>
|
||||
<td class="text-center">
|
||||
<a href="{% url 'notifications:view' notif.id %}" class="btn btn-primary" title="View">
|
||||
<span class="glyphicon glyphicon-eye-open"></span>
|
||||
</a>
|
||||
<a href="{% url 'notifications:remove' notif.id %}" class="btn btn-danger" title="Remove">
|
||||
<span class="glyphicon glyphicon-remove"></span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-default text-center">{% translate "No notifications." %}</div>
|
||||
{% endif %}
|
||||
@@ -5,25 +5,22 @@
|
||||
{% block page_title %}{% translate "View Notification" %}{% endblock page_title %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="page-header text-center">
|
||||
{% translate "View Notification" %}
|
||||
<div class="text-right">
|
||||
<a href="{% url 'notifications:list' %}" class="btn btn-primary btn-lg">
|
||||
<span class="glyphicon glyphicon-arrow-left"></span>
|
||||
</a>
|
||||
</div>
|
||||
</h1>
|
||||
|
||||
<div class="col-lg-12">
|
||||
<h1 class="page-header text-center">
|
||||
{% translate "View Notification" %}
|
||||
<div class="text-right">
|
||||
<a href="{% url 'notifications:list' %}" class="btn btn-primary btn-lg">
|
||||
<span class="glyphicon glyphicon-arrow-left"></span>
|
||||
</a>
|
||||
</div>
|
||||
</h1>
|
||||
<div class="col-lg-12 container">
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
<div class="panel panel-{{ notif.level }}">
|
||||
<div class="panel-heading">{{ notif.timestamp }} {{ notif.title }}</div>
|
||||
<div class="panel-body"><pre>{{ notif.message }}</pre></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
<div class="panel panel-{{ notif.level }}">
|
||||
<div class="panel-heading">{{ notif.timestamp }} {{ notif.title }}</div>
|
||||
<div class="panel-body"><pre>{{ notif.message }}</pre></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
85
allianceauth/notifications/tests/test_core.py
Normal file
85
allianceauth/notifications/tests/test_core.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from allianceauth.tests.auth_utils import AuthUtils
|
||||
|
||||
from ..core import NotifyApiWrapper
|
||||
from ..models import Notification
|
||||
|
||||
|
||||
class TestUserNotificationCount(TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
super().setUpClass()
|
||||
cls.user = AuthUtils.create_user("bruce_wayne")
|
||||
|
||||
def test_should_add_danger_notification(self):
|
||||
# given
|
||||
notify = NotifyApiWrapper()
|
||||
# when
|
||||
notify.danger(user=self.user, title="title", message="message")
|
||||
# then
|
||||
obj = Notification.objects.first()
|
||||
self.assertEqual(obj.user, self.user)
|
||||
self.assertEqual(obj.title, "title")
|
||||
self.assertEqual(obj.message, "message")
|
||||
self.assertEqual(obj.level, Notification.Level.DANGER)
|
||||
|
||||
def test_should_add_info_notification(self):
|
||||
# given
|
||||
notify = NotifyApiWrapper()
|
||||
# when
|
||||
notify.info(user=self.user, title="title", message="message")
|
||||
# then
|
||||
obj = Notification.objects.first()
|
||||
self.assertEqual(obj.user, self.user)
|
||||
self.assertEqual(obj.title, "title")
|
||||
self.assertEqual(obj.message, "message")
|
||||
self.assertEqual(obj.level, Notification.Level.INFO)
|
||||
|
||||
def test_should_add_success_notification(self):
|
||||
# given
|
||||
notify = NotifyApiWrapper()
|
||||
# when
|
||||
notify.success(user=self.user, title="title", message="message")
|
||||
# then
|
||||
obj = Notification.objects.first()
|
||||
self.assertEqual(obj.user, self.user)
|
||||
self.assertEqual(obj.title, "title")
|
||||
self.assertEqual(obj.message, "message")
|
||||
self.assertEqual(obj.level, Notification.Level.SUCCESS)
|
||||
|
||||
def test_should_add_warning_notification(self):
|
||||
# given
|
||||
notify = NotifyApiWrapper()
|
||||
# when
|
||||
notify.warning(user=self.user, title="title", message="message")
|
||||
# then
|
||||
obj = Notification.objects.first()
|
||||
self.assertEqual(obj.user, self.user)
|
||||
self.assertEqual(obj.title, "title")
|
||||
self.assertEqual(obj.message, "message")
|
||||
self.assertEqual(obj.level, Notification.Level.WARNING)
|
||||
|
||||
def test_should_add_info_notification_via_callable(self):
|
||||
# given
|
||||
notify = NotifyApiWrapper()
|
||||
# when
|
||||
notify(user=self.user, title="title", message="message")
|
||||
# then
|
||||
obj = Notification.objects.first()
|
||||
self.assertEqual(obj.user, self.user)
|
||||
self.assertEqual(obj.title, "title")
|
||||
self.assertEqual(obj.message, "message")
|
||||
self.assertEqual(obj.level, Notification.Level.INFO)
|
||||
|
||||
def test_should_add_danger_notification_via_callable(self):
|
||||
# given
|
||||
notify = NotifyApiWrapper()
|
||||
# when
|
||||
notify(user=self.user, title="title", message="message", level="danger")
|
||||
# then
|
||||
obj = Notification.objects.first()
|
||||
self.assertEqual(obj.user, self.user)
|
||||
self.assertEqual(obj.title, "title")
|
||||
self.assertEqual(obj.message, "message")
|
||||
self.assertEqual(obj.level, Notification.Level.DANGER)
|
||||
@@ -4,11 +4,8 @@ from allianceauth.tests.auth_utils import AuthUtils
|
||||
from .. import notify
|
||||
from ..models import Notification
|
||||
|
||||
MODULE_PATH = 'allianceauth.notifications'
|
||||
|
||||
|
||||
class TestUserNotificationCount(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.user = AuthUtils.create_user('magic_mike')
|
||||
@@ -23,6 +20,18 @@ class TestUserNotificationCount(TestCase):
|
||||
alliance_name='RIDERS'
|
||||
)
|
||||
|
||||
def test_can_notify(self):
|
||||
notify(self.user, 'dummy')
|
||||
def test_can_notify_short(self):
|
||||
# when
|
||||
notify(self.user, "dummy")
|
||||
# then
|
||||
self.assertEqual(Notification.objects.filter(user=self.user).count(), 1)
|
||||
|
||||
def test_can_notify_full(self):
|
||||
# when
|
||||
notify(user=self.user, title="title", message="message", level="danger")
|
||||
# then
|
||||
obj = Notification.objects.first()
|
||||
self.assertEqual(obj.user, self.user)
|
||||
self.assertEqual(obj.title, "title")
|
||||
self.assertEqual(obj.message, "message")
|
||||
self.assertEqual(obj.level, "danger")
|
||||
|
||||
@@ -8,7 +8,7 @@ from uuid import uuid1
|
||||
from redis import Redis
|
||||
import requests
|
||||
|
||||
from django.core.cache import caches
|
||||
from allianceauth.utils.cache import get_redis_client
|
||||
|
||||
from allianceauth import __title__ as AUTH_TITLE, __url__, __version__
|
||||
|
||||
@@ -103,8 +103,7 @@ class DiscordClient:
|
||||
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()
|
||||
self._redis = get_redis_client()
|
||||
if not isinstance(self._redis, Redis):
|
||||
raise RuntimeError(
|
||||
'This class requires a Redis client, but none was provided '
|
||||
|
||||
@@ -85,29 +85,18 @@ class TestBasicsAndHelpers(TestCase):
|
||||
client = DiscordClient(TEST_BOT_TOKEN, mock_redis, is_rate_limited=True)
|
||||
self.assertTrue(client.is_rate_limited)
|
||||
|
||||
@patch(MODULE_PATH + '.caches')
|
||||
def test_use_default_redis_if_none_provided(self, mock_caches):
|
||||
my_redis = MagicMock(spec=Redis)
|
||||
mock_default_cache = MagicMock(**{'get_master_client.return_value': my_redis})
|
||||
my_dict = {'default': mock_default_cache}
|
||||
mock_caches.__getitem__.side_effect = my_dict.__getitem__
|
||||
|
||||
def test_use_default_redis_if_none_provided(self):
|
||||
client = DiscordClient(TEST_BOT_TOKEN)
|
||||
self.assertTrue(mock_default_cache.get_master_client.called)
|
||||
self.assertEqual(client._redis, my_redis)
|
||||
|
||||
@patch(MODULE_PATH + '.caches')
|
||||
def test_raise_exception_if_default_cache_is_not_redis(self, mock_caches):
|
||||
my_redis = MagicMock()
|
||||
mock_default_cache = MagicMock(**{'get_master_client.return_value': my_redis})
|
||||
my_dict = {'default': mock_default_cache}
|
||||
mock_caches.__getitem__.side_effect = my_dict.__getitem__
|
||||
self.assertIsInstance(client._redis, Redis)
|
||||
|
||||
@patch(MODULE_PATH + '.get_redis_client')
|
||||
def test_raise_exception_if_redis_client_not_found(self, mock_get_redis_client):
|
||||
# given
|
||||
mock_get_redis_client.return_value = None
|
||||
# when
|
||||
with self.assertRaises(RuntimeError):
|
||||
DiscordClient(TEST_BOT_TOKEN)
|
||||
|
||||
self.assertTrue(mock_default_cache.get_master_client.called)
|
||||
|
||||
|
||||
@requests_mock.Mocker()
|
||||
class TestOtherMethods(TestCase):
|
||||
|
||||
@@ -35,17 +35,17 @@ import logging
|
||||
from uuid import uuid1
|
||||
import random
|
||||
|
||||
from django.core.cache import caches
|
||||
from django.contrib.auth.models import User, Group
|
||||
|
||||
from allianceauth.services.modules.discord.models import DiscordUser
|
||||
from allianceauth.utils.cache import get_redis_client
|
||||
|
||||
logger = logging.getLogger('allianceauth')
|
||||
MAX_RUNS = 3
|
||||
|
||||
|
||||
def clear_cache():
|
||||
default_cache = caches['default']
|
||||
redis = default_cache.get_master_client()
|
||||
redis = get_redis_client()
|
||||
redis.flushall()
|
||||
logger.info('Cache flushed')
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ from requests.exceptions import HTTPError
|
||||
import requests_mock
|
||||
|
||||
from django.contrib.auth.models import Group, User
|
||||
from django.core.cache import caches
|
||||
from django.shortcuts import reverse
|
||||
from django.test import TransactionTestCase, TestCase
|
||||
from django.test.utils import override_settings
|
||||
@@ -23,6 +22,7 @@ from allianceauth.authentication.models import State
|
||||
from allianceauth.eveonline.models import EveCharacter
|
||||
from allianceauth.notifications.models import Notification
|
||||
from allianceauth.tests.auth_utils import AuthUtils
|
||||
from allianceauth.utils.cache import get_redis_client
|
||||
|
||||
from . import (
|
||||
TEST_GUILD_ID,
|
||||
@@ -87,8 +87,7 @@ remove_guild_member_request = DiscordRequest(
|
||||
|
||||
|
||||
def clear_cache():
|
||||
default_cache = caches['default']
|
||||
redis = default_cache.get_master_client()
|
||||
redis = get_redis_client()
|
||||
redis.flushall()
|
||||
logger.info('Cache flushed')
|
||||
|
||||
@@ -109,7 +108,6 @@ def reset_testdata():
|
||||
class TestServiceFeatures(TransactionTestCase):
|
||||
fixtures = ['disable_analytics.json']
|
||||
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from functools import partial
|
||||
|
||||
from django.contrib.auth.models import User, Group, Permission
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
@@ -8,7 +9,7 @@ from django.db.models.signals import pre_delete
|
||||
from django.db.models.signals import pre_save
|
||||
from django.dispatch import receiver
|
||||
from .hooks import ServicesHook
|
||||
from .tasks import disable_user
|
||||
from .tasks import disable_user, update_groups_for_user
|
||||
|
||||
from allianceauth.authentication.models import State, UserProfile
|
||||
from allianceauth.authentication.signals import state_changed
|
||||
@@ -19,21 +20,27 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
@receiver(m2m_changed, sender=User.groups.through)
|
||||
def m2m_changed_user_groups(sender, instance, action, *args, **kwargs):
|
||||
logger.debug(f"Received m2m_changed from {instance} groups with action {action}")
|
||||
|
||||
def trigger_service_group_update():
|
||||
logger.debug("Triggering service group update for %s" % instance)
|
||||
# Iterate through Service hooks
|
||||
for svc in ServicesHook.get_services():
|
||||
try:
|
||||
svc.validate_user(instance)
|
||||
svc.update_groups(instance)
|
||||
except:
|
||||
logger.exception(f'Exception running update_groups for services module {svc} on user {instance}')
|
||||
|
||||
if instance.pk and (action == "post_add" or action == "post_remove" or action == "post_clear"):
|
||||
logger.debug("Waiting for commit to trigger service group update for %s" % instance)
|
||||
transaction.on_commit(trigger_service_group_update)
|
||||
logger.debug(
|
||||
"%s: Received m2m_changed from groups with action %s", instance, action
|
||||
)
|
||||
if instance.pk and (
|
||||
action == "post_add" or action == "post_remove" or action == "post_clear"
|
||||
):
|
||||
if isinstance(instance, User):
|
||||
logger.debug(
|
||||
"Waiting for commit to trigger service group update for %s", instance
|
||||
)
|
||||
transaction.on_commit(partial(update_groups_for_user.delay, instance.pk))
|
||||
elif (
|
||||
isinstance(instance, Group)
|
||||
and kwargs.get("model") is User
|
||||
and "pk_set" in kwargs
|
||||
):
|
||||
for user_pk in kwargs["pk_set"]:
|
||||
logger.debug(
|
||||
"%s: Waiting for commit to trigger service group update for user", user_pk
|
||||
)
|
||||
transaction.on_commit(partial(update_groups_for_user.delay, user_pk))
|
||||
|
||||
|
||||
@receiver(m2m_changed, sender=User.user_permissions.through)
|
||||
|
||||
@@ -47,3 +47,20 @@ def disable_user(user):
|
||||
for svc in ServicesHook.get_services():
|
||||
if svc.service_active_for_user(user):
|
||||
svc.delete_user(user)
|
||||
|
||||
|
||||
@shared_task
|
||||
def update_groups_for_user(user_pk: int) -> None:
|
||||
"""Update groups for all services registered to a user."""
|
||||
user = User.objects.get(pk=user_pk)
|
||||
logger.debug("%s: Triggering service group update for user", user)
|
||||
for svc in ServicesHook.get_services():
|
||||
try:
|
||||
svc.validate_user(user)
|
||||
svc.update_groups(user)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
'Exception running update_groups for services module %s on user %s',
|
||||
svc,
|
||||
user
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from copy import deepcopy
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase
|
||||
from django.test import override_settings, TestCase, TransactionTestCase
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
|
||||
from allianceauth.authentication.models import State
|
||||
@@ -9,6 +9,9 @@ from allianceauth.eveonline.models import EveCharacter
|
||||
from allianceauth.tests.auth_utils import AuthUtils
|
||||
|
||||
|
||||
MODULE_PATH = 'allianceauth.services.signals'
|
||||
|
||||
|
||||
class ServicesSignalsTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.member = AuthUtils.create_user('auth_member', disconnect_signals=True)
|
||||
@@ -17,17 +20,12 @@ class ServicesSignalsTestCase(TestCase):
|
||||
)
|
||||
self.none_user = AuthUtils.create_user('none_user', disconnect_signals=True)
|
||||
|
||||
@mock.patch('allianceauth.services.signals.transaction')
|
||||
@mock.patch('allianceauth.services.signals.ServicesHook')
|
||||
def test_m2m_changed_user_groups(self, services_hook, transaction):
|
||||
@mock.patch(MODULE_PATH + '.transaction', spec=True)
|
||||
@mock.patch(MODULE_PATH + '.update_groups_for_user', spec=True)
|
||||
def test_m2m_changed_user_groups(self, update_groups_for_user, transaction):
|
||||
"""
|
||||
Test that update_groups hook function is called on user groups change
|
||||
"""
|
||||
svc = mock.Mock()
|
||||
svc.update_groups.return_value = None
|
||||
svc.validate_user.return_value = None
|
||||
|
||||
services_hook.get_services.return_value = [svc]
|
||||
|
||||
# Overload transaction.on_commit so everything happens synchronously
|
||||
transaction.on_commit = lambda fn: fn()
|
||||
@@ -39,17 +37,11 @@ class ServicesSignalsTestCase(TestCase):
|
||||
self.member.save()
|
||||
|
||||
# Assert
|
||||
self.assertTrue(services_hook.get_services.called)
|
||||
self.assertTrue(update_groups_for_user.delay.called)
|
||||
args, _ = update_groups_for_user.delay.call_args
|
||||
self.assertEqual(self.member.pk, args[0])
|
||||
|
||||
self.assertTrue(svc.update_groups.called)
|
||||
args, kwargs = svc.update_groups.call_args
|
||||
self.assertEqual(self.member, args[0])
|
||||
|
||||
self.assertTrue(svc.validate_user.called)
|
||||
args, kwargs = svc.validate_user.call_args
|
||||
self.assertEqual(self.member, args[0])
|
||||
|
||||
@mock.patch('allianceauth.services.signals.disable_user')
|
||||
@mock.patch(MODULE_PATH + '.disable_user')
|
||||
def test_pre_delete_user(self, disable_user):
|
||||
"""
|
||||
Test that disable_member is called when a user is deleted
|
||||
@@ -60,7 +52,7 @@ class ServicesSignalsTestCase(TestCase):
|
||||
args, kwargs = disable_user.call_args
|
||||
self.assertEqual(self.none_user, args[0])
|
||||
|
||||
@mock.patch('allianceauth.services.signals.disable_user')
|
||||
@mock.patch(MODULE_PATH + '.disable_user')
|
||||
def test_pre_save_user_inactivation(self, disable_user):
|
||||
"""
|
||||
Test a user set inactive has disable_member called
|
||||
@@ -72,7 +64,7 @@ class ServicesSignalsTestCase(TestCase):
|
||||
args, kwargs = disable_user.call_args
|
||||
self.assertEqual(self.member, args[0])
|
||||
|
||||
@mock.patch('allianceauth.services.signals.disable_user')
|
||||
@mock.patch(MODULE_PATH + '.disable_user')
|
||||
def test_disable_services_on_loss_of_main_character(self, disable_user):
|
||||
"""
|
||||
Test a user set inactive has disable_member called
|
||||
@@ -84,8 +76,8 @@ class ServicesSignalsTestCase(TestCase):
|
||||
args, kwargs = disable_user.call_args
|
||||
self.assertEqual(self.member, args[0])
|
||||
|
||||
@mock.patch('allianceauth.services.signals.transaction')
|
||||
@mock.patch('allianceauth.services.signals.ServicesHook')
|
||||
@mock.patch(MODULE_PATH + '.transaction')
|
||||
@mock.patch(MODULE_PATH + '.ServicesHook')
|
||||
def test_m2m_changed_group_permissions(self, services_hook, transaction):
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
svc = mock.Mock()
|
||||
@@ -116,8 +108,8 @@ class ServicesSignalsTestCase(TestCase):
|
||||
args, kwargs = svc.validate_user.call_args
|
||||
self.assertEqual(self.member, args[0])
|
||||
|
||||
@mock.patch('allianceauth.services.signals.transaction')
|
||||
@mock.patch('allianceauth.services.signals.ServicesHook')
|
||||
@mock.patch(MODULE_PATH + '.transaction')
|
||||
@mock.patch(MODULE_PATH + '.ServicesHook')
|
||||
def test_m2m_changed_user_permissions(self, services_hook, transaction):
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
svc = mock.Mock()
|
||||
@@ -145,8 +137,8 @@ class ServicesSignalsTestCase(TestCase):
|
||||
args, kwargs = svc.validate_user.call_args
|
||||
self.assertEqual(self.member, args[0])
|
||||
|
||||
@mock.patch('allianceauth.services.signals.transaction')
|
||||
@mock.patch('allianceauth.services.signals.ServicesHook')
|
||||
@mock.patch(MODULE_PATH + '.transaction')
|
||||
@mock.patch(MODULE_PATH + '.ServicesHook')
|
||||
def test_m2m_changed_user_state_permissions(self, services_hook, transaction):
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
svc = mock.Mock()
|
||||
@@ -180,7 +172,7 @@ class ServicesSignalsTestCase(TestCase):
|
||||
args, kwargs = svc.validate_user.call_args
|
||||
self.assertEqual(self.member, args[0])
|
||||
|
||||
@mock.patch('allianceauth.services.signals.ServicesHook')
|
||||
@mock.patch(MODULE_PATH + '.ServicesHook')
|
||||
def test_state_changed_services_validation_and_groups_update(self, services_hook):
|
||||
"""Test a user changing state has service accounts validated and groups updated
|
||||
"""
|
||||
@@ -206,8 +198,7 @@ class ServicesSignalsTestCase(TestCase):
|
||||
args, kwargs = svc.update_groups.call_args
|
||||
self.assertEqual(self.member, args[0])
|
||||
|
||||
|
||||
@mock.patch('allianceauth.services.signals.ServicesHook')
|
||||
@mock.patch(MODULE_PATH + '.ServicesHook')
|
||||
def test_state_changed_services_validation_and_groups_update_1(self, services_hook):
|
||||
"""Test a user changing main has service accounts validated and sync updated
|
||||
"""
|
||||
@@ -238,7 +229,7 @@ class ServicesSignalsTestCase(TestCase):
|
||||
args, kwargs = svc.sync_nickname.call_args
|
||||
self.assertEqual(self.member, args[0])
|
||||
|
||||
@mock.patch('allianceauth.services.signals.ServicesHook')
|
||||
@mock.patch(MODULE_PATH + '.ServicesHook')
|
||||
def test_state_changed_services_validation_and_groups_update_2(self, services_hook):
|
||||
"""Test a user changing main has service does not have accounts validated
|
||||
and sync updated if the new main is equal to the old main
|
||||
@@ -260,3 +251,71 @@ class ServicesSignalsTestCase(TestCase):
|
||||
self.assertFalse(services_hook.get_services.called)
|
||||
self.assertFalse(svc.validate_user.called)
|
||||
self.assertFalse(svc.sync_nickname.called)
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"allianceauth.services.modules.mumble.auth_hooks.MumbleService.update_groups"
|
||||
)
|
||||
@override_settings(CELERY_ALWAYS_EAGER=True, CELERY_EAGER_PROPAGATES_EXCEPTIONS=True)
|
||||
class TestUserGroupBulkUpdate(TransactionTestCase):
|
||||
def test_should_run_user_service_check_when_group_added_to_user(
|
||||
self, mock_update_groups
|
||||
):
|
||||
# given
|
||||
user = AuthUtils.create_user("Bruce Wayne")
|
||||
AuthUtils.add_main_character_2(user, "Bruce Wayne", 1001)
|
||||
group = Group.objects.create(name="Group")
|
||||
mock_update_groups.reset_mock()
|
||||
# when
|
||||
user.groups.add(group)
|
||||
# then
|
||||
users_updated = {obj[0][0] for obj in mock_update_groups.call_args_list}
|
||||
self.assertSetEqual(users_updated, {user})
|
||||
|
||||
def test_should_run_user_service_check_when_multiple_groups_are_added_to_user(
|
||||
self, mock_update_groups
|
||||
):
|
||||
# given
|
||||
user = AuthUtils.create_user("Bruce Wayne")
|
||||
AuthUtils.add_main_character_2(user, "Bruce Wayne", 1001)
|
||||
group_1 = Group.objects.create(name="Group 1")
|
||||
group_2 = Group.objects.create(name="Group 2")
|
||||
mock_update_groups.reset_mock()
|
||||
# when
|
||||
user.groups.add(group_1, group_2)
|
||||
# then
|
||||
users_updated = {obj[0][0] for obj in mock_update_groups.call_args_list}
|
||||
self.assertSetEqual(users_updated, {user})
|
||||
|
||||
def test_should_run_user_service_check_when_user_added_to_group(
|
||||
self, mock_update_groups
|
||||
):
|
||||
# given
|
||||
user = AuthUtils.create_user("Bruce Wayne")
|
||||
AuthUtils.add_main_character_2(user, "Bruce Wayne", 1001)
|
||||
group = Group.objects.create(name="Group")
|
||||
mock_update_groups.reset_mock()
|
||||
# when
|
||||
group.user_set.add(user)
|
||||
# then
|
||||
users_updated = {obj[0][0] for obj in mock_update_groups.call_args_list}
|
||||
self.assertSetEqual(users_updated, {user})
|
||||
|
||||
def test_should_run_user_service_check_when_multiple_users_are_added_to_group(
|
||||
self, mock_update_groups
|
||||
):
|
||||
# given
|
||||
user_1 = AuthUtils.create_user("Bruce Wayne")
|
||||
AuthUtils.add_main_character_2(user_1, "Bruce Wayne", 1001)
|
||||
user_2 = AuthUtils.create_user("Peter Parker")
|
||||
AuthUtils.add_main_character_2(user_2, "Peter Parker", 1002)
|
||||
user_3 = AuthUtils.create_user("Lex Luthor")
|
||||
AuthUtils.add_main_character_2(user_3, "Lex Luthor", 1011)
|
||||
group = Group.objects.create(name="Group")
|
||||
user_1.groups.add(group)
|
||||
mock_update_groups.reset_mock()
|
||||
# when
|
||||
group.user_set.add(user_2, user_3)
|
||||
# then
|
||||
users_updated = {obj[0][0] for obj in mock_update_groups.call_args_list}
|
||||
self.assertSetEqual(users_updated, {user_2, user_3})
|
||||
|
||||
@@ -3,32 +3,50 @@ from unittest import mock
|
||||
from celery_once import AlreadyQueued
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
from django.test import override_settings, TestCase
|
||||
|
||||
from allianceauth.tests.auth_utils import AuthUtils
|
||||
from allianceauth.services.tasks import validate_services
|
||||
from allianceauth.services.tasks import validate_services, update_groups_for_user
|
||||
|
||||
from ..tasks import DjangoBackend
|
||||
|
||||
|
||||
@override_settings(CELERY_ALWAYS_EAGER=True, CELERY_EAGER_PROPAGATES_EXCEPTIONS=True)
|
||||
class ServicesTasksTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.member = AuthUtils.create_user('auth_member')
|
||||
|
||||
@mock.patch('allianceauth.services.tasks.ServicesHook')
|
||||
def test_validate_services(self, services_hook):
|
||||
# given
|
||||
svc = mock.Mock()
|
||||
svc.validate_user.return_value = None
|
||||
|
||||
services_hook.get_services.return_value = [svc]
|
||||
|
||||
# when
|
||||
validate_services.delay(self.member.pk)
|
||||
|
||||
# then
|
||||
self.assertTrue(services_hook.get_services.called)
|
||||
self.assertTrue(svc.validate_user.called)
|
||||
args, kwargs = svc.validate_user.call_args
|
||||
args, _ = svc.validate_user.call_args
|
||||
self.assertEqual(self.member, args[0]) # Assert correct user is passed to service hook function
|
||||
|
||||
@mock.patch('allianceauth.services.tasks.ServicesHook')
|
||||
def test_update_groups_for_user(self, services_hook):
|
||||
# given
|
||||
svc = mock.Mock()
|
||||
svc.validate_user.return_value = None
|
||||
services_hook.get_services.return_value = [svc]
|
||||
# when
|
||||
update_groups_for_user.delay(self.member.pk)
|
||||
# then
|
||||
self.assertTrue(services_hook.get_services.called)
|
||||
self.assertTrue(svc.validate_user.called)
|
||||
args, _ = svc.validate_user.call_args
|
||||
self.assertEqual(self.member, args[0]) # Assert correct user
|
||||
self.assertTrue(svc.update_groups.called)
|
||||
args, _ = svc.update_groups.call_args
|
||||
self.assertEqual(self.member, args[0]) # Assert correct user
|
||||
|
||||
|
||||
class TestDjangoBackend(TestCase):
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{% load humanize %}
|
||||
{% load admin_status %}
|
||||
|
||||
<div
|
||||
class="progress-bar progress-bar-{{ level }} task-status-progress-bar"
|
||||
role="progressbar"
|
||||
aria-valuenow="{% widthratio tasks_count tasks_total 100 %}"
|
||||
aria-valuenow="{% decimal_widthratio tasks_count tasks_total 100 %}"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
style="width: {% widthratio tasks_count tasks_total 100 %}%;"
|
||||
title="{{ tasks_count|intcomma }} {{ label }}">
|
||||
{% widthratio tasks_count tasks_total 100 %}%
|
||||
style="width: {% decimal_widthratio tasks_count tasks_total 100 %}%;">
|
||||
<p style="margin-top:5px;">{% widthratio tasks_count tasks_total 100 %}%</p>
|
||||
</div>
|
||||
|
||||
@@ -78,10 +78,15 @@
|
||||
<div class="panel-heading text-center"><h3 class="panel-title">{% translate "Task Queue" %}</h3></div>
|
||||
<div class="panel-body flex-center-horizontal">
|
||||
<p>
|
||||
{% blocktranslate with total=tasks_total|intcomma latest=earliest_task|timesince|default_if_none:"?" %}
|
||||
Status of {{ total }} processed tasks • last {{ latest }}</p>
|
||||
{% blocktranslate with total=tasks_total|intcomma latest=earliest_task|timesince|default:"?" %}
|
||||
Status of {{ total }} processed tasks • last {{ latest }}
|
||||
{% endblocktranslate %}
|
||||
<div class="progress" style="height: 21px;">
|
||||
</p>
|
||||
<div
|
||||
class="progress"
|
||||
style="height: 21px;"
|
||||
title="{{ tasks_succeeded|intcomma }} succeeded, {{ tasks_retried|intcomma }} retried, {{ tasks_failed|intcomma }} failed"
|
||||
>
|
||||
{% include "allianceauth/admin-status/celery_bar_partial.html" with label="suceeded" level="success" tasks_count=tasks_succeeded %}
|
||||
{% include "allianceauth/admin-status/celery_bar_partial.html" with label="retried" level="info" tasks_count=tasks_retried %}
|
||||
{% include "allianceauth/admin-status/celery_bar_partial.html" with label="failed" level="danger" tasks_count=tasks_failed %}
|
||||
|
||||
3
allianceauth/templates/bundles/evetime-js.html
Normal file
3
allianceauth/templates/bundles/evetime-js.html
Normal file
@@ -0,0 +1,3 @@
|
||||
{% load static %}
|
||||
|
||||
<script src="{% static 'js/eve-time.js' %}"></script>
|
||||
3
allianceauth/templates/bundles/filterdropdown-js.html
Normal file
3
allianceauth/templates/bundles/filterdropdown-js.html
Normal file
@@ -0,0 +1,3 @@
|
||||
{% load static %}
|
||||
|
||||
<script type="application/javascript" src="{% static 'js/filterDropDown/filterDropDown.min.js' %}">
|
||||
@@ -0,0 +1,3 @@
|
||||
{% load static %}
|
||||
|
||||
<script src="{% static 'js/refresh_notifications.js' %}"></script>
|
||||
3
allianceauth/templates/bundles/timers-js.html
Normal file
3
allianceauth/templates/bundles/timers-js.html
Normal file
@@ -0,0 +1,3 @@
|
||||
{% load static %}
|
||||
|
||||
<script src="{% static 'js/timers.js' %}"></script>
|
||||
@@ -13,7 +13,7 @@ from django.core.cache import cache
|
||||
|
||||
from allianceauth import __version__
|
||||
|
||||
from ..authentication.task_statistics.event_series import dashboard_results
|
||||
from ..authentication.task_statistics.counters import dashboard_results
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@@ -36,6 +36,14 @@ GITLAB_AUTH_ANNOUNCEMENT_ISSUES_URL = (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def decimal_widthratio(this_value, max_value, max_width) -> str:
|
||||
if max_value == 0:
|
||||
return str(0)
|
||||
|
||||
return str(round(this_value/max_value * max_width, 2))
|
||||
|
||||
|
||||
@register.inclusion_tag('allianceauth/admin-status/overview.html')
|
||||
def status_overview() -> dict:
|
||||
response = {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from typing import List
|
||||
|
||||
from django.contrib.auth.models import User, Group, Permission
|
||||
from django.db.models.signals import m2m_changed, pre_save, post_save
|
||||
from django.test import TestCase
|
||||
@@ -258,6 +260,23 @@ class AuthUtils:
|
||||
p = cls.get_permission_by_name(perm)
|
||||
return cls.add_permissions_to_user([p], user, disconnect_signals)
|
||||
|
||||
@classmethod
|
||||
def add_permissions_to_user_by_name(
|
||||
cls, perms: List[str], user: User, disconnect_signals: bool = True
|
||||
) -> User:
|
||||
"""Add permissions given by name to a user
|
||||
|
||||
Args:
|
||||
perms: List of permission names as 'app_label.codename'
|
||||
user: user object
|
||||
disconnect_signals: whether to run process without signals
|
||||
|
||||
Returns:
|
||||
Updated user object
|
||||
"""
|
||||
permissions = [cls.get_permission_by_name(perm) for perm in perms]
|
||||
return cls.add_permissions_to_user(permissions, user, disconnect_signals)
|
||||
|
||||
@staticmethod
|
||||
def get_permission_by_name(perm: str) -> Permission:
|
||||
"""returns permission specified by qualified name
|
||||
|
||||
0
allianceauth/utils/__init__.py
Normal file
0
allianceauth/utils/__init__.py
Normal file
21
allianceauth/utils/cache.py
Normal file
21
allianceauth/utils/cache.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from redis import Redis
|
||||
|
||||
from django.core.cache import caches
|
||||
|
||||
try:
|
||||
import django_redis
|
||||
except ImportError:
|
||||
django_redis = None
|
||||
|
||||
|
||||
def get_redis_client() -> Redis:
|
||||
"""Get the configured redis client used by Django for caching.
|
||||
|
||||
This function is a wrapper designed to work for both AA2 and AA3
|
||||
and should always be used to ensure backwards compatibility.
|
||||
"""
|
||||
try:
|
||||
return django_redis.get_redis_connection("default")
|
||||
except AttributeError:
|
||||
default_cache = caches["default"]
|
||||
return default_cache.get_master_client()
|
||||
0
allianceauth/utils/tests/__init__.py
Normal file
0
allianceauth/utils/tests/__init__.py
Normal file
39
allianceauth/utils/tests/test_cache.py
Normal file
39
allianceauth/utils/tests/test_cache.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from allianceauth.utils.cache import get_redis_client
|
||||
|
||||
MODULE_PATH = "allianceauth.utils.cache"
|
||||
|
||||
|
||||
class RedisClientStub:
|
||||
"""Substitute for a Redis client."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TestGetRedisClient(TestCase):
|
||||
def test_should_work_with_aa2_api(self):
|
||||
# given
|
||||
mock_django_cache = MagicMock()
|
||||
mock_django_cache.get_master_client.return_value = RedisClientStub()
|
||||
# when
|
||||
with patch(MODULE_PATH + ".django_redis", None), patch.dict(
|
||||
MODULE_PATH + ".caches", {"default": mock_django_cache}
|
||||
):
|
||||
client = get_redis_client()
|
||||
# then
|
||||
self.assertIsInstance(client, RedisClientStub)
|
||||
|
||||
def test_should_work_with_aa3_api(self):
|
||||
# given
|
||||
mock_django_redis = MagicMock()
|
||||
mock_django_redis.get_redis_connection.return_value = RedisClientStub()
|
||||
# when
|
||||
with patch(MODULE_PATH + ".django_redis", mock_django_redis), patch.dict(
|
||||
MODULE_PATH + ".caches", {"default": None}
|
||||
):
|
||||
client = get_redis_client()
|
||||
# then
|
||||
self.assertIsInstance(client, RedisClientStub)
|
||||
@@ -1,7 +1,7 @@
|
||||
PROTOCOL=https://
|
||||
AUTH_SUBDOMAIN=%AUTH_SUBDOMAIN%
|
||||
DOMAIN=%DOMAIN%
|
||||
AA_DOCKER_TAG=registry.gitlab.com/allianceauth/allianceauth/auth:v2.10
|
||||
AA_DOCKER_TAG=registry.gitlab.com/allianceauth/allianceauth/auth:v2.14.0
|
||||
|
||||
# Nginx Proxy Manager
|
||||
PROXY_HTTP_PORT=80
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
FROM python:3.9-slim
|
||||
ARG AUTH_VERSION=2.10.0
|
||||
ARG AUTH_VERSION=2.14.0
|
||||
ARG AUTH_PACKAGE=allianceauth==${AUTH_VERSION}
|
||||
ENV VIRTUAL_ENV=/opt/venv
|
||||
ENV AUTH_USER=allianceauth
|
||||
@@ -39,7 +39,6 @@ RUN allianceauth start myauth
|
||||
COPY /allianceauth/project_template/project_name/settings/local.py ${AUTH_HOME}/myauth/myauth/settings/local.py
|
||||
RUN allianceauth update myauth
|
||||
RUN mkdir -p ${STATIC_BASE}/myauth/static
|
||||
RUN python ${AUTH_HOME}/myauth/manage.py collectstatic --noinput
|
||||
COPY /docker/conf/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||
RUN echo 'alias auth="python $AUTH_HOME/myauth/manage.py"' >> ~/.bashrc && \
|
||||
echo 'alias supervisord="supervisord -c /etc/supervisor/conf.d/supervisord.conf"' >> ~/.bashrc && \
|
||||
|
||||
@@ -8,7 +8,7 @@ You should have the following available on the system you are using to set this
|
||||
|
||||
## Setup Guide
|
||||
|
||||
1. run `bash <(curl -s https://gitlab.com/allianceauth/allianceauth/-/raw/v2.9.x/docker/scripts/download.sh)`. This will download all the files you need to install auth and place them in a directory named `aa-docker`. Feel free to rename/move this folder.
|
||||
1. run `bash <(curl -s https://gitlab.com/allianceauth/allianceauth/-/raw/v2.14.x/docker/scripts/download.sh)`. This will download all the files you need to install auth and place them in a directory named `aa-docker`. Feel free to rename/move this folder.
|
||||
1. run `./scripts/prepare-env.sh` to set up your environment
|
||||
1. (optional) Change `PROTOCOL` to `http://` if not using SSL in `.env`
|
||||
1. run `docker-compose --env-file=.env up -d` (NOTE: if this command hangs, follow the instructions [here](https://www.digitalocean.com/community/tutorials/how-to-setup-additional-entropy-for-cloud-servers-using-haveged))
|
||||
|
||||
114
docs/_static/css/rtd_dark.css
vendored
Normal file
114
docs/_static/css/rtd_dark.css
vendored
Normal file
@@ -0,0 +1,114 @@
|
||||
/*!
|
||||
* @name Readthedocs
|
||||
* @namespace http://userstyles.org
|
||||
* @description Styles the documentation pages hosted on Readthedocs.io
|
||||
* @author Anthony Post
|
||||
* @homepage https://userstyles.org/styles/142968
|
||||
* @version 0.20170529055029
|
||||
*
|
||||
* Modified by Aloïs Dreyfus: 20200527-1037
|
||||
* Modified by Erik Kalkoken: 20220615
|
||||
*/
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
a:visited {
|
||||
color: #bf84d8;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: #2d2d2d !important;
|
||||
}
|
||||
|
||||
.wy-nav-content {
|
||||
background: #3c3c3c;
|
||||
color: aliceblue;
|
||||
}
|
||||
|
||||
.method dt, .class dt, .data dt, .attribute dt, .function dt,
|
||||
.descclassname, .descname {
|
||||
background-color: #525252 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.toc-backref {
|
||||
color: grey !important;
|
||||
}
|
||||
|
||||
code.literal {
|
||||
background-color: #2d2d2d !important;
|
||||
border: 1px solid #6d6d6d !important;
|
||||
}
|
||||
|
||||
.wy-nav-content-wrap {
|
||||
background-color: rgba(0, 0, 0, 0.6) !important;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-color: #191919 !important;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
background-color: #2b2b2b !important;
|
||||
}
|
||||
|
||||
.xref, .py-meth {
|
||||
color: #7ec3e6 !important;
|
||||
}
|
||||
|
||||
.admonition, .note {
|
||||
background-color: #2d2d2d !important;
|
||||
}
|
||||
|
||||
.wy-side-nav-search {
|
||||
background-color: inherit;
|
||||
border-bottom: 1px solid #fcfcfc;
|
||||
}
|
||||
|
||||
.wy-table thead, .rst-content table.docutils thead, .rst-content table.field-list thead {
|
||||
background-color: #b9b9b9;
|
||||
}
|
||||
|
||||
.wy-table thead th, .rst-content table.docutils thead th, .rst-content table.field-list thead th {
|
||||
border: solid 2px #e1e4e5;
|
||||
}
|
||||
|
||||
.wy-table thead p, .rst-content table.docutils thead p, .rst-content table.field-list thead p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wy-table-odd td, .wy-table-striped tr:nth-child(2n-1) td, .rst-content table.docutils:not(.field-list) tr:nth-child(2n-1) td {
|
||||
background-color: #343131;
|
||||
}
|
||||
|
||||
.highlight .m {
|
||||
color: inherit
|
||||
}
|
||||
|
||||
/* Literal.Number */
|
||||
.highlight .nv {
|
||||
color: #3a7ca8
|
||||
}
|
||||
|
||||
/* Name.Variable */
|
||||
|
||||
body {
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.rst-content .section .admonition ul {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
li.toctree-l1 {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.wy-menu-vertical li code {
|
||||
color: #E74C3C;
|
||||
}
|
||||
|
||||
.wy-menu-vertical .xref {
|
||||
color: #2980B9 !important;
|
||||
}
|
||||
}
|
||||
BIN
docs/_static/images/features/core/admin_site.png
vendored
Normal file
BIN
docs/_static/images/features/core/admin_site.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
@@ -44,6 +44,8 @@ extensions = [
|
||||
'sphinx.ext.autodoc',
|
||||
'sphinx.ext.napoleon',
|
||||
'recommonmark',
|
||||
'sphinxcontrib_django2',
|
||||
'sphinx.ext.viewcode',
|
||||
]
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
@@ -60,7 +62,7 @@ master_doc = 'index'
|
||||
|
||||
# General information about the project.
|
||||
project = 'Alliance Auth'
|
||||
copyright = '2018-2020, Alliance Auth'
|
||||
copyright = '2018-2022, Alliance Auth'
|
||||
author = 'R4stl1n'
|
||||
|
||||
# The version info for the project you're documenting, acts as replacement for
|
||||
@@ -111,6 +113,7 @@ html_theme_options = {
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
html_static_path = ['_static']
|
||||
html_css_files = ["css/rtd_dark.css"]
|
||||
|
||||
|
||||
# -- Options for HTMLHelp output ------------------------------------------
|
||||
|
||||
@@ -150,12 +150,14 @@ sudo redis-server --daemonize yes
|
||||
|
||||
```eval_rst
|
||||
.. note::
|
||||
WSL does not have an init.d service, so it will not automatically start your services such as MySQL and Redis when you boot your Windows machine. For convenience we recommend putting the commands for starting these services in a bash script. Here is an example: ::
|
||||
WSL does not have an init.d service, so it will not automatically start your services such as MySQL and Redis when you boot your Windows machine. For convenience we recommend putting the commands for starting these services in a bash script. Here is an example:
|
||||
|
||||
#/bin/bash
|
||||
# start services for AA dev
|
||||
sudo service mysql start
|
||||
sudo redis-server --daemonize yes
|
||||
::
|
||||
|
||||
#/bin/bash
|
||||
# start services for AA dev
|
||||
sudo service mysql start
|
||||
sudo redis-server --daemonize yes
|
||||
|
||||
In addition it is possible to configure Windows to automatically start WSL services, but that procedure goes beyond the scopes of this guide.
|
||||
```
|
||||
|
||||
@@ -4,36 +4,4 @@ django-esi
|
||||
|
||||
The django-esi package provides an interface for easy access to the ESI.
|
||||
|
||||
Location: ``esi``
|
||||
|
||||
This is an external package. Please also see `here <https://gitlab.com/allianceauth/django-esi>`_ for it's official documentation.
|
||||
|
||||
clients
|
||||
===========
|
||||
|
||||
.. automodule:: esi.clients
|
||||
:members: esi_client_factory
|
||||
:undoc-members:
|
||||
|
||||
decorators
|
||||
===========
|
||||
|
||||
.. automodule:: esi.decorators
|
||||
:members:
|
||||
:undoc-members:
|
||||
|
||||
errors
|
||||
===========
|
||||
|
||||
.. automodule:: esi.errors
|
||||
:members:
|
||||
:undoc-members:
|
||||
|
||||
|
||||
models
|
||||
===========
|
||||
|
||||
.. automodule:: esi.models
|
||||
:members: Scope, Token
|
||||
:exclude-members: objects, provider
|
||||
:undoc-members:
|
||||
This is an external package. Please see `here <https://django-esi.readthedocs.io/en/latest/>`_ for it's documentation.
|
||||
|
||||
@@ -11,5 +11,4 @@ models
|
||||
|
||||
.. automodule:: allianceauth.eveonline.models
|
||||
:members:
|
||||
:exclude-members: objects, provider
|
||||
:undoc-members:
|
||||
:exclude-members: DoesNotExist, MultipleObjectsReturned
|
||||
|
||||
@@ -11,4 +11,5 @@ To reduce redundancy and help speed up development we encourage developers to ut
|
||||
eveonline
|
||||
notifications
|
||||
testutils
|
||||
utils
|
||||
```
|
||||
|
||||
@@ -13,7 +13,8 @@ models
|
||||
===========
|
||||
|
||||
.. autoclass:: allianceauth.notifications.models.Notification
|
||||
:members: Level, mark_viewed, set_level
|
||||
:members:
|
||||
:exclude-members: DoesNotExist, MultipleObjectsReturned, save
|
||||
|
||||
managers
|
||||
===========
|
||||
|
||||
14
docs/development/tech_docu/api/utils.rst
Normal file
14
docs/development/tech_docu/api/utils.rst
Normal file
@@ -0,0 +1,14 @@
|
||||
=============================
|
||||
utils
|
||||
=============================
|
||||
|
||||
Utilities and helper functions.
|
||||
|
||||
Location: ``allianceauth.utils``
|
||||
|
||||
cache
|
||||
===========
|
||||
|
||||
.. automodule:: allianceauth.utils.cache
|
||||
:members:
|
||||
:undoc-members:
|
||||
@@ -1,15 +1,27 @@
|
||||
=============
|
||||
Template Tags
|
||||
=============
|
||||
=======================
|
||||
Template tags & filters
|
||||
=======================
|
||||
|
||||
The following template tags are available to be used by all apps. To use them just load the respeetive template tag in your template like so:
|
||||
The following template tags and filters are available to be used by all apps. To use them just load them into your template like so:
|
||||
|
||||
.. code-block:: html
|
||||
.. code-block:: html+django
|
||||
|
||||
{% load evelinks %}
|
||||
|
||||
|
||||
Template Filters
|
||||
================
|
||||
|
||||
evelinks
|
||||
========
|
||||
--------
|
||||
|
||||
Example for using an evelinks filter to render an alliance logo:
|
||||
|
||||
|
||||
.. code-block:: html+django
|
||||
|
||||
<img src="{{ alliance_id|alliance_logo_url }}">
|
||||
|
||||
|
||||
.. automodule:: allianceauth.eveonline.templatetags.evelinks
|
||||
:members:
|
||||
|
||||
96
docs/features/core/admin_site.md
Normal file
96
docs/features/core/admin_site.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Admin Site
|
||||
|
||||
The admin site allows administrators to configure, manage and trouble shoot Alliance Auth and all it's applications and services. E.g. you can create new groups and assign groups to users.
|
||||
|
||||
You can open the admin site by clicking on "Admin" in the drop down menu for a user that has access.
|
||||
|
||||

|
||||
|
||||
## Setup for small to medium size installations
|
||||
|
||||
For small to medium size alliances it is often sufficient to have no more then two superuser admins (admins that also are superusers). Having two admins usually makes sense, so you can have one primary and one backup.
|
||||
|
||||
```eval_rst
|
||||
.. warning::
|
||||
Superusers have read & write access to everything on your AA installation. Superusers also automatically have all permissions and therefore access to all features of your apps. Therefore we recommend to be very careful to whom you give superuser privileges.
|
||||
```
|
||||
|
||||
## Setup for large installations
|
||||
|
||||
For large alliances and coalitions you may want to have a couple of administrators to be able to distribute and handle the work load. However, having a larger number of superusers may be a security concern.
|
||||
|
||||
As an alternative to superusers admins you can define staff admins. Staff admins can perform most of the daily admin work, but are not superusers and therefore can be restricted in what they can access.
|
||||
|
||||
To create a staff admin you need to do two things:
|
||||
|
||||
1. Enable the `is_staff` property for a user
|
||||
1. Give the user permissions for admin tasks
|
||||
|
||||
```eval_rst
|
||||
.. note::
|
||||
Note that staff admins have the following limitations:
|
||||
|
||||
- Can not promote users to staff
|
||||
- Can not promote users to superuser
|
||||
- Can not add/remove permissions for users, groups and states
|
||||
|
||||
These limitations exist to prevent staff admins to promote themselves to quasi superusers. Only superusers can perform these actions.
|
||||
```
|
||||
|
||||
### Staff property
|
||||
|
||||
Access to the admin site is restricted. Users needs to have the `is_staff` property to be able to open the site at all. The superuser that is created during the installation
|
||||
process will automatically have access to the admin site.
|
||||
|
||||
```eval_rst
|
||||
.. hint::
|
||||
Without any permissions a "staff user" can open the admin site, but can neither view nor edit anything except for viewing the list of permissions.
|
||||
```
|
||||
|
||||
### Permissions for common admin tasks
|
||||
|
||||
Here is a list of permissions a staff admin would need to perform some common admin tasks:
|
||||
|
||||
#### Edit users
|
||||
|
||||
- auth | user | Can view user
|
||||
- auth | user | Can change user
|
||||
- authentication | user | Can view user
|
||||
- authentication | user | Can change user
|
||||
- authentication | user profile | Can change profile
|
||||
|
||||
#### Delete users
|
||||
|
||||
- auth | user | Can view user
|
||||
- auth | user | Can delete user
|
||||
- authentication | user | Can delete user
|
||||
- authentication | user profile | Can delete user profile
|
||||
|
||||
#### Add & edit states
|
||||
|
||||
- authentication | state | Can add state
|
||||
- authentication | state | Can change state
|
||||
- authentication | state | Can view state
|
||||
|
||||
#### Delete states
|
||||
|
||||
- authentication | state | Can delete state
|
||||
- authentication | state | Can view state
|
||||
|
||||
#### Add & edit groups
|
||||
|
||||
- auth | group | Can add group
|
||||
- auth | group | Can change group
|
||||
- auth | group | Can view group
|
||||
- authentication | group | Can add group
|
||||
- authentication | group | Can change group
|
||||
- authentication | group | Can view group
|
||||
|
||||
#### Delete groups
|
||||
|
||||
- auth | group | Can delete group
|
||||
- authentication | group | Can delete group
|
||||
|
||||
### Permissions for other apps
|
||||
|
||||
The permissions a staff admin needs to perform tasks for other applications depends on how the applications are configured. the default is to have four permissions (change, delete, edit view) for each model of the applications. The view permission is usually required to see the model list on the admin site and the other three permissions are required to perform the respective action to an object of that model. However, app developer can chose to define permissions differently.
|
||||
@@ -38,6 +38,10 @@ The key difference is that the group is completely unmanaged by Auth. **Once a m
|
||||
|
||||
Most people won't have a use for public groups, though it can be useful if you wish to allow public access to some services. You can grant service permissions on a public group to allow this behavior.
|
||||
|
||||
### Restricted
|
||||
|
||||
When a group is restricted only superuser admins can directly add or remove them to/from users. The purpose of this property is prevent staff admins from assigning themselves to groups that are security sensitive. The "restricted" property can be combined with all the other properties.
|
||||
|
||||
```eval_rst
|
||||
.. _ref-reserved-group-names:
|
||||
```
|
||||
|
||||
@@ -11,4 +11,5 @@ Managing access to applications and services is one of the core functions of **A
|
||||
groups
|
||||
analytics
|
||||
notifications
|
||||
admin_site
|
||||
```
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
sphinx>=3.2.1,<4.0.0
|
||||
sphinx_rtd_theme==0.5.0
|
||||
recommonmark==0.6.0
|
||||
Jinja2<3.1
|
||||
sphinxcontrib-django2
|
||||
|
||||
# Autodoc dependencies
|
||||
django>=3.2,<4.0.0
|
||||
django-celery-beat>=2.0.0
|
||||
django-redis-cache
|
||||
django-bootstrap-form
|
||||
django-sortedm2m
|
||||
django-esi>=3,<4
|
||||
django-esi>=3,<5
|
||||
celery>5,<6
|
||||
celery_once
|
||||
passlib
|
||||
|
||||
2
setup.py
2
setup.py
@@ -33,7 +33,7 @@ install_requires = [
|
||||
'sleekxmpp<=1.3.2',
|
||||
'pydiscourse',
|
||||
|
||||
'django-esi>=3.0.0,<4.0.0'
|
||||
'django-esi>=4.0.1,<5.0.0'
|
||||
]
|
||||
|
||||
testing_extras = [
|
||||
|
||||
10
tox.ini
10
tox.ini
@@ -1,7 +1,7 @@
|
||||
[tox]
|
||||
skipsdist = true
|
||||
usedevelop = true
|
||||
envlist = py{37,38,39,310,311}-{all,core}
|
||||
envlist = py{37,38,39,310,311}-{all,core}, docs
|
||||
|
||||
[testenv]
|
||||
setenv =
|
||||
@@ -21,3 +21,11 @@ commands =
|
||||
core: coverage run runtests.py allianceauth.authentication.tests.test_app_settings -v 2 --debug-mode
|
||||
all: coverage report -m
|
||||
all: coverage xml
|
||||
|
||||
[testenv:docs]
|
||||
description = invoke sphinx-build to build the HTML docs
|
||||
basepython = python3.9
|
||||
deps = -r{toxinidir}/docs/requirements.txt
|
||||
install_command =
|
||||
commands =
|
||||
sphinx-build -T -E -b html -d "{toxworkdir}/docs_doctree" -D language=en docs "{toxworkdir}/docs_out" {posargs}
|
||||
|
||||
Reference in New Issue
Block a user