Compare commits

...

29 Commits

Author SHA1 Message Date
Ariel Rin
113977b19f Version Bump 2.13.0 2022-06-18 13:07:36 +10:00
Ariel Rin
8f39b50b6d Merge branch 'Maestro-Zacht-fix-fat-attributeerror' into 'master'
fixed attribute error

See merge request allianceauth/allianceauth!1421
2022-06-18 02:53:11 +00:00
Maestro-Zacht
95b309c358 fixed attribute error 2022-06-18 02:53:11 +00:00
Ariel Rin
cf3df3b715 Merge branch 'fix_issue_1328' into 'master'
Fix: Changing group's state setting does not kick existing non-conforming group members

Closes #1328

See merge request allianceauth/allianceauth!1400
2022-06-18 02:47:14 +00:00
Erik Kalkoken
d815028c4d Fix: Changing group's state setting does not kick existing non-conforming group members 2022-06-18 02:47:14 +00:00
Ariel Rin
ac5570abe2 Merge branch 'fix_issue_1268' into 'master'
Fix: Service group updates broken when adding users to groups

Closes #1268

See merge request allianceauth/allianceauth!1403
2022-06-18 02:41:23 +00:00
Erik Kalkoken
84ad571aa4 Fix: Service group updates broken when adding users to groups 2022-06-18 02:41:23 +00:00
Ariel Rin
38e7705ae7 Merge branch 'docs-dark-mode' into 'master'
Add automatic dark mode to docs

See merge request allianceauth/allianceauth!1427
2022-06-18 02:39:59 +00:00
ErikKalkoken
0b6af014fa Add automatic dark mode to docs 2022-06-17 21:49:18 +02:00
Ariel Rin
2401f2299d Merge branch 'fix-doc-redis-issue' into 'master'
Fix: Broken docs generation on readthedocs.org (2nd attempt)

See merge request allianceauth/allianceauth!1425
2022-06-17 11:58:45 +00:00
Erik Kalkoken
919768c8bb Fix: Broken docs generation on readthedocs.org (2nd attempt) 2022-06-17 11:58:45 +00:00
Ariel Rin
24db21463b Merge branch 'docs-template-tags-example' into 'master'
Add example for template tags to docs

See merge request allianceauth/allianceauth!1426
2022-06-17 11:58:05 +00:00
Erik Kalkoken
1e029af83a Add example for template tags to docs 2022-06-17 11:58:05 +00:00
Ariel Rin
2b31be789d Merge branch 'fix-issue-1336' into 'master'
Fix: Broken docs generation on readthedocs.org

Closes #1336

See merge request allianceauth/allianceauth!1423
2022-06-06 10:48:16 +00:00
Erik Kalkoken
bf1b4bb549 Fix: Broken docs generation on readthedocs.org 2022-06-06 10:48:16 +00:00
Ariel Rin
dd42b807f0 Version Bump 2.12.1 2022-05-13 00:19:45 +10:00
Ariel Rin
542fbafd98 Merge branch 'cherry-pick-4836559a' into 'v2.12.x'
Merge branch 'fix-decimal_widthratio-template-tag' into 'v2.12.x'

See merge request allianceauth/allianceauth!1420
2022-05-12 14:14:01 +00:00
Ariel Rin
37b9f5c882 Merge branch 'fix-decimal_widthratio-template-tag' into 'v3.x'
[FIX] Division by zero in decimal_widthratio template tag

See merge request allianceauth/allianceauth!1419

(cherry picked from commit 4836559abe)

8dd07b97 [FIX] Devision by zero in decimal_widthratio template tag
17b06c88 Make it a string in accordance to the return value type
2022-05-12 13:33:45 +00:00
Ariel Rin
5bde9a6952 Version Bump 2.12.0 2022-05-12 18:54:22 +10:00
Ariel Rin
23ad9d02d3 Merge branch 'cherry-pick-7fa76d6d' into 'v2.11.x'
Update GitLab CI to conform with the changes to artifacts collection, 2.11.x backport

See merge request allianceauth/allianceauth!1418
2022-05-12 04:30:07 +00:00
Ariel Rin
f99878cc29 Update .gitlab-ci.yml 2022-05-12 04:07:43 +00:00
Ariel Rin
e64431b06c Merge branch 'update-gitlab-ci' into 'v3.x'
Update GitLab CI to conform with the changes to artifacts collection

See merge request allianceauth/allianceauth!1417

(cherry picked from commit 7fa76d6d37)

a3cce358 Update GitLab CI to conform with the changes to artifacts collection
2022-05-12 04:06:04 +00:00
Ariel Rin
0b2993c1c3 Merge branch 'improve_notifications_2' into 'v2.11.x'
Improve notifications

See merge request allianceauth/allianceauth!1411
2022-05-12 04:02:17 +00:00
Erik Kalkoken
75bccf1b0f Improve notifications 2022-05-12 04:02:17 +00:00
Ariel Rin
945bc92898 Merge branch 'admin-dash-improvement' into 'v2.11.x'
Improve Admin Celery Bar

See merge request allianceauth/allianceauth!1414
2022-05-12 03:57:02 +00:00
Ariel Rin
ec7d14a839 Merge branch 'fix_issue_1222' into 'v2.11.x'
Close security loopholes to make non-superuser admins usable

See merge request allianceauth/allianceauth!1413
2022-05-12 03:56:22 +00:00
Erik Kalkoken
dd1a368ff6 Close security loopholes to make non-superuser admins usable 2022-05-12 03:56:22 +00:00
colcrunch
54085617dc Add a few pixels of margin-top to bar labels to better center them. 2022-04-16 15:46:01 -04:00
colcrunch
8cdc5af453 Improve celery bar by using decimalized width values (2 decimal places) to reduce likelyhood of an empty portion of the bar. 2022-04-16 15:44:53 -04:00
41 changed files with 1567 additions and 457 deletions

View File

@@ -54,7 +54,9 @@ test-3.7-core:
artifacts: artifacts:
when: always when: always
reports: reports:
cobertura: coverage.xml coverage_report:
coverage_format: cobertura
path: coverage.xml
test-3.8-core: test-3.8-core:
<<: *only-default <<: *only-default
@@ -64,7 +66,9 @@ test-3.8-core:
artifacts: artifacts:
when: always when: always
reports: reports:
cobertura: coverage.xml coverage_report:
coverage_format: cobertura
path: coverage.xml
test-3.9-core: test-3.9-core:
<<: *only-default <<: *only-default
@@ -74,7 +78,9 @@ test-3.9-core:
artifacts: artifacts:
when: always when: always
reports: reports:
cobertura: coverage.xml coverage_report:
coverage_format: cobertura
path: coverage.xml
test-3.10-core: test-3.10-core:
<<: *only-default <<: *only-default
@@ -84,7 +90,9 @@ test-3.10-core:
artifacts: artifacts:
when: always when: always
reports: reports:
cobertura: coverage.xml coverage_report:
coverage_format: cobertura
path: coverage.xml
test-3.11-core: test-3.11-core:
<<: *only-default <<: *only-default
@@ -94,7 +102,9 @@ test-3.11-core:
artifacts: artifacts:
when: always when: always
reports: reports:
cobertura: coverage.xml coverage_report:
coverage_format: cobertura
path: coverage.xml
allow_failure: true allow_failure: true
test-3.7-all: test-3.7-all:
@@ -105,7 +115,9 @@ test-3.7-all:
artifacts: artifacts:
when: always when: always
reports: reports:
cobertura: coverage.xml coverage_report:
coverage_format: cobertura
path: coverage.xml
test-3.8-all: test-3.8-all:
<<: *only-default <<: *only-default
@@ -115,7 +127,9 @@ test-3.8-all:
artifacts: artifacts:
when: always when: always
reports: reports:
cobertura: coverage.xml coverage_report:
coverage_format: cobertura
path: coverage.xml
test-3.9-all: test-3.9-all:
<<: *only-default <<: *only-default
@@ -125,7 +139,9 @@ test-3.9-all:
artifacts: artifacts:
when: always when: always
reports: reports:
cobertura: coverage.xml coverage_report:
coverage_format: cobertura
path: coverage.xml
test-3.10-all: test-3.10-all:
<<: *only-default <<: *only-default
@@ -135,7 +151,9 @@ test-3.10-all:
artifacts: artifacts:
when: always when: always
reports: reports:
cobertura: coverage.xml coverage_report:
coverage_format: cobertura
path: coverage.xml
test-3.11-all: test-3.11-all:
<<: *only-default <<: *only-default
@@ -145,9 +163,17 @@ test-3.11-all:
artifacts: artifacts:
when: always when: always
reports: reports:
cobertura: coverage.xml coverage_report:
coverage_format: cobertura
path: coverage.xml
allow_failure: true allow_failure: true
test-docs:
<<: *only-default
image: python:3.9-bullseye
script:
- tox -e docs
deploy_production: deploy_production:
stage: deploy stage: deploy
image: python:3.10-bullseye image: python:3.10-bullseye

View File

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

View File

@@ -1,26 +1,44 @@
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.models import User as BaseUser, \ from django.contrib.auth.models import Group
Permission as BasePermission, 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 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.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.dispatch import receiver
from django.forms import ModelForm
from django.utils.html import format_html
from django.urls import reverse from django.urls import reverse
from django.utils.html import format_html
from django.utils.text import slugify from django.utils.text import slugify
from allianceauth.authentication.models import State, get_guest_state,\ from allianceauth.authentication.models import (
CharacterOwnership, UserProfile, OwnershipRecord CharacterOwnership,
from allianceauth.hooks import get_hooks OwnershipRecord,
from allianceauth.eveonline.models import EveCharacter, EveCorporationInfo,\ State,
EveAllianceInfo, EveFactionInfo UserProfile,
get_guest_state
)
from allianceauth.eveonline.models import (
EveAllianceInfo,
EveCharacter,
EveCorporationInfo,
EveFactionInfo
)
from allianceauth.eveonline.tasks import update_character from allianceauth.eveonline.tasks import update_character
from .app_settings import AUTHENTICATION_ADMIN_USERS_MAX_GROUPS, \ from allianceauth.hooks import get_hooks
AUTHENTICATION_ADMIN_USERS_MAX_CHARS 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): def make_service_hooks_update_groups_action(service):
@@ -59,19 +77,10 @@ def make_service_hooks_sync_nickname_action(service):
return sync_nickname 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): class UserProfileInline(admin.StackedInline):
model = UserProfile model = UserProfile
readonly_fields = ('state',) readonly_fields = ('state',)
form = QuerysetModelForm form = UserProfileForm
verbose_name = '' verbose_name = ''
verbose_name_plural = 'Profile' verbose_name_plural = 'Profile'
@@ -99,6 +108,7 @@ class UserProfileInline(admin.StackedInline):
return False return False
@admin.display(description="")
def user_profile_pic(obj): def user_profile_pic(obj):
"""profile pic column data for user objects """profile pic column data for user objects
@@ -111,13 +121,10 @@ def user_profile_pic(obj):
'<img src="{}" class="img-circle">', '<img src="{}" class="img-circle">',
user_obj.profile.main_character.portrait_url(size=32) user_obj.profile.main_character.portrait_url(size=32)
) )
else: return None
return None
user_profile_pic.short_description = ''
@admin.display(description="user / main", ordering="username")
def user_username(obj): def user_username(obj):
"""user column data for user objects """user column data for user objects
@@ -139,18 +146,17 @@ def user_username(obj):
user_obj.username, user_obj.username,
user_obj.profile.main_character.character_name user_obj.profile.main_character.character_name
) )
else: return format_html(
return format_html( '<strong><a href="{}">{}</a></strong>',
'<strong><a href="{}">{}</a></strong>', link,
link, user_obj.username,
user_obj.username, )
)
user_username.short_description = 'user / main'
user_username.admin_order_field = 'username'
@admin.display(
description="Corporation / Alliance (Main)",
ordering="profile__main_character__corporation_name"
)
def user_main_organization(obj): def user_main_organization(obj):
"""main organization column data for user objects """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 user_obj = obj.user if hasattr(obj, 'user') else obj
if not user_obj.profile.main_character: if not user_obj.profile.main_character:
result = '' return ''
else: result = user_obj.profile.main_character.corporation_name
result = user_obj.profile.main_character.corporation_name if user_obj.profile.main_character.alliance_id:
if user_obj.profile.main_character.alliance_id: result += f'<br>{user_obj.profile.main_character.alliance_name}'
result += f'<br>{user_obj.profile.main_character.alliance_name}' elif user_obj.profile.main_character.faction_name:
elif user_obj.profile.main_character.faction_name: result += f'<br>{user_obj.profile.main_character.faction_name}'
result += f'<br>{user_obj.profile.main_character.faction_name}'
return format_html(result) 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): class MainCorporationsFilter(admin.SimpleListFilter):
"""Custom filter to filter on corporations from mains only """Custom filter to filter on corporations from mains only
@@ -196,15 +196,13 @@ class MainCorporationsFilter(admin.SimpleListFilter):
def queryset(self, request, qs): def queryset(self, request, qs):
if self.value() is None: if self.value() is None:
return qs.all() return qs.all()
else: if qs.model == User:
if qs.model == User: return qs.filter(
return qs.filter( profile__main_character__corporation_id=self.value()
profile__main_character__corporation_id=self.value() )
) return qs.filter(
else: user__profile__main_character__corporation_id=self.value()
return qs.filter( )
user__profile__main_character__corporation_id=self.value()
)
class MainAllianceFilter(admin.SimpleListFilter): class MainAllianceFilter(admin.SimpleListFilter):
@@ -217,12 +215,14 @@ class MainAllianceFilter(admin.SimpleListFilter):
parameter_name = 'main_alliance_id__exact' parameter_name = 'main_alliance_id__exact'
def lookups(self, request, model_admin): def lookups(self, request, model_admin):
qs = EveCharacter.objects\ qs = (
.exclude(alliance_id=None)\ EveCharacter.objects
.exclude(userprofile=None)\ .exclude(alliance_id=None)
.values('alliance_id', 'alliance_name')\ .exclude(userprofile=None)
.distinct()\ .values('alliance_id', 'alliance_name')
.distinct()
.order_by(Lower('alliance_name')) .order_by(Lower('alliance_name'))
)
return tuple( return tuple(
(x['alliance_id'], x['alliance_name']) for x in qs (x['alliance_id'], x['alliance_name']) for x in qs
) )
@@ -230,13 +230,11 @@ class MainAllianceFilter(admin.SimpleListFilter):
def queryset(self, request, qs): def queryset(self, request, qs):
if self.value() is None: if self.value() is None:
return qs.all() return qs.all()
else: if qs.model == User:
if qs.model == User: return qs.filter(profile__main_character__alliance_id=self.value())
return qs.filter(profile__main_character__alliance_id=self.value()) return qs.filter(
else: user__profile__main_character__alliance_id=self.value()
return qs.filter( )
user__profile__main_character__alliance_id=self.value()
)
class MainFactionFilter(admin.SimpleListFilter): class MainFactionFilter(admin.SimpleListFilter):
@@ -249,12 +247,14 @@ class MainFactionFilter(admin.SimpleListFilter):
parameter_name = 'main_faction_id__exact' parameter_name = 'main_faction_id__exact'
def lookups(self, request, model_admin): def lookups(self, request, model_admin):
qs = EveCharacter.objects\ qs = (
.exclude(faction_id=None)\ EveCharacter.objects
.exclude(userprofile=None)\ .exclude(faction_id=None)
.values('faction_id', 'faction_name')\ .exclude(userprofile=None)
.distinct()\ .values('faction_id', 'faction_name')
.distinct()
.order_by(Lower('faction_name')) .order_by(Lower('faction_name'))
)
return tuple( return tuple(
(x['faction_id'], x['faction_name']) for x in qs (x['faction_id'], x['faction_name']) for x in qs
) )
@@ -262,15 +262,14 @@ class MainFactionFilter(admin.SimpleListFilter):
def queryset(self, request, qs): def queryset(self, request, qs):
if self.value() is None: if self.value() is None:
return qs.all() return qs.all()
else: if qs.model == User:
if qs.model == User: return qs.filter(profile__main_character__faction_id=self.value())
return qs.filter(profile__main_character__faction_id=self.value()) return qs.filter(
else: user__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): def update_main_character_model(modeladmin, request, queryset):
tasks_count = 0 tasks_count = 0
for obj in queryset: for obj in queryset:
@@ -279,21 +278,48 @@ def update_main_character_model(modeladmin, request, queryset):
tasks_count += 1 tasks_count += 1
modeladmin.message_user( modeladmin.message_user(
request, request, f'Update from ESI started for {tasks_count} characters'
f'Update from ESI started for {tasks_count} characters'
) )
update_main_character_model.short_description = \
'Update main character model from ESI'
class UserAdmin(BaseUserAdmin): class UserAdmin(BaseUserAdmin):
"""Extending Django's UserAdmin model """Extending Django's UserAdmin model
Behavior of groups and characters columns can be configured via settings 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: class Media:
css = { css = {
"all": ("authentication/css/admin.css",) "all": ("authentication/css/admin.css",)
@@ -303,9 +329,21 @@ class UserAdmin(BaseUserAdmin):
qs = super().get_queryset(request) qs = super().get_queryset(request)
return qs.prefetch_related("character_ownerships__character", "groups") return qs.prefetch_related("character_ownerships__character", "groups")
def get_actions(self, request): def get_form(self, request, obj=None, **kwargs):
actions = super(BaseUserAdmin, self).get_actions(request) """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__] = ( actions[update_main_character_model.__name__] = (
update_main_character_model, update_main_character_model,
update_main_character_model.__name__, update_main_character_model.__name__,
@@ -349,39 +387,6 @@ class UserAdmin(BaseUserAdmin):
) )
return result 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): def _characters(self, obj):
character_ownerships = list(obj.character_ownerships.all()) character_ownerships = list(obj.character_ownerships.all())
characters = [obj.character.character_name for obj in character_ownerships] characters = [obj.character.character_name for obj in character_ownerships]
@@ -390,22 +395,16 @@ class UserAdmin(BaseUserAdmin):
AUTHENTICATION_ADMIN_USERS_MAX_CHARS AUTHENTICATION_ADMIN_USERS_MAX_CHARS
) )
_characters.short_description = 'characters' @admin.display(ordering="profile__state")
def _state(self, obj): def _state(self, obj):
return obj.profile.state.name return obj.profile.state.name
_state.short_description = 'state'
_state.admin_order_field = 'profile__state'
def _groups(self, obj): def _groups(self, obj):
my_groups = sorted(group.name for group in list(obj.groups.all())) my_groups = sorted(group.name for group in list(obj.groups.all()))
return self._list_2_html_w_tooltips( return self._list_2_html_w_tooltips(
my_groups, AUTHENTICATION_ADMIN_USERS_MAX_GROUPS my_groups, AUTHENTICATION_ADMIN_USERS_MAX_GROUPS
) )
_groups.short_description = 'groups'
def _role(self, obj): def _role(self, obj):
if obj.is_superuser: if obj.is_superuser:
role = 'Superuser' role = 'Superuser'
@@ -415,8 +414,6 @@ class UserAdmin(BaseUserAdmin):
role = 'User' role = 'User'
return role return role
_role.short_description = 'role'
def has_change_permission(self, request, obj=None): def has_change_permission(self, request, obj=None):
return request.user.has_perm('auth.change_user') return request.user.has_perm('auth.change_user')
@@ -438,9 +435,16 @@ class UserAdmin(BaseUserAdmin):
if obj_state: if obj_state:
matching_groups_qs = Group.objects.filter(authgroup__states=obj_state) matching_groups_qs = Group.objects.filter(authgroup__states=obj_state)
groups_qs = groups_qs | matching_groups_qs 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) 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) @admin.register(State)
class StateAdmin(admin.ModelAdmin): class StateAdmin(admin.ModelAdmin):
@@ -451,10 +455,9 @@ class StateAdmin(admin.ModelAdmin):
qs = super().get_queryset(request) qs = super().get_queryset(request)
return qs.annotate(user_count=Count("userprofile__id")) return qs.annotate(user_count=Count("userprofile__id"))
@admin.display(description="Users", ordering="user_count")
def _user_count(self, obj): def _user_count(self, obj):
return obj.user_count return obj.user_count
_user_count.short_description = 'Users'
_user_count.admin_order_field = 'user_count'
fieldsets = ( fieldsets = (
(None, { (None, {
@@ -510,13 +513,13 @@ class StateAdmin(admin.ModelAdmin):
) )
return super().get_fieldsets(request, obj=obj) 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 BaseOwnershipAdmin(admin.ModelAdmin):
class Media:
css = {
"all": ("authentication/css/admin.css",)
}
list_select_related = ( list_select_related = (
'user__profile__state', 'user__profile__main_character', 'character') 'user__profile__state', 'user__profile__main_character', 'character')
list_display = ( list_display = (
@@ -537,6 +540,11 @@ class BaseOwnershipAdmin(admin.ModelAdmin):
MainAllianceFilter, MainAllianceFilter,
) )
class Media:
css = {
"all": ("authentication/css/admin.css",)
}
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):
if obj and obj.pk: if obj and obj.pk:
return 'owner_hash', 'character' return 'owner_hash', 'character'

View File

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

View File

@@ -1,27 +1,62 @@
import datetime as dt import datetime as dt
from typing import Optional, List import logging
from typing import List, Optional
from redis import Redis
from pytz import utc from pytz import utc
from redis import Redis, RedisError
from django.core.cache import cache from django.core.cache import cache
logger = logging.getLogger(__name__)
class _RedisStub:
"""Stub of a Redis client.
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: class EventSeries:
"""API for recording and analysing a series of events.""" """API for recording and analyzing a series of events."""
_ROOT_KEY = "ALLIANCEAUTH_EVENT_SERIES" _ROOT_KEY = "ALLIANCEAUTH_EVENT_SERIES"
def __init__(self, key_id: str, redis: Redis = None) -> None: def __init__(self, key_id: str, redis: Redis = None) -> None:
self._redis = cache.get_master_client() if not redis else redis self._redis = cache.get_master_client() if not redis else redis
if not isinstance(self._redis, Redis): try:
raise TypeError( if not self._redis.ping():
"This class requires a Redis client, but none was provided " raise RuntimeError()
"and the default Django cache backend is not Redis either." 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._key_id = str(key_id)
self.clear() 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 @property
def _key_counter(self): def _key_counter(self):
return f"{self._ROOT_KEY}_{self._key_id}_COUNTER" return f"{self._ROOT_KEY}_{self._key_id}_COUNTER"

View File

@@ -1,13 +1,48 @@
import datetime as dt import datetime as dt
from unittest.mock import patch
from pytz import utc from pytz import utc
from redis import RedisError
from django.test import TestCase from django.test import TestCase
from django.utils.timezone import now from django.utils.timezone import now
from allianceauth.authentication.task_statistics.event_series import EventSeries from allianceauth.authentication.task_statistics.event_series import (
EventSeries,
_RedisStub,
)
MODULE_PATH = "allianceauth.authentication.task_statistics.event_series"
class TestEventSeries(TestCase): class TestEventSeries(TestCase):
def test_should_abort_without_redis_client(self):
# when
with patch(MODULE_PATH + ".cache.get_master_client") as mock:
mock.return_value = None
events = EventSeries("dummy")
# then
self.assertTrue(events._redis, _RedisStub)
self.assertTrue(events.is_disabled)
def test_should_disable_itself_if_redis_not_available_1(self):
# when
with patch(MODULE_PATH + ".cache.get_master_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_disable_itself_if_redis_not_available_2(self):
# when
with patch(MODULE_PATH + ".cache.get_master_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): def test_should_add_event(self):
# given # given
events = EventSeries("dummy") events = EventSeries("dummy")

View File

@@ -2,6 +2,8 @@ from bs4 import BeautifulSoup
from urllib.parse import quote from urllib.parse import quote
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from django_webtest import WebTest
from django.contrib.admin.sites import AdminSite from django.contrib.admin.sites import AdminSite
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.test import TestCase, RequestFactory, Client from django.test import TestCase, RequestFactory, Client
@@ -276,10 +278,10 @@ class TestOwnershipRecordAdmin(TestCaseWithTestData):
class TestStateAdmin(TestCaseWithTestData): class TestStateAdmin(TestCaseWithTestData):
fixtures = ["disable_analytics"] fixtures = ["disable_analytics"]
def setUp(self): @classmethod
self.modeladmin = StateAdmin( def setUpClass(cls) -> None:
model=User, admin_site=AdminSite() super().setUpClass()
) cls.modeladmin = StateAdmin(model=User, admin_site=AdminSite())
def test_change_view_loads_normally(self): def test_change_view_loads_normally(self):
User.objects.create_superuser( User.objects.create_superuser(
@@ -543,7 +545,74 @@ class TestUserAdmin(TestCaseWithTestData):
self.assertEqual(response.status_code, expected) 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): class TestUserAdminChangeForm(TestCase):
fixtures = ["disable_analytics"]
@classmethod @classmethod
def setUpClass(cls) -> None: def setUpClass(cls) -> None:
super().setUpClass() super().setUpClass()
@@ -552,7 +621,7 @@ class TestUserAdminChangeForm(TestCase):
def test_should_show_groups_available_to_user_with_blue_state_only(self): def test_should_show_groups_available_to_user_with_blue_state_only(self):
# given # given
superuser = User.objects.create_superuser("Super") superuser = User.objects.create_superuser("Super")
user = AuthUtils.create_user("Bruce Wayne") user = AuthUtils.create_user("bruce_wayne")
character = AuthUtils.add_main_character_2( character = AuthUtils.add_main_character_2(
user, user,
name="Bruce Wayne", name="Bruce Wayne",
@@ -579,6 +648,126 @@ class TestUserAdminChangeForm(TestCase):
self.assertSetEqual(group_ids, {group_1.pk, group_2.pk}) 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 TestMakeServicesHooksActions(TestCaseWithTestData):
class MyServicesHookTypeA(ServicesHook): class MyServicesHookTypeA(ServicesHook):

View File

@@ -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) start_of_previous_month = first_day_of_previous_month(year, month)
if request.user.has_perm('auth.fleetactivitytracking_statistics') and char_id: 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: else:
user = request.user user = request.user
logger.debug(f"Personal monthly statistics view for user {user} called by {request.user}") logger.debug(f"Personal monthly statistics view for user {user} called by {request.user}")

View File

@@ -1,19 +1,21 @@
from django import forms
from django.apps import apps from django.apps import apps
from django.contrib.auth.models import Permission
from django.contrib import admin 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 django.contrib.auth.models import Group as BaseGroup, Permission, User
from .models import GroupRequest 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: if 'eve_autogroups' in apps.app_configs:
_has_auto_groups = True _has_auto_groups = True
@@ -28,10 +30,12 @@ class AuthGroupInlineAdmin(admin.StackedInline):
'description', 'description',
'group_leaders', 'group_leaders',
'group_leader_groups', 'group_leader_groups',
'states', 'internal', 'states',
'internal',
'hidden', 'hidden',
'open', 'open',
'public' 'public',
'restricted',
) )
verbose_name_plural = 'Auth Settings' verbose_name_plural = 'Auth Settings'
verbose_name = '' verbose_name = ''
@@ -50,6 +54,11 @@ class AuthGroupInlineAdmin(admin.StackedInline):
def has_change_permission(self, request, obj=None): def has_change_permission(self, request, obj=None):
return request.user.has_perm('auth.change_group') 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: if _has_auto_groups:
class IsAutoGroupFilter(admin.SimpleListFilter): class IsAutoGroupFilter(admin.SimpleListFilter):
@@ -96,27 +105,15 @@ class HasLeaderFilter(admin.SimpleListFilter):
return queryset 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): class GroupAdmin(admin.ModelAdmin):
form = GroupAdminForm form = GroupAdminForm
list_select_related = ('authgroup',)
ordering = ('name',) ordering = ('name',)
list_display = ( list_display = (
'name', 'name',
'_description', '_description',
'_properties', '_properties',
'_member_count', '_member_count',
'has_leader' 'has_leader',
) )
list_filter = [ list_filter = [
'authgroup__internal', 'authgroup__internal',
@@ -132,34 +129,51 @@ class GroupAdmin(admin.ModelAdmin):
def get_queryset(self, request): def get_queryset(self, request):
qs = super().get_queryset(request) qs = super().get_queryset(request)
if _has_auto_groups: has_leader_qs = (
qs = qs.prefetch_related('managedalliancegroup_set', 'managedcorpgroup_set') AuthGroup.objects.filter(group=OuterRef('pk'), group_leaders__isnull=False)
qs = qs.prefetch_related('authgroup__group_leaders').select_related('authgroup')
qs = qs.annotate(
member_count=Count('user', distinct=True),
) )
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 return qs
def _description(self, obj): def _description(self, obj):
return obj.authgroup.description return obj.authgroup.description
@admin.display(description='Members', ordering='member_count')
def _member_count(self, obj): def _member_count(self, obj):
return obj.member_count return obj.member_count
_member_count.short_description = 'Members' @admin.display(boolean=True)
_member_count.admin_order_field = 'member_count'
def has_leader(self, obj): def has_leader(self, obj):
return obj.authgroup.group_leaders.exists() or obj.authgroup.group_leader_groups.exists() return obj.has_leader or obj.has_leader_groups
has_leader.boolean = True
def _properties(self, obj): def _properties(self, obj):
properties = list() properties = list()
if _has_auto_groups and ( if _has_auto_groups and (obj.is_autogroup_corp or obj.is_autogroup_alliance):
obj.managedalliancegroup_set.exists()
or obj.managedcorpgroup_set.exists()
):
properties.append('Auto Group') properties.append('Auto Group')
elif obj.authgroup.internal: elif obj.authgroup.internal:
properties.append('Internal') properties.append('Internal')
@@ -172,11 +186,10 @@ class GroupAdmin(admin.ModelAdmin):
properties.append('Public') properties.append('Public')
if not properties: if not properties:
properties.append('Default') properties.append('Default')
if obj.authgroup.restricted:
properties.append('Restricted')
return properties return properties
_properties.short_description = "properties"
filter_horizontal = ('permissions',) filter_horizontal = ('permissions',)
inlines = (AuthGroupInlineAdmin,) inlines = (AuthGroupInlineAdmin,)
@@ -190,8 +203,15 @@ class GroupAdmin(admin.ModelAdmin):
ag_instance = inline_form.save(commit=False) ag_instance = inline_form.save(commit=False)
ag_instance.group = form.instance ag_instance.group = form.instance
ag_instance.save() ag_instance.save()
if ag_instance.states.exists():
remove_users_not_matching_states_from_group.delay(ag_instance.group.pk)
formset.save() 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 Group(BaseGroup):
class Meta: class Meta:
@@ -216,33 +236,10 @@ class GroupRequestAdmin(admin.ModelAdmin):
'leave_request', 'leave_request',
) )
@admin.display(boolean=True, description="is leave request")
def _leave_request(self, obj) -> True: def _leave_request(self, obj) -> True:
return obj.leave_request 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) @admin.register(ReservedGroupName)
class ReservedGroupNameAdmin(admin.ModelAdmin): class ReservedGroupNameAdmin(admin.ModelAdmin):

View 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()

View File

@@ -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.'),
),
]

View File

@@ -13,6 +13,7 @@ from allianceauth.notifications import notify
class GroupRequest(models.Model): class GroupRequest(models.Model):
"""Request from a user for joining or leaving a group.""" """Request from a user for joining or leaving a group."""
leave_request = models.BooleanField(default=0) leave_request = models.BooleanField(default=0)
user = models.ForeignKey(User, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE)
group = models.ForeignKey(Group, on_delete=models.CASCADE) group = models.ForeignKey(Group, on_delete=models.CASCADE)
@@ -44,6 +45,7 @@ class GroupRequest(models.Model):
class RequestLog(models.Model): class RequestLog(models.Model):
"""Log entry about who joined and left a group and who approved it.""" """Log entry about who joined and left a group and who approved it."""
request_type = models.BooleanField(null=True) request_type = models.BooleanField(null=True)
group = models.ForeignKey(Group, on_delete=models.CASCADE) group = models.ForeignKey(Group, on_delete=models.CASCADE)
request_info = models.CharField(max_length=254) request_info = models.CharField(max_length=254)
@@ -95,6 +97,7 @@ class AuthGroup(models.Model):
Open - Users are automatically accepted into the group Open - Users are automatically accepted into the group
Not Open - Users requests must be approved before they are added to 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) group = models.OneToOneField(Group, on_delete=models.CASCADE, primary_key=True)
internal = models.BooleanField( internal = models.BooleanField(
default=True, default=True,
@@ -126,6 +129,13 @@ class AuthGroup(models.Model):
"are no longer authenticated." "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( group_leaders = models.ManyToManyField(
User, User,
related_name='leads_groups', related_name='leads_groups',
@@ -179,12 +189,22 @@ class AuthGroup(models.Model):
| User.objects.filter(groups__in=list(self.group_leader_groups.all())) | 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): class ReservedGroupName(models.Model):
"""Name that can not be used for groups. """Name that can not be used for groups.
This enables AA to ignore groups on other services (e.g. Discord) with that name. This enables AA to ignore groups on other services (e.g. Discord) with that name.
""" """
name = models.CharField( name = models.CharField(
_('name'), _('name'),
max_length=150, max_length=150,

View 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()

View File

@@ -1,17 +1,21 @@
from unittest.mock import patch from unittest.mock import patch
from django_webtest import WebTest
from django.conf import settings from django.conf import settings
from django.contrib import admin from django.contrib import admin
from django.contrib.admin.sites import AdminSite from django.contrib.admin.sites import AdminSite
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import TestCase, RequestFactory, Client from django.test import TestCase, RequestFactory, Client, override_settings
from allianceauth.authentication.models import CharacterOwnership, State from allianceauth.authentication.models import CharacterOwnership, State
from allianceauth.eveonline.models import ( from allianceauth.eveonline.models import (
EveCharacter, EveCorporationInfo, EveAllianceInfo EveCharacter, EveCorporationInfo, EveAllianceInfo
) )
from ..admin import HasLeaderFilter, GroupAdmin, Group from allianceauth.tests.auth_utils import AuthUtils
from . import get_admin_change_view_url from . import get_admin_change_view_url
from ..admin import HasLeaderFilter, GroupAdmin, Group
from ..models import ReservedGroupName from ..models import ReservedGroupName
@@ -33,7 +37,6 @@ class MockRequest:
class TestGroupAdmin(TestCase): class TestGroupAdmin(TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
super().setUpClass() super().setUpClass()
@@ -233,60 +236,104 @@ class TestGroupAdmin(TestCase):
self.assertEqual(result, expected) self.assertEqual(result, expected)
def test_member_count(self): def test_member_count(self):
expected = 1 # given
obj = self.modeladmin.get_queryset(MockRequest(user=self.user_1))\ request = MockRequest(user=self.user_1)
.get(pk=self.group_1.pk) obj = self.modeladmin.get_queryset(request).get(pk=self.group_1.pk)
# when
result = self.modeladmin._member_count(obj) result = self.modeladmin._member_count(obj)
self.assertEqual(result, expected) # then
self.assertEqual(result, 1)
def test_has_leader_user(self): 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) self.assertTrue(result)
def test_has_leader_group(self): 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) self.assertTrue(result)
def test_properties_1(self): def test_properties_1(self):
expected = ['Default'] # given
result = self.modeladmin._properties(self.group_1) request = MockRequest(user=self.user_1)
self.assertListEqual(result, expected) 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): def test_properties_2(self):
expected = ['Internal'] # given
result = self.modeladmin._properties(self.group_2) request = MockRequest(user=self.user_1)
self.assertListEqual(result, expected) 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): def test_properties_3(self):
expected = ['Hidden'] # given
result = self.modeladmin._properties(self.group_3) request = MockRequest(user=self.user_1)
self.assertListEqual(result, expected) 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): def test_properties_4(self):
expected = ['Open'] # given
result = self.modeladmin._properties(self.group_4) request = MockRequest(user=self.user_1)
self.assertListEqual(result, expected) 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): def test_properties_5(self):
expected = ['Public'] # given
result = self.modeladmin._properties(self.group_5) request = MockRequest(user=self.user_1)
self.assertListEqual(result, expected) 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): def test_properties_6(self):
expected = ['Hidden', 'Open', 'Public'] # given
result = self.modeladmin._properties(self.group_6) request = MockRequest(user=self.user_1)
self.assertListEqual(result, expected) 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: if _has_auto_groups:
@patch(MODULE_PATH + '._has_auto_groups', True) @patch(MODULE_PATH + '._has_auto_groups', True)
def test_properties_7(self): def test_should_show_autogroup_for_corporation(self):
# given
self._create_autogroups() self._create_autogroups()
expected = ['Auto Group'] request = MockRequest(user=self.user_1)
my_group = Group.objects\ queryset = self.modeladmin.get_queryset(request)
.filter(managedcorpgroup__isnull=False)\ obj = queryset.filter(managedcorpgroup__isnull=False).first()
.first() # when
result = self.modeladmin._properties(my_group) result = self.modeladmin._properties(obj)
self.assertListEqual(result, expected) # 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 # actions
@@ -468,6 +515,136 @@ class TestGroupAdmin(TestCase):
self.assertFalse(Group.objects.filter(name="new group").exists()) 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): class TestReservedGroupNameAdmin(TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):

View File

@@ -232,6 +232,38 @@ class TestAuthGroup(TestCase):
expected = 'Superheros' expected = 'Superheros'
self.assertEqual(str(group.authgroup), expected) 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): class TestAuthGroupRequestApprovers(TestCase):
def setUp(self) -> None: def setUp(self) -> None:

View File

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

View File

@@ -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()

View File

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

View File

@@ -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 %}

View File

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

View 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)

View File

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

View File

@@ -1,4 +1,5 @@
import logging import logging
from functools import partial
from django.contrib.auth.models import User, Group, Permission from django.contrib.auth.models import User, Group, Permission
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
@@ -8,7 +9,7 @@ from django.db.models.signals import pre_delete
from django.db.models.signals import pre_save from django.db.models.signals import pre_save
from django.dispatch import receiver from django.dispatch import receiver
from .hooks import ServicesHook 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.models import State, UserProfile
from allianceauth.authentication.signals import state_changed from allianceauth.authentication.signals import state_changed
@@ -19,21 +20,27 @@ logger = logging.getLogger(__name__)
@receiver(m2m_changed, sender=User.groups.through) @receiver(m2m_changed, sender=User.groups.through)
def m2m_changed_user_groups(sender, instance, action, *args, **kwargs): def m2m_changed_user_groups(sender, instance, action, *args, **kwargs):
logger.debug(f"Received m2m_changed from {instance} groups with action {action}") logger.debug(
"%s: Received m2m_changed from groups with action %s", instance, action
def trigger_service_group_update(): )
logger.debug("Triggering service group update for %s" % instance) if instance.pk and (
# Iterate through Service hooks action == "post_add" or action == "post_remove" or action == "post_clear"
for svc in ServicesHook.get_services(): ):
try: if isinstance(instance, User):
svc.validate_user(instance) logger.debug(
svc.update_groups(instance) "Waiting for commit to trigger service group update for %s", instance
except: )
logger.exception(f'Exception running update_groups for services module {svc} on user {instance}') transaction.on_commit(partial(update_groups_for_user.delay, instance.pk))
elif (
if instance.pk and (action == "post_add" or action == "post_remove" or action == "post_clear"): isinstance(instance, Group)
logger.debug("Waiting for commit to trigger service group update for %s" % instance) and kwargs.get("model") is User
transaction.on_commit(trigger_service_group_update) 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) @receiver(m2m_changed, sender=User.user_permissions.through)

View File

@@ -47,3 +47,20 @@ def disable_user(user):
for svc in ServicesHook.get_services(): for svc in ServicesHook.get_services():
if svc.service_active_for_user(user): if svc.service_active_for_user(user):
svc.delete_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
)

View File

@@ -1,7 +1,7 @@
from copy import deepcopy from copy import deepcopy
from unittest import mock 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 django.contrib.auth.models import Group, Permission
from allianceauth.authentication.models import State from allianceauth.authentication.models import State
@@ -9,6 +9,9 @@ from allianceauth.eveonline.models import EveCharacter
from allianceauth.tests.auth_utils import AuthUtils from allianceauth.tests.auth_utils import AuthUtils
MODULE_PATH = 'allianceauth.services.signals'
class ServicesSignalsTestCase(TestCase): class ServicesSignalsTestCase(TestCase):
def setUp(self): def setUp(self):
self.member = AuthUtils.create_user('auth_member', disconnect_signals=True) 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) self.none_user = AuthUtils.create_user('none_user', disconnect_signals=True)
@mock.patch('allianceauth.services.signals.transaction') @mock.patch(MODULE_PATH + '.transaction', spec=True)
@mock.patch('allianceauth.services.signals.ServicesHook') @mock.patch(MODULE_PATH + '.update_groups_for_user', spec=True)
def test_m2m_changed_user_groups(self, services_hook, transaction): def test_m2m_changed_user_groups(self, update_groups_for_user, transaction):
""" """
Test that update_groups hook function is called on user groups change 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 # Overload transaction.on_commit so everything happens synchronously
transaction.on_commit = lambda fn: fn() transaction.on_commit = lambda fn: fn()
@@ -39,17 +37,11 @@ class ServicesSignalsTestCase(TestCase):
self.member.save() self.member.save()
# Assert # 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) @mock.patch(MODULE_PATH + '.disable_user')
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')
def test_pre_delete_user(self, disable_user): def test_pre_delete_user(self, disable_user):
""" """
Test that disable_member is called when a user is deleted Test that disable_member is called when a user is deleted
@@ -60,7 +52,7 @@ class ServicesSignalsTestCase(TestCase):
args, kwargs = disable_user.call_args args, kwargs = disable_user.call_args
self.assertEqual(self.none_user, args[0]) 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): def test_pre_save_user_inactivation(self, disable_user):
""" """
Test a user set inactive has disable_member called Test a user set inactive has disable_member called
@@ -72,7 +64,7 @@ class ServicesSignalsTestCase(TestCase):
args, kwargs = disable_user.call_args args, kwargs = disable_user.call_args
self.assertEqual(self.member, args[0]) 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): def test_disable_services_on_loss_of_main_character(self, disable_user):
""" """
Test a user set inactive has disable_member called Test a user set inactive has disable_member called
@@ -84,8 +76,8 @@ class ServicesSignalsTestCase(TestCase):
args, kwargs = disable_user.call_args args, kwargs = disable_user.call_args
self.assertEqual(self.member, args[0]) self.assertEqual(self.member, args[0])
@mock.patch('allianceauth.services.signals.transaction') @mock.patch(MODULE_PATH + '.transaction')
@mock.patch('allianceauth.services.signals.ServicesHook') @mock.patch(MODULE_PATH + '.ServicesHook')
def test_m2m_changed_group_permissions(self, services_hook, transaction): def test_m2m_changed_group_permissions(self, services_hook, transaction):
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
svc = mock.Mock() svc = mock.Mock()
@@ -116,8 +108,8 @@ class ServicesSignalsTestCase(TestCase):
args, kwargs = svc.validate_user.call_args args, kwargs = svc.validate_user.call_args
self.assertEqual(self.member, args[0]) self.assertEqual(self.member, args[0])
@mock.patch('allianceauth.services.signals.transaction') @mock.patch(MODULE_PATH + '.transaction')
@mock.patch('allianceauth.services.signals.ServicesHook') @mock.patch(MODULE_PATH + '.ServicesHook')
def test_m2m_changed_user_permissions(self, services_hook, transaction): def test_m2m_changed_user_permissions(self, services_hook, transaction):
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
svc = mock.Mock() svc = mock.Mock()
@@ -145,8 +137,8 @@ class ServicesSignalsTestCase(TestCase):
args, kwargs = svc.validate_user.call_args args, kwargs = svc.validate_user.call_args
self.assertEqual(self.member, args[0]) self.assertEqual(self.member, args[0])
@mock.patch('allianceauth.services.signals.transaction') @mock.patch(MODULE_PATH + '.transaction')
@mock.patch('allianceauth.services.signals.ServicesHook') @mock.patch(MODULE_PATH + '.ServicesHook')
def test_m2m_changed_user_state_permissions(self, services_hook, transaction): def test_m2m_changed_user_state_permissions(self, services_hook, transaction):
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
svc = mock.Mock() svc = mock.Mock()
@@ -180,7 +172,7 @@ class ServicesSignalsTestCase(TestCase):
args, kwargs = svc.validate_user.call_args args, kwargs = svc.validate_user.call_args
self.assertEqual(self.member, args[0]) 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): def test_state_changed_services_validation_and_groups_update(self, services_hook):
"""Test a user changing state has service accounts validated and groups updated """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 args, kwargs = svc.update_groups.call_args
self.assertEqual(self.member, args[0]) self.assertEqual(self.member, args[0])
@mock.patch(MODULE_PATH + '.ServicesHook')
@mock.patch('allianceauth.services.signals.ServicesHook')
def test_state_changed_services_validation_and_groups_update_1(self, services_hook): 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 """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 args, kwargs = svc.sync_nickname.call_args
self.assertEqual(self.member, args[0]) 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): 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 """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 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(services_hook.get_services.called)
self.assertFalse(svc.validate_user.called) self.assertFalse(svc.validate_user.called)
self.assertFalse(svc.sync_nickname.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})

View File

@@ -3,32 +3,50 @@ from unittest import mock
from celery_once import AlreadyQueued from celery_once import AlreadyQueued
from django.core.cache import cache 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.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 from ..tasks import DjangoBackend
@override_settings(CELERY_ALWAYS_EAGER=True, CELERY_EAGER_PROPAGATES_EXCEPTIONS=True)
class ServicesTasksTestCase(TestCase): class ServicesTasksTestCase(TestCase):
def setUp(self): def setUp(self):
self.member = AuthUtils.create_user('auth_member') self.member = AuthUtils.create_user('auth_member')
@mock.patch('allianceauth.services.tasks.ServicesHook') @mock.patch('allianceauth.services.tasks.ServicesHook')
def test_validate_services(self, services_hook): def test_validate_services(self, services_hook):
# given
svc = mock.Mock() svc = mock.Mock()
svc.validate_user.return_value = None svc.validate_user.return_value = None
services_hook.get_services.return_value = [svc] services_hook.get_services.return_value = [svc]
# when
validate_services.delay(self.member.pk) validate_services.delay(self.member.pk)
# then
self.assertTrue(services_hook.get_services.called) self.assertTrue(services_hook.get_services.called)
self.assertTrue(svc.validate_user.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 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): class TestDjangoBackend(TestCase):

View File

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

View File

@@ -36,6 +36,14 @@ GITLAB_AUTH_ANNOUNCEMENT_ISSUES_URL = (
logger = logging.getLogger(__name__) 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') @register.inclusion_tag('allianceauth/admin-status/overview.html')
def status_overview() -> dict: def status_overview() -> dict:
response = { response = {

View File

@@ -1,3 +1,5 @@
from typing import List
from django.contrib.auth.models import User, Group, Permission from django.contrib.auth.models import User, Group, Permission
from django.db.models.signals import m2m_changed, pre_save, post_save from django.db.models.signals import m2m_changed, pre_save, post_save
from django.test import TestCase from django.test import TestCase
@@ -258,6 +260,23 @@ class AuthUtils:
p = cls.get_permission_by_name(perm) p = cls.get_permission_by_name(perm)
return cls.add_permissions_to_user([p], user, disconnect_signals) 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 @staticmethod
def get_permission_by_name(perm: str) -> Permission: def get_permission_by_name(perm: str) -> Permission:
"""returns permission specified by qualified name """returns permission specified by qualified name

View File

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

View File

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

114
docs/_static/css/rtd_dark.css vendored Normal file
View 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;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

View File

@@ -60,7 +60,7 @@ master_doc = 'index'
# General information about the project. # General information about the project.
project = 'Alliance Auth' project = 'Alliance Auth'
copyright = '2018-2020, Alliance Auth' copyright = '2018-2022, Alliance Auth'
author = 'R4stl1n' author = 'R4stl1n'
# The version info for the project you're documenting, acts as replacement for # The version info for the project you're documenting, acts as replacement for
@@ -111,6 +111,7 @@ html_theme_options = {
# relative to this directory. They are copied after the builtin static files, # relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css". # so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static'] html_static_path = ['_static']
html_css_files = ["css/rtd_dark.css"]
# -- Options for HTMLHelp output ------------------------------------------ # -- Options for HTMLHelp output ------------------------------------------

View File

@@ -150,12 +150,14 @@ sudo redis-server --daemonize yes
```eval_rst ```eval_rst
.. note:: .. 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 #/bin/bash
sudo redis-server --daemonize yes # 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. In addition it is possible to configure Windows to automatically start WSL services, but that procedure goes beyond the scopes of this guide.
``` ```

View File

@@ -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 %} {% load evelinks %}
Template Filters
================
evelinks 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 .. automodule:: allianceauth.eveonline.templatetags.evelinks
:members: :members:

View 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.
![Admin Site](/_static/images/features/core/admin_site.png)
## 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.

View File

@@ -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. 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 ```eval_rst
.. _ref-reserved-group-names: .. _ref-reserved-group-names:
``` ```

View File

@@ -11,4 +11,5 @@ Managing access to applications and services is one of the core functions of **A
groups groups
analytics analytics
notifications notifications
admin_site
``` ```

View File

@@ -2,13 +2,15 @@
sphinx>=3.2.1,<4.0.0 sphinx>=3.2.1,<4.0.0
sphinx_rtd_theme==0.5.0 sphinx_rtd_theme==0.5.0
recommonmark==0.6.0 recommonmark==0.6.0
Jinja2<3.1
# Autodoc dependencies # Autodoc dependencies
django>=3.2,<4.0.0 django>=3.2,<4.0.0
django-celery-beat>=2.0.0 django-celery-beat>=2.0.0
django-redis-cache
django-bootstrap-form django-bootstrap-form
django-sortedm2m django-sortedm2m
django-esi>=3,<4 django-esi>=3,<5
celery>5,<6 celery>5,<6
celery_once celery_once
passlib passlib

10
tox.ini
View File

@@ -1,7 +1,7 @@
[tox] [tox]
skipsdist = true skipsdist = true
usedevelop = true usedevelop = true
envlist = py{37,38,39,310,311}-{all,core} envlist = py{37,38,39,310,311}-{all,core}, docs
[testenv] [testenv]
setenv = setenv =
@@ -21,3 +21,11 @@ commands =
core: coverage run runtests.py allianceauth.authentication.tests.test_app_settings -v 2 --debug-mode core: coverage run runtests.py allianceauth.authentication.tests.test_app_settings -v 2 --debug-mode
all: coverage report -m all: coverage report -m
all: coverage xml 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}