mirror of
https://gitlab.com/allianceauth/allianceauth.git
synced 2025-07-08 20:10:17 +02:00
Improve menu app
This commit is contained in:
parent
2a762df9b3
commit
62c936f1c0
@ -19,5 +19,6 @@ exclude_lines =
|
|||||||
if __name__ == .__main__.:
|
if __name__ == .__main__.:
|
||||||
def __repr__
|
def __repr__
|
||||||
raise AssertionError
|
raise AssertionError
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
|
||||||
ignore_errors = True
|
ignore_errors = True
|
||||||
|
@ -26,11 +26,11 @@ pre-commit-check:
|
|||||||
<<: *only-default
|
<<: *only-default
|
||||||
stage: pre-commit
|
stage: pre-commit
|
||||||
image: python:3.11-bullseye
|
image: python:3.11-bullseye
|
||||||
variables:
|
# variables:
|
||||||
PRE_COMMIT_HOME: ${CI_PROJECT_DIR}/.cache/pre-commit
|
# PRE_COMMIT_HOME: ${CI_PROJECT_DIR}/.cache/pre-commit
|
||||||
cache:
|
# cache:
|
||||||
paths:
|
# paths:
|
||||||
- ${PRE_COMMIT_HOME}
|
# - ${PRE_COMMIT_HOME}
|
||||||
script:
|
script:
|
||||||
- pip install pre-commit
|
- pip install pre-commit
|
||||||
- pre-commit run --all-files
|
- pre-commit run --all-files
|
||||||
|
@ -0,0 +1,10 @@
|
|||||||
|
{% extends 'allianceauth/base.html' %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block page_title %}Dashboard{% endblock page_title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div>
|
||||||
|
<h1>Dashboard Dummy</h1>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
@ -38,5 +38,6 @@ urlpatterns = [
|
|||||||
name='token_refresh'
|
name='token_refresh'
|
||||||
),
|
),
|
||||||
path('dashboard/', views.dashboard, name='dashboard'),
|
path('dashboard/', views.dashboard, name='dashboard'),
|
||||||
|
path('dashboard_bs3/', views.dashboard_bs3, name='dashboard_bs3'),
|
||||||
path('task-counts/', views.task_counts, name='task_counts'),
|
path('task-counts/', views.task_counts, name='task_counts'),
|
||||||
]
|
]
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import logging
|
import logging
|
||||||
from allianceauth.hooks import get_hooks
|
|
||||||
|
|
||||||
from django_registration.backends.activation.views import (
|
from django_registration.backends.activation.views import (
|
||||||
REGISTRATION_SALT, ActivationView as BaseActivationView,
|
REGISTRATION_SALT, ActivationView as BaseActivationView,
|
||||||
@ -23,6 +22,7 @@ from esi.decorators import token_required
|
|||||||
from esi.models import Token
|
from esi.models import Token
|
||||||
|
|
||||||
from allianceauth.eveonline.models import EveCharacter
|
from allianceauth.eveonline.models import EveCharacter
|
||||||
|
from allianceauth.hooks import get_hooks
|
||||||
|
|
||||||
from .core.celery_workers import active_tasks_count, queued_tasks_count
|
from .core.celery_workers import active_tasks_count, queued_tasks_count
|
||||||
from .forms import RegistrationForm
|
from .forms import RegistrationForm
|
||||||
@ -349,3 +349,12 @@ def task_counts(request) -> JsonResponse:
|
|||||||
"tasks_queued": queued_tasks_count()
|
"tasks_queued": queued_tasks_count()
|
||||||
}
|
}
|
||||||
return JsonResponse(data)
|
return JsonResponse(data)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def dashboard_bs3(request):
|
||||||
|
"""Render dashboard view with BS3 theme.
|
||||||
|
|
||||||
|
This is an internal view used for testing BS3 backward compatibility in AA4 only.
|
||||||
|
"""
|
||||||
|
return render(request, 'authentication/dashboard_bs3.html')
|
||||||
|
@ -20,6 +20,7 @@
|
|||||||
width: 325px;
|
width: 325px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Menu items in general */
|
||||||
#sidebar-menu li > a,
|
#sidebar-menu li > a,
|
||||||
#sidebar-menu li > ul > li > a {
|
#sidebar-menu li > ul > li > a {
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@ -28,13 +29,21 @@
|
|||||||
max-width: 210px;
|
max-width: 210px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Parent items with chevron and possible badge */
|
||||||
|
#sidebar-menu li:has(span.badge) > a[data-bs-toggle="collapse"] {
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Child items with possible badge */
|
||||||
#sidebar-menu li > ul > li > a {
|
#sidebar-menu li > ul > li > a {
|
||||||
max-width: 189px;
|
max-width: 189px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Chevron icons */
|
||||||
#sidebar-menu [data-bs-toggle="collapse"] > i.fa-chevron-down,
|
#sidebar-menu [data-bs-toggle="collapse"] > i.fa-chevron-down,
|
||||||
#sidebar-menu [data-bs-toggle="collapse"].collapsed > i.fa-chevron-right {
|
#sidebar-menu [data-bs-toggle="collapse"].collapsed > i.fa-chevron-right {
|
||||||
display: block;
|
display: block;
|
||||||
|
width: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar-menu [data-bs-toggle="collapse"] > i.fa-chevron-right,
|
#sidebar-menu [data-bs-toggle="collapse"] > i.fa-chevron-right,
|
||||||
|
@ -1,9 +1,111 @@
|
|||||||
|
"""Admin site for menu app."""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.utils.translation import gettext_noop as _
|
||||||
|
|
||||||
from . import models
|
from .constants import MenuItemType
|
||||||
|
from .core.smart_sync import sync_menu
|
||||||
|
from .filters import MenuItemTypeListFilter
|
||||||
|
from .forms import (
|
||||||
|
AppMenuItemAdminForm,
|
||||||
|
FolderMenuItemAdminForm,
|
||||||
|
LinkMenuItemAdminForm,
|
||||||
|
)
|
||||||
|
from .models import MenuItem
|
||||||
|
|
||||||
|
|
||||||
@admin.register(models.MenuItem)
|
@admin.register(MenuItem)
|
||||||
class MenuItemAdmin(admin.ModelAdmin):
|
class MenuItemAdmin(admin.ModelAdmin):
|
||||||
list_display = ['text', 'hide', 'parent', 'url', 'icon_classes', 'rank']
|
list_display = (
|
||||||
ordering = ('rank',)
|
"_text",
|
||||||
|
"parent",
|
||||||
|
"order",
|
||||||
|
"_user_defined",
|
||||||
|
"_visible",
|
||||||
|
"_children",
|
||||||
|
)
|
||||||
|
list_filter = [
|
||||||
|
MenuItemTypeListFilter,
|
||||||
|
"is_hidden",
|
||||||
|
("parent", admin.RelatedOnlyFieldListFilter),
|
||||||
|
]
|
||||||
|
ordering = ["parent", "order", "text"]
|
||||||
|
|
||||||
|
def get_form(self, request: HttpRequest, obj: Optional[MenuItem] = None, **kwargs):
|
||||||
|
kwargs["form"] = self._choose_form(request, obj)
|
||||||
|
return super().get_form(request, obj, **kwargs)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _choose_form(cls, request: HttpRequest, obj: Optional[MenuItem]):
|
||||||
|
"""Return the form for the current menu item type."""
|
||||||
|
if obj: # change
|
||||||
|
if obj.hook_hash:
|
||||||
|
return AppMenuItemAdminForm
|
||||||
|
|
||||||
|
if obj.is_folder:
|
||||||
|
return FolderMenuItemAdminForm
|
||||||
|
|
||||||
|
return LinkMenuItemAdminForm
|
||||||
|
|
||||||
|
# add
|
||||||
|
if cls._type_from_request(request) is MenuItemType.FOLDER:
|
||||||
|
return FolderMenuItemAdminForm
|
||||||
|
|
||||||
|
return LinkMenuItemAdminForm
|
||||||
|
|
||||||
|
def add_view(self, request, form_url="", extra_context=None) -> HttpResponse:
|
||||||
|
context = extra_context or {}
|
||||||
|
item_type = self._type_from_request(request, default=MenuItemType.LINK)
|
||||||
|
context["title"] = _("Add %s menu item") % item_type.label
|
||||||
|
return super().add_view(request, form_url, context)
|
||||||
|
|
||||||
|
def change_view(
|
||||||
|
self, request, object_id, form_url="", extra_context=None
|
||||||
|
) -> HttpResponse:
|
||||||
|
extra_context = extra_context or {}
|
||||||
|
obj = get_object_or_404(MenuItem, id=object_id)
|
||||||
|
extra_context["title"] = _("Change %s menu item") % obj.item_type.label
|
||||||
|
return super().change_view(request, object_id, form_url, extra_context)
|
||||||
|
|
||||||
|
def changelist_view(self, request: HttpRequest, extra_context=None):
|
||||||
|
# needed to ensure items are updated after an app change
|
||||||
|
# and when the admin page is opened directly
|
||||||
|
sync_menu()
|
||||||
|
extra_context = extra_context or {}
|
||||||
|
extra_context["folder_type"] = MenuItemType.FOLDER.value
|
||||||
|
return super().changelist_view(request, extra_context)
|
||||||
|
|
||||||
|
@admin.display(description=_("children"))
|
||||||
|
def _children(self, obj: MenuItem):
|
||||||
|
if not obj.is_folder:
|
||||||
|
return []
|
||||||
|
|
||||||
|
names = [obj.text for obj in obj.children.order_by("order", "text")]
|
||||||
|
return names if names else "?"
|
||||||
|
|
||||||
|
@admin.display(description=_("text"), ordering="text")
|
||||||
|
def _text(self, obj: MenuItem) -> str:
|
||||||
|
if obj.is_folder:
|
||||||
|
return f"[{obj.text}]"
|
||||||
|
return obj.text
|
||||||
|
|
||||||
|
@admin.display(description=_("user defined"), boolean=True)
|
||||||
|
def _user_defined(self, obj: MenuItem) -> bool:
|
||||||
|
return obj.is_user_defined
|
||||||
|
|
||||||
|
@admin.display(description=_("visible"), ordering="is_hidden", boolean=True)
|
||||||
|
def _visible(self, obj: MenuItem) -> bool:
|
||||||
|
return not bool(obj.is_hidden)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _type_from_request(
|
||||||
|
request: HttpRequest, default=None
|
||||||
|
) -> Optional[MenuItemType]:
|
||||||
|
try:
|
||||||
|
return MenuItemType(request.GET.get("type"))
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
@ -1,19 +1,19 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
from django.db.utils import ProgrammingError, OperationalError
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# TODO discuss permissions for user defined links
|
||||||
|
# TODO define aa way for hooks to predefine a "parent" to create a sub menu from modules
|
||||||
|
# TODO Add user documentation
|
||||||
|
|
||||||
|
|
||||||
class MenuConfig(AppConfig):
|
class MenuConfig(AppConfig):
|
||||||
name = "allianceauth.menu"
|
name = "allianceauth.menu"
|
||||||
label = "menu"
|
label = "menu"
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
try:
|
from allianceauth.menu.core import smart_sync
|
||||||
from allianceauth.menu.providers import menu_provider
|
|
||||||
menu_provider.clear_synced_flag()
|
smart_sync.reset_menu_items_sync()
|
||||||
except (ProgrammingError, OperationalError):
|
|
||||||
logger.warning("Migrations not completed for MenuItems")
|
|
||||||
|
18
allianceauth/menu/constants.py
Normal file
18
allianceauth/menu/constants.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
"""Global constants for the menu app."""
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
DEFAULT_FOLDER_ICON_CLASSES = "fa-solid fa-folder" # TODO: Make this a setting?
|
||||||
|
"""Default icon class for folders."""
|
||||||
|
|
||||||
|
DEFAULT_MENU_ITEM_ORDER = 9999
|
||||||
|
"""Default order for any menu item."""
|
||||||
|
|
||||||
|
|
||||||
|
class MenuItemType(models.TextChoices):
|
||||||
|
"""The type of a menu item."""
|
||||||
|
|
||||||
|
APP = "app", _("app")
|
||||||
|
FOLDER = "folder", _("folder")
|
||||||
|
LINK = "link", _("link")
|
0
allianceauth/menu/core/__init__.py
Normal file
0
allianceauth/menu/core/__init__.py
Normal file
48
allianceauth/menu/core/menu_item_hooks.py
Normal file
48
allianceauth/menu/core/menu_item_hooks.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
"""Logic for handling MenuItemHook objects."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from typing import List, NamedTuple, Optional
|
||||||
|
|
||||||
|
from allianceauth.menu.hooks import MenuItemHook
|
||||||
|
|
||||||
|
|
||||||
|
class MenuItemHookCustom(MenuItemHook):
|
||||||
|
"""A user defined menu item that can be rendered with the standard template."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
classes: str,
|
||||||
|
url_name: str,
|
||||||
|
order: Optional[int] = None,
|
||||||
|
navactive: Optional[List[str]] = None,
|
||||||
|
):
|
||||||
|
super().__init__(text, classes, url_name, order, navactive)
|
||||||
|
self.url = ""
|
||||||
|
self.is_folder = None
|
||||||
|
self.html_id = ""
|
||||||
|
self.children = []
|
||||||
|
|
||||||
|
|
||||||
|
class MenuItemHookParams(NamedTuple):
|
||||||
|
"""Immutable container for params about a menu item hook."""
|
||||||
|
|
||||||
|
text: str
|
||||||
|
order: int
|
||||||
|
hash: str
|
||||||
|
|
||||||
|
|
||||||
|
def generate_hash(obj: MenuItemHook) -> str:
|
||||||
|
"""Return the hash for a menu item hook."""
|
||||||
|
my_class = obj.__class__
|
||||||
|
name = f"{my_class.__module__}.{my_class.__name__}"
|
||||||
|
hash_value = hashlib.sha256(name.encode("utf-8")).hexdigest()
|
||||||
|
return hash_value
|
||||||
|
|
||||||
|
|
||||||
|
def gather_params(obj: MenuItemHook) -> MenuItemHookParams:
|
||||||
|
"""Return params from a menu item hook."""
|
||||||
|
text = getattr(obj, "text", obj.__class__.__name__)
|
||||||
|
order = getattr(obj, "order", None)
|
||||||
|
hash = generate_hash(obj)
|
||||||
|
return MenuItemHookParams(text=text, hash=hash, order=order)
|
30
allianceauth/menu/core/smart_sync.py
Normal file
30
allianceauth/menu/core/smart_sync.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
"""Provide capability to sync menu items when needed only."""
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
_MENU_SYNC_CACHE_KEY = "ALLIANCEAUTH-MENU-SYNCED"
|
||||||
|
|
||||||
|
|
||||||
|
def sync_menu() -> None:
|
||||||
|
"""Sync menu items if needed only."""
|
||||||
|
from allianceauth.menu.models import MenuItem
|
||||||
|
|
||||||
|
is_sync_needed = not _is_menu_synced() or not MenuItem.objects.exists()
|
||||||
|
# need to also check for existence of MenuItems in database
|
||||||
|
# to ensure the menu is synced during tests
|
||||||
|
if is_sync_needed:
|
||||||
|
MenuItem.objects.sync_all()
|
||||||
|
_record_menu_was_synced()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_menu_synced() -> bool:
|
||||||
|
return cache.get(_MENU_SYNC_CACHE_KEY, False)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_menu_was_synced() -> None:
|
||||||
|
cache.set(_MENU_SYNC_CACHE_KEY, True, timeout=None) # no timeout
|
||||||
|
|
||||||
|
|
||||||
|
def reset_menu_items_sync() -> None:
|
||||||
|
"""Ensure menu items are synced, e.g. after a Django restart."""
|
||||||
|
cache.delete(_MENU_SYNC_CACHE_KEY)
|
24
allianceauth/menu/filters.py
Normal file
24
allianceauth/menu/filters.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
"""Filters for the menu app."""
|
||||||
|
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.utils.translation import gettext_noop as _
|
||||||
|
|
||||||
|
from allianceauth.menu.constants import MenuItemType
|
||||||
|
|
||||||
|
|
||||||
|
class MenuItemTypeListFilter(admin.SimpleListFilter):
|
||||||
|
"""Allow filtering admin changelist by menu item type."""
|
||||||
|
|
||||||
|
title = _("type")
|
||||||
|
parameter_name = "type"
|
||||||
|
|
||||||
|
def lookups(self, request, model_admin):
|
||||||
|
return [(obj.value, obj.label.title()) for obj in MenuItemType]
|
||||||
|
|
||||||
|
def queryset(self, request, queryset):
|
||||||
|
if value := self.value():
|
||||||
|
return queryset.annotate_item_type_2().filter(
|
||||||
|
item_type_2=MenuItemType(value).value
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
49
allianceauth/menu/forms.py
Normal file
49
allianceauth/menu/forms.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
from django import forms
|
||||||
|
|
||||||
|
from .constants import DEFAULT_FOLDER_ICON_CLASSES
|
||||||
|
from .models import MenuItem
|
||||||
|
|
||||||
|
|
||||||
|
class FolderMenuItemAdminForm(forms.ModelForm):
|
||||||
|
"""A form for changing folder items."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = MenuItem
|
||||||
|
fields = ["text", "classes", "order", "is_hidden"]
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
data = super().clean()
|
||||||
|
if not data["classes"]:
|
||||||
|
data["classes"] = DEFAULT_FOLDER_ICON_CLASSES
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class _BasedMenuItemAdminForm(forms.ModelForm):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields["parent"].queryset = MenuItem.objects.filter_folders().order_by(
|
||||||
|
"text"
|
||||||
|
)
|
||||||
|
self.fields["parent"].required = False
|
||||||
|
self.fields["parent"].widget = forms.Select(
|
||||||
|
choices=self.fields["parent"].widget.choices
|
||||||
|
) # disable modify buttons
|
||||||
|
|
||||||
|
|
||||||
|
class AppMenuItemAdminForm(_BasedMenuItemAdminForm):
|
||||||
|
"""A form for changing app items."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = MenuItem
|
||||||
|
fields = ["order", "parent", "is_hidden"]
|
||||||
|
|
||||||
|
|
||||||
|
class LinkMenuItemAdminForm(_BasedMenuItemAdminForm):
|
||||||
|
"""A form for changing link items."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = MenuItem
|
||||||
|
fields = ["text", "url", "classes", "order", "parent", "is_hidden"]
|
||||||
|
widgets = {
|
||||||
|
"url": forms.TextInput(attrs={"size": "100"}),
|
||||||
|
}
|
@ -1,42 +1,58 @@
|
|||||||
from django.template.loader import render_to_string
|
"""Menu item hooks."""
|
||||||
|
|
||||||
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
|
||||||
|
from allianceauth.menu.constants import DEFAULT_MENU_ITEM_ORDER
|
||||||
|
|
||||||
|
|
||||||
class MenuItemHook:
|
class MenuItemHook:
|
||||||
"""
|
"""Auth Hook for generating side menu items.
|
||||||
Auth Hook for generating Side Menu Items
|
|
||||||
"""
|
|
||||||
def __init__(self, text: str, classes: str, url_name: str, order: Optional[int] = None, navactive: List = []):
|
|
||||||
"""
|
|
||||||
:param text: The text shown as menu item, e.g. usually the name of the app.
|
|
||||||
:type text: str
|
|
||||||
:param classes: The classes that should be applied to the menu item icon
|
|
||||||
:type classes: List[str]
|
|
||||||
:param url_name: The name of the Django URL to use
|
|
||||||
:type url_name: str
|
|
||||||
:param order: An integer which specifies the order of the menu item, lowest to highest. Community apps are free to use any order above `1000`. Numbers below are served for Auth.
|
|
||||||
:type order: Optional[int], optional
|
|
||||||
:param navactive: A list of views or namespaces the link should be highlighted on. See [django-navhelper](https://github.com/geelweb/django-navhelper#navactive) for usage. Defaults to the supplied `url_name`.
|
|
||||||
:type navactive: List, optional
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- text: The text shown as menu item, e.g. usually the name of the app.
|
||||||
|
- classes: The classes that should be applied to the menu item icon
|
||||||
|
- url_name: The name of the Django URL to use
|
||||||
|
- order: An integer which specifies the order of the menu item,
|
||||||
|
lowest to highest. Community apps are free to use any order above `1000`.
|
||||||
|
Numbers below are served for Auth.
|
||||||
|
- A list of views or namespaces the link should be highlighted on.
|
||||||
|
See 3rd party package django-navhelper for usage.
|
||||||
|
Defaults to the supplied `url_name`.
|
||||||
|
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
- count is an integer shown next to the menu item as badge when is is not `None`.
|
||||||
|
Apps need to set the count in their child class, e.g. in `render()` method
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
classes: str,
|
||||||
|
url_name: str,
|
||||||
|
order: Optional[int] = None,
|
||||||
|
navactive: Optional[List[str]] = None,
|
||||||
|
):
|
||||||
self.text = text
|
self.text = text
|
||||||
self.classes = classes
|
self.classes = classes
|
||||||
self.url_name = url_name
|
self.url_name = url_name
|
||||||
self.template = 'public/menuitem.html'
|
self.template = "public/menuitem.html"
|
||||||
self.order = order if order is not None else 9999
|
self.order = order if order is not None else DEFAULT_MENU_ITEM_ORDER
|
||||||
|
|
||||||
# count is an integer shown next to the menu item as badge when count != None
|
|
||||||
# apps need to set the count in their child class, e.g. in render() method
|
|
||||||
self.count = None
|
self.count = None
|
||||||
|
|
||||||
navactive = navactive or []
|
navactive = navactive or []
|
||||||
navactive.append(url_name)
|
navactive.append(url_name)
|
||||||
self.navactive = navactive
|
self.navactive = navactive
|
||||||
|
|
||||||
def render(self, request):
|
def __str__(self) -> str:
|
||||||
return render_to_string(self.template,
|
return self.text
|
||||||
{'item': self},
|
|
||||||
request=request)
|
def __repr__(self) -> str:
|
||||||
|
return f'{self.__class__.__name__}(text="{self.text}")'
|
||||||
|
|
||||||
|
def render(self, request) -> str:
|
||||||
|
"""Render this menu item and return resulting HTML."""
|
||||||
|
return render_to_string(self.template, {"item": self}, request=request)
|
||||||
|
67
allianceauth/menu/managers.py
Normal file
67
allianceauth/menu/managers.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models import Case, Q, Value, When
|
||||||
|
|
||||||
|
from allianceauth.hooks import get_hooks
|
||||||
|
|
||||||
|
from .constants import MenuItemType
|
||||||
|
from .core.menu_item_hooks import MenuItemHookParams, gather_params
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .models import MenuItem
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MenuItemQuerySet(models.QuerySet):
|
||||||
|
def filter_folders(self):
|
||||||
|
"""Add filter to include folders only."""
|
||||||
|
return self.filter(hook_hash__isnull=True, url="")
|
||||||
|
|
||||||
|
def annotate_item_type_2(self):
|
||||||
|
"""Add calculated field with item type."""
|
||||||
|
return self.annotate(
|
||||||
|
item_type_2=Case(
|
||||||
|
When(~Q(hook_hash__isnull=True), then=Value(MenuItemType.APP.value)),
|
||||||
|
When(url="", then=Value(MenuItemType.FOLDER.value)),
|
||||||
|
default=Value(MenuItemType.LINK.value),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MenuItemManagerBase(models.Manager):
|
||||||
|
def sync_all(self):
|
||||||
|
"""Sync all menu items from hooks."""
|
||||||
|
hook_params = self._gather_menu_item_hook_params()
|
||||||
|
self._delete_obsolete_app_items(hook_params)
|
||||||
|
self._update_or_create_app_items(hook_params)
|
||||||
|
|
||||||
|
def _gather_menu_item_hook_params(self) -> list[MenuItemHookParams]:
|
||||||
|
params = [gather_params(hook()) for hook in get_hooks("menu_item_hook")]
|
||||||
|
return params
|
||||||
|
|
||||||
|
def _delete_obsolete_app_items(self, params: list[MenuItemHookParams]):
|
||||||
|
hashes = [obj.hash for obj in params]
|
||||||
|
self.exclude(hook_hash__isnull=True).exclude(hook_hash__in=hashes).delete()
|
||||||
|
|
||||||
|
def _update_or_create_app_items(self, params: list[MenuItemHookParams]):
|
||||||
|
for param in params:
|
||||||
|
try:
|
||||||
|
obj: MenuItem = self.get(hook_hash=param.hash)
|
||||||
|
except self.model.DoesNotExist:
|
||||||
|
self.create(hook_hash=param.hash, order=param.order, text=param.text)
|
||||||
|
else:
|
||||||
|
# if it exists update the text only
|
||||||
|
if obj.text != param.text:
|
||||||
|
obj.text = param.text
|
||||||
|
obj.save()
|
||||||
|
|
||||||
|
logger.debug("Updated menu items from %d menu item hooks", len(params))
|
||||||
|
|
||||||
|
|
||||||
|
MenuItemManager = MenuItemManagerBase.from_queryset(MenuItemQuerySet)
|
@ -1,15 +0,0 @@
|
|||||||
from django.utils.deprecation import MiddlewareMixin
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from allianceauth.menu.providers import menu_provider
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class MenuSyncMiddleware(MiddlewareMixin):
|
|
||||||
|
|
||||||
def __call__(self, request):
|
|
||||||
"""Alliance Auth Menu Sync Middleware"""
|
|
||||||
menu_provider.check_and_sync_menu()
|
|
||||||
return super().__call__(request)
|
|
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 4.0.2 on 2022-08-28 14:00
|
# Generated by Django 4.2.9 on 2024-02-15 00:01
|
||||||
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
@ -8,21 +8,88 @@ class Migration(migrations.Migration):
|
|||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = []
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='MenuItem',
|
name="MenuItem",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('hook_function', models.CharField(max_length=500)),
|
"id",
|
||||||
('icon_classes', models.CharField(max_length=150)),
|
models.AutoField(
|
||||||
('text', models.CharField(max_length=150)),
|
auto_created=True,
|
||||||
('url', models.CharField(blank=True, default=None, max_length=2048, null=True)),
|
primary_key=True,
|
||||||
('rank', models.IntegerField(default=1000)),
|
serialize=False,
|
||||||
('hide', models.BooleanField(default=False)),
|
verbose_name="ID",
|
||||||
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='menu.menuitem')),
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"text",
|
||||||
|
models.CharField(
|
||||||
|
db_index=True,
|
||||||
|
help_text="Text to show on menu",
|
||||||
|
max_length=150,
|
||||||
|
verbose_name="text",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"order",
|
||||||
|
models.IntegerField(
|
||||||
|
db_index=True,
|
||||||
|
default=9999,
|
||||||
|
help_text="Order of the menu. Lowest First",
|
||||||
|
verbose_name="order",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_hidden",
|
||||||
|
models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Hide this menu item.If this item is a folder all items under it will be hidden too",
|
||||||
|
verbose_name="is hidden",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"hook_hash",
|
||||||
|
models.CharField(
|
||||||
|
default=None,
|
||||||
|
editable=False,
|
||||||
|
max_length=64,
|
||||||
|
null=True,
|
||||||
|
unique=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"classes",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
help_text="Font Awesome classes to show as icon on menu, e.g. <code>fa-solid fa-house</code>",
|
||||||
|
max_length=150,
|
||||||
|
verbose_name="icon classes",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"url",
|
||||||
|
models.TextField(
|
||||||
|
default="",
|
||||||
|
help_text="External URL this menu items will link to",
|
||||||
|
verbose_name="url",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"parent",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
default=None,
|
||||||
|
help_text="Folder this item is in (optional)",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="children",
|
||||||
|
to="menu.menuitem",
|
||||||
|
verbose_name="folder",
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
@ -1,28 +0,0 @@
|
|||||||
# Generated by Django 4.0.2 on 2022-08-28 14:10
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('menu', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='menuitem',
|
|
||||||
name='hook_function',
|
|
||||||
field=models.CharField(blank=True, default=None, max_length=500, null=True),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='menuitem',
|
|
||||||
name='icon_classes',
|
|
||||||
field=models.CharField(blank=True, default=None, max_length=150, null=True),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='menuitem',
|
|
||||||
name='text',
|
|
||||||
field=models.CharField(blank=True, default=None, max_length=150, null=True),
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,17 +0,0 @@
|
|||||||
# Generated by Django 4.0.8 on 2023-02-05 07:34
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('menu', '0002_alter_menuitem_hook_function_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='menuitem',
|
|
||||||
index=models.Index(fields=['rank'], name='menu_menuit_rank_e880ab_idx'),
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,39 +0,0 @@
|
|||||||
# Generated by Django 4.0.10 on 2023-07-16 11:41
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
import django.db.models.deletion
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('menu', '0003_menuitem_menu_menuit_rank_e880ab_idx'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='menuitem',
|
|
||||||
name='hide',
|
|
||||||
field=models.BooleanField(default=False, help_text='Hide this menu item. If this item is a header all items under it will be hidden too.'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='menuitem',
|
|
||||||
name='icon_classes',
|
|
||||||
field=models.CharField(blank=True, default=None, help_text='Font Awesome classes to show as icon on menu', max_length=150, null=True),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='menuitem',
|
|
||||||
name='parent',
|
|
||||||
field=models.ForeignKey(blank=True, help_text='Parent Header. (Optional)', null=True, on_delete=django.db.models.deletion.SET_NULL, to='menu.menuitem'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='menuitem',
|
|
||||||
name='rank',
|
|
||||||
field=models.IntegerField(default=1000, help_text='Order of the menu. Lowest First.'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='menuitem',
|
|
||||||
name='text',
|
|
||||||
field=models.CharField(blank=True, default=None, help_text='Text to show on menu', max_length=150, null=True),
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,174 +1,132 @@
|
|||||||
import logging
|
|
||||||
from allianceauth.hooks import get_hooks
|
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.template.loader import render_to_string
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
from allianceauth.menu.constants import DEFAULT_FOLDER_ICON_CLASSES
|
||||||
|
|
||||||
|
from .constants import DEFAULT_MENU_ITEM_ORDER, MenuItemType
|
||||||
|
from .core.menu_item_hooks import MenuItemHookCustom
|
||||||
|
from .managers import MenuItemManager
|
||||||
|
|
||||||
|
|
||||||
class MenuItem(models.Model):
|
class MenuItem(models.Model):
|
||||||
# Auto Generated model from an auth_hook
|
"""An item in the sidebar menu.
|
||||||
hook_function = models.CharField(
|
|
||||||
max_length=500, default=None, null=True, blank=True)
|
Some of these objects are generated from `MenuItemHook` objects.
|
||||||
|
To avoid confusion we are using the same same field names.user defined
|
||||||
|
"""
|
||||||
|
|
||||||
# User Made Model
|
|
||||||
icon_classes = models.CharField(
|
|
||||||
max_length=150, default=None, null=True, blank=True, help_text="Font Awesome classes to show as icon on menu")
|
|
||||||
text = models.CharField(
|
text = models.CharField(
|
||||||
max_length=150, default=None, null=True, blank=True, help_text="Text to show on menu")
|
max_length=150,
|
||||||
url = models.CharField(max_length=2048, default=None,
|
db_index=True,
|
||||||
null=True, blank=True)
|
verbose_name=_("text"),
|
||||||
|
help_text=_("Text to show on menu"),
|
||||||
# Put it under a header?
|
)
|
||||||
|
order = models.IntegerField(
|
||||||
|
default=DEFAULT_MENU_ITEM_ORDER,
|
||||||
|
db_index=True,
|
||||||
|
verbose_name=_("order"),
|
||||||
|
help_text=_("Order of the menu. Lowest First"),
|
||||||
|
)
|
||||||
parent = models.ForeignKey(
|
parent = models.ForeignKey(
|
||||||
'self', on_delete=models.SET_NULL, null=True, blank=True, help_text="Parent Header. (Optional)")
|
"self",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
default=None,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="children",
|
||||||
|
verbose_name=_("folder"),
|
||||||
|
help_text=_("Folder this item is in (optional)"),
|
||||||
|
)
|
||||||
|
is_hidden = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name=_("is hidden"),
|
||||||
|
help_text=_(
|
||||||
|
"Hide this menu item."
|
||||||
|
"If this item is a folder all items under it will be hidden too"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# Put it where? lowest first
|
# app related properties
|
||||||
rank = models.IntegerField(default=1000, help_text="Order of the menu. Lowest First.")
|
hook_hash = models.CharField(
|
||||||
|
max_length=64, default=None, null=True, unique=True, editable=False
|
||||||
|
) # hash of a menu item hook. Must be nullable for unique comparison.
|
||||||
|
|
||||||
# Hide it fully? Hiding a parent will hide all it's children
|
# user defined properties
|
||||||
hide = models.BooleanField(default=False, help_text="Hide this menu item. If this item is a header all items under it will be hidden too.")
|
classes = models.CharField(
|
||||||
|
max_length=150,
|
||||||
|
default="",
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_("icon classes"),
|
||||||
|
help_text=_(
|
||||||
|
"Font Awesome classes to show as icon on menu, "
|
||||||
|
"e.g. <code>fa-solid fa-house</code>"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
url = models.TextField(
|
||||||
|
default="",
|
||||||
|
verbose_name=_("url"),
|
||||||
|
help_text=_("External URL this menu items will link to"),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
objects = MenuItemManager()
|
||||||
indexes = [
|
|
||||||
models.Index(fields=['rank', ]),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return self.text
|
return self.text
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.hook_hash:
|
||||||
|
self.hook_hash = None # empty strings can create problems
|
||||||
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def classes(self): # Helper function to make this model closer to the hook functions
|
def item_type(self) -> MenuItemType:
|
||||||
return self.icon_classes
|
"""Return the type of this menu item."""
|
||||||
|
if self.hook_hash:
|
||||||
|
return MenuItemType.APP
|
||||||
|
|
||||||
@staticmethod
|
if not self.url:
|
||||||
def hook_to_name(mh):
|
return MenuItemType.FOLDER
|
||||||
return f"{mh.__class__.__module__}.{mh.__class__.__name__}"
|
|
||||||
|
|
||||||
@staticmethod
|
return MenuItemType.LINK
|
||||||
def sync_hook_models():
|
|
||||||
# TODO define aa way for hooks to predefine a "parent" to create a sub menu from modules
|
|
||||||
menu_hooks = get_hooks('menu_item_hook')
|
|
||||||
hook_functions = []
|
|
||||||
for hook in menu_hooks:
|
|
||||||
mh = hook()
|
|
||||||
cls = MenuItem.hook_to_name(mh)
|
|
||||||
try:
|
|
||||||
# if it exists update the text only
|
|
||||||
# Users can adjust ranks so lets not change it if they have.
|
|
||||||
mi = MenuItem.objects.get(hook_function=cls)
|
|
||||||
mi.text = getattr(mh, "text", mh.__class__.__name__)
|
|
||||||
mi.save()
|
|
||||||
except MenuItem.DoesNotExist:
|
|
||||||
# This is a new hook, Make the database model.
|
|
||||||
MenuItem.objects.create(
|
|
||||||
hook_function=cls,
|
|
||||||
rank=getattr(mh, "order", 500),
|
|
||||||
text=getattr(mh, "text", mh.__class__.__name__)
|
|
||||||
)
|
|
||||||
hook_functions.append(cls)
|
|
||||||
|
|
||||||
# Get rid of any legacy hooks from modules removed
|
@property
|
||||||
MenuItem.objects.filter(hook_function__isnull=False).exclude(
|
def is_app_item(self) -> bool:
|
||||||
hook_function__in=hook_functions).delete()
|
"""Return True if this is an app item, else False."""
|
||||||
|
return self.item_type is MenuItemType.APP
|
||||||
|
|
||||||
@classmethod
|
@property
|
||||||
def filter_items(cls, menu_item: dict):
|
def is_child(self) -> bool:
|
||||||
"""
|
"""Return True if this item is a child, else False."""
|
||||||
filter any items with no valid children from a menu
|
return bool(self.parent_id)
|
||||||
"""
|
|
||||||
count_items = len(menu_item['items'])
|
|
||||||
if count_items: # if we have children confirm we can see them
|
|
||||||
for i in menu_item['items']:
|
|
||||||
if len(i['render']) == 0:
|
|
||||||
count_items -= 1
|
|
||||||
if count_items == 0: # no children left dont render header
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
|
|
||||||
@classmethod
|
@property
|
||||||
def render_menu(cls, request):
|
def is_folder(self) -> bool:
|
||||||
"""
|
"""Return True if this item is a folder, else False."""
|
||||||
Return the sorted side menu items with any items the user can't see removed.
|
return self.item_type is MenuItemType.FOLDER
|
||||||
"""
|
|
||||||
# Override all the items to the bs5 theme
|
|
||||||
template = "menu/menu-item-bs5.html"
|
|
||||||
# TODO discuss permissions for user defined links
|
|
||||||
|
|
||||||
# Turn all the hooks into functions
|
@property
|
||||||
menu_hooks = get_hooks('menu_item_hook')
|
def is_link_item(self) -> bool:
|
||||||
items = {}
|
"""Return True if this item is a link item, else False."""
|
||||||
for fn in menu_hooks:
|
return self.item_type is MenuItemType.LINK
|
||||||
f = fn()
|
|
||||||
items[cls.hook_to_name(f)] = f
|
|
||||||
|
|
||||||
menu_items = MenuItem.objects.all().order_by("rank")
|
@property
|
||||||
|
def is_user_defined(self) -> bool:
|
||||||
|
"""Return True if this item is user defined."""
|
||||||
|
return self.item_type is not MenuItemType.APP
|
||||||
|
|
||||||
menu = {}
|
def to_hook_obj(self) -> MenuItemHookCustom:
|
||||||
for mi in menu_items:
|
"""Convert to hook object for rendering."""
|
||||||
if mi.hide:
|
if self.is_app_item:
|
||||||
# hidden item, skip it completely
|
raise ValueError("The related hook objects should be used for app items.")
|
||||||
continue
|
|
||||||
try:
|
|
||||||
_cnt = 0
|
|
||||||
_render = None
|
|
||||||
if mi.hook_function:
|
|
||||||
# This is a module hook, so we need to render it as the developer intended
|
|
||||||
# TODO add a new attribute for apps that want to override it in the new theme
|
|
||||||
items[mi.hook_function].template = template
|
|
||||||
_render = items[mi.hook_function].render(request)
|
|
||||||
_cnt = items[mi.hook_function].count
|
|
||||||
else:
|
|
||||||
# This is a user defined menu item so we render it with defaults.
|
|
||||||
_render = render_to_string(template,
|
|
||||||
{'item': mi},
|
|
||||||
request=request)
|
|
||||||
|
|
||||||
parent = mi.id
|
hook_obj = MenuItemHookCustom(
|
||||||
if mi.parent_id: # Set it if present
|
text=self.text, classes=self.classes, url_name="", order=self.order
|
||||||
parent = mi.parent_id
|
)
|
||||||
|
hook_obj.navactive = []
|
||||||
|
if self.is_folder and not self.classes:
|
||||||
|
hook_obj.classes = DEFAULT_FOLDER_ICON_CLASSES
|
||||||
|
|
||||||
if parent not in menu: # this will cause the menu headers to be out of order
|
hook_obj.url = self.url
|
||||||
menu[parent] = {"items": [],
|
hook_obj.is_folder = self.is_folder
|
||||||
"count": 0,
|
hook_obj.html_id = f"id-folder-{self.id}" if self.is_folder else ""
|
||||||
"render": None,
|
return hook_obj
|
||||||
"text": "None",
|
|
||||||
"rank": 9999,
|
|
||||||
}
|
|
||||||
_mi = {
|
|
||||||
"count": _cnt,
|
|
||||||
"render": _render,
|
|
||||||
"text": mi.text,
|
|
||||||
"rank": mi.rank,
|
|
||||||
"classes": (mi.icon_classes if mi.icon_classes != "" else "fa-solid fa-folder"),
|
|
||||||
"hide": mi.hide
|
|
||||||
}
|
|
||||||
|
|
||||||
if parent != mi.id:
|
|
||||||
# this is a sub item
|
|
||||||
menu[parent]["items"].append(_mi)
|
|
||||||
if _cnt:
|
|
||||||
#add its count to the header count
|
|
||||||
menu[parent]["count"] += _cnt
|
|
||||||
else:
|
|
||||||
if len(menu[parent]["items"]):
|
|
||||||
# this is a top folder dont update the count.
|
|
||||||
del(_mi["count"])
|
|
||||||
menu[parent].update(_mi)
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(e)
|
|
||||||
|
|
||||||
# reset to list
|
|
||||||
menu = list(menu.values())
|
|
||||||
|
|
||||||
# sort the menu list as the parents may be out of order.
|
|
||||||
menu.sort(key=lambda i: i['rank'])
|
|
||||||
|
|
||||||
# ensure no empty groups
|
|
||||||
menu = filter(cls.filter_items, menu)
|
|
||||||
|
|
||||||
return menu
|
|
||||||
|
@ -1,49 +0,0 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
from django.core.cache import cache
|
|
||||||
|
|
||||||
from allianceauth.menu.models import MenuItem
|
|
||||||
from allianceauth.utils.django import StartupCommand
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
MENU_SYNC_CACHE_KEY = "ALLIANCEAUTH-MENU-SYNCED"
|
|
||||||
MENU_CACHE_KEY = "ALLIANCEAUTH-MENU-CACHE"
|
|
||||||
|
|
||||||
|
|
||||||
class MenuProvider():
|
|
||||||
|
|
||||||
def clear_synced_flag(self) -> bool:
|
|
||||||
return cache.delete(MENU_SYNC_CACHE_KEY)
|
|
||||||
|
|
||||||
def set_synced_flag(self) -> bool:
|
|
||||||
return cache.set(MENU_SYNC_CACHE_KEY, True)
|
|
||||||
|
|
||||||
def get_synced_flag(self) -> bool:
|
|
||||||
return cache.get(MENU_SYNC_CACHE_KEY, False)
|
|
||||||
|
|
||||||
def sync_menu_models(self):
|
|
||||||
MenuItem.sync_hook_models()
|
|
||||||
self.set_synced_flag()
|
|
||||||
|
|
||||||
def check_and_sync_menu(self) -> None:
|
|
||||||
if self.get_synced_flag():
|
|
||||||
# performance hit to each page view to ensure tests work.
|
|
||||||
# tests clear DB but not cache.
|
|
||||||
# TODO rethink all of this?
|
|
||||||
if MenuItem.objects.all().count() > 0:
|
|
||||||
logger.debug("Menu Hooks Synced")
|
|
||||||
else:
|
|
||||||
self.sync_menu_models()
|
|
||||||
else:
|
|
||||||
logger.debug("Syncing Menu Hooks")
|
|
||||||
self.sync_menu_models()
|
|
||||||
|
|
||||||
def get_and_cache_menu(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def clear_menu_cache(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
menu_provider = MenuProvider()
|
|
@ -0,0 +1,13 @@
|
|||||||
|
{% extends "admin/change_list.html" %}
|
||||||
|
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block object-tools-items %}
|
||||||
|
|
||||||
|
{{ block.super }}
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'admin:menu_menuitem_add' %}?type={{ folder_type }}" class="addlink">
|
||||||
|
{% translate "Add folder" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endblock %}
|
@ -1,7 +1,3 @@
|
|||||||
{% for data in menu_items %}
|
{% for item in menu_items %}
|
||||||
{% if data.items|length > 0 %}
|
{{ item.html }}
|
||||||
{% include "menu/menu-item-bs5.html" with item=data %}
|
|
||||||
{% else %}
|
|
||||||
{{ data.render }}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -1,40 +1,57 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load navactive %}
|
{% load navactive %}
|
||||||
|
|
||||||
{% if not item.hide %}
|
|
||||||
<li class="d-flex flex-wrap m-2 p-2 pt-0 pb-0 mt-0 mb-0 me-0 pe-0">
|
|
||||||
<i class="nav-link {{ item.classes }} fa-fw align-self-center me-3 {% if item.navactive %}{% navactive request item.navactive|join:' ' %}{% endif %}" {% if item.items|length %} type="button" data-bs-toggle="collapse" data-bs-target="#id-{{ item.text|slugify }}" aria-expanded="false" aria-controls="" {% endif %}></i>
|
|
||||||
<a class="nav-link flex-fill align-self-center" {% if item.items|length %} type="button" data-bs-toggle="collapse" data-bs-target="#id-{{ item.text|slugify }}" aria-expanded="false" aria-controls="" {% endif %}
|
|
||||||
href="{% if item.url_name %}{% url item.url_name %}{% else %}{{ item.url }}{% endif %}">
|
|
||||||
{% translate item.text %}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{% if item.count >= 1 %}
|
<li class="d-flex flex-wrap m-2 p-2 pt-0 pb-0 mt-0 mb-0 me-0 pe-0">
|
||||||
<span class="badge bg-primary m-2 align-self-center {% if item.items|length == 0 %}me-2{% endif %}">
|
<i
|
||||||
{{ item.count }}
|
class="nav-link {{ item.classes }} fa-fw align-self-center me-3 {% if item.navactive %}{% navactive request item.navactive|join:' ' %}{% endif %}"
|
||||||
</span>
|
{% if item.is_folder %}
|
||||||
{% elif item.url %}
|
type="button"
|
||||||
<span class="pill m-2 me-2 align-self-center fas fa-external-link-alt" title="{% translate 'External link' %}"></span>
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#{{ item.html_id }}"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls=""
|
||||||
|
{% endif %}>
|
||||||
|
</i>
|
||||||
|
<a
|
||||||
|
class="nav-link flex-fill align-self-center me-auto"
|
||||||
|
{% if item.is_folder %}
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#{{ item.html_id }}"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls=""
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
href="{% if item.url_name %}{% url item.url_name %}{% else %}{{ item.url }}{% endif %}">
|
||||||
|
{% translate item.text %}
|
||||||
|
</a>
|
||||||
|
|
||||||
{% if item.items|length > 0 %}
|
{% if item.count >= 1 %}
|
||||||
<span
|
<span class="badge bg-primary m-2 align-self-center{% if not item.is_folder %} me-2{% endif %}">
|
||||||
class="pill m-2 me-2 align-self-center collapsed"
|
{{ item.count }}
|
||||||
type="button"
|
</span>
|
||||||
data-bs-toggle="collapse"
|
{% elif item.url %}
|
||||||
data-bs-target="#id-{{ item.text|slugify }}"
|
<span class="pill m-2 me-4 align-self-center fas fa-external-link-alt"></span>
|
||||||
aria-expanded="false"
|
{% endif %}
|
||||||
aria-controls=""
|
|
||||||
>
|
{% if item.is_folder %}
|
||||||
<i class="fas fa-chevron-right"></i>
|
<span
|
||||||
<i class="fas fa-chevron-down"></i>
|
class="pill m-2 align-self-center collapsed"
|
||||||
</span>
|
type="button"
|
||||||
<!--<hr class="m-0 w-100">-->
|
data-bs-toggle="collapse"
|
||||||
<ul class="collapse ps-1 w-100 border-start rounded-start border-light border-3" id="id-{{ item.text|slugify }}">
|
data-bs-target="#{{ item.html_id }}"
|
||||||
{% for sub_item in item.items %}
|
aria-expanded="false"
|
||||||
{{ sub_item.render }}
|
aria-controls=""
|
||||||
{% endfor %}
|
>
|
||||||
</ul>
|
<i class="fas fa-chevron-right"></i>
|
||||||
{% endif %}
|
<i class="fas fa-chevron-down"></i>
|
||||||
</li>
|
</span>
|
||||||
{% endif %}
|
<ul
|
||||||
|
class="collapse ps-1 w-100 border-start rounded-start border-light border-3"
|
||||||
|
id="{{ item.html_id }}">
|
||||||
|
{% for sub_item in item.children %}
|
||||||
|
{{ sub_item }}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
@ -15,7 +15,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
{% sorted_menu_items %}
|
{% menu_items %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{% include 'menu/menu-logo.html' %}
|
{% include 'menu/menu-logo.html' %}
|
||||||
|
32
allianceauth/menu/templatetags/menu_items.py
Normal file
32
allianceauth/menu/templatetags/menu_items.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"""Template tags for rendering the classic side menu."""
|
||||||
|
|
||||||
|
from django import template
|
||||||
|
from django.http import HttpRequest
|
||||||
|
|
||||||
|
from allianceauth.hooks import get_hooks
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: Show user created menu items
|
||||||
|
# TODO: Apply is_hidden feature for BS3 type items
|
||||||
|
|
||||||
|
|
||||||
|
@register.inclusion_tag("public/menublock.html", takes_context=True)
|
||||||
|
def menu_items(context: dict) -> dict:
|
||||||
|
"""Render menu items for classic dashboard."""
|
||||||
|
items = render_menu(context["request"])
|
||||||
|
return {"menu_items": items}
|
||||||
|
|
||||||
|
|
||||||
|
def render_menu(request: HttpRequest):
|
||||||
|
"""Return the rendered side menu for including in a template.
|
||||||
|
|
||||||
|
This function is creating a BS3 style menu.
|
||||||
|
"""
|
||||||
|
|
||||||
|
hooks = get_hooks("menu_item_hook")
|
||||||
|
raw_items = [fn() for fn in hooks]
|
||||||
|
raw_items.sort(key=lambda i: i.order)
|
||||||
|
menu_items = [item.render(request) for item in raw_items]
|
||||||
|
return menu_items
|
@ -1,34 +1,174 @@
|
|||||||
|
"""Template tags for rendering the new side menu.
|
||||||
|
|
||||||
|
Documentation of the render logic
|
||||||
|
---------------------------------
|
||||||
|
|
||||||
|
The are 3 types of menu items:
|
||||||
|
|
||||||
|
- App entries: Generated by hooks from Django apps
|
||||||
|
- Link entries: Linking to external pages. User created.
|
||||||
|
- Folder: Grouping together several app or link entries. User created.
|
||||||
|
|
||||||
|
The MenuItem model holds the current list of all menu items.
|
||||||
|
|
||||||
|
App entries are linked to a `MenuItemHook` object in the respective Django app.
|
||||||
|
Those hook objects contain dynamic logic in a `render()` method,
|
||||||
|
which must be executed when rendering for the current request.
|
||||||
|
|
||||||
|
Since the same template must be used to render all items, link entries and folders
|
||||||
|
are converted to `MenuItemHookCustom` objects, a sub class of `MenuItemHook`.
|
||||||
|
This ensures the template only rendered objects of one specific type or sub-type.
|
||||||
|
|
||||||
|
The rendered menu items are finally collected in a list of RenderedMenuItem objects,
|
||||||
|
which is used to render the complete menu.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from django import template
|
from django import template
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
from django.http import HttpRequest
|
||||||
|
|
||||||
from allianceauth.hooks import get_hooks
|
from allianceauth.hooks import get_hooks
|
||||||
|
from allianceauth.menu.core import menu_item_hooks, smart_sync
|
||||||
from allianceauth.menu.models import MenuItem
|
from allianceauth.menu.models import MenuItem
|
||||||
|
from allianceauth.services.auth_hooks import MenuItemHook
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
def process_menu_items(hooks, request):
|
@register.inclusion_tag("menu/menu-block.html", takes_context=True)
|
||||||
_menu_items = list()
|
def menu_items(context: dict) -> dict:
|
||||||
items = [fn() for fn in hooks]
|
"""Render menu items for new dashboards."""
|
||||||
items.sort(key=lambda i: i.order)
|
smart_sync.sync_menu()
|
||||||
for item in items:
|
|
||||||
_menu_items.append(item.render(request))
|
items = render_menu(context["request"])
|
||||||
return _menu_items
|
return {"menu_items": items}
|
||||||
|
|
||||||
|
|
||||||
@register.inclusion_tag('public/menublock.html', takes_context=True)
|
@dataclass
|
||||||
def menu_items(context):
|
class RenderedMenuItem:
|
||||||
request = context['request']
|
"""A rendered menu item.
|
||||||
|
|
||||||
return {
|
These objects can be rendered with the menu-block template.
|
||||||
'menu_items': process_menu_items(get_hooks('menu_item_hook'), request),
|
"""
|
||||||
}
|
|
||||||
|
menu_item: MenuItem
|
||||||
|
|
||||||
|
children: List["RenderedMenuItem"] = field(default_factory=list)
|
||||||
|
count: Optional[int] = None
|
||||||
|
html: str = ""
|
||||||
|
html_id: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_folder(self) -> bool:
|
||||||
|
"""Return True if this item is a folder."""
|
||||||
|
return self.menu_item.is_folder
|
||||||
|
|
||||||
|
def update_html(self, request: HttpRequest, template: str):
|
||||||
|
"""Render this menu item with defaults and set HTML ID."""
|
||||||
|
hook_obj = self.menu_item.to_hook_obj()
|
||||||
|
hook_obj.template = template
|
||||||
|
hook_obj.count = self.count
|
||||||
|
if self.is_folder:
|
||||||
|
hook_obj.children = [child.html for child in self.children]
|
||||||
|
|
||||||
|
self.html = hook_obj.render(request)
|
||||||
|
self.html_id = hook_obj.html_id
|
||||||
|
|
||||||
|
|
||||||
@register.inclusion_tag('menu/menu-block.html', takes_context=True)
|
def render_menu(request: HttpRequest) -> List[RenderedMenuItem]:
|
||||||
def sorted_menu_items(context):
|
"""Return the rendered side menu for including in a template.
|
||||||
request = context['request']
|
|
||||||
menu_items = MenuItem.render_menu(request)
|
This function is creating BS5 style menus.
|
||||||
return {
|
"""
|
||||||
'menu_items':menu_items
|
hook_items = _gather_menu_items_from_hooks()
|
||||||
}
|
|
||||||
|
# Menu items needs to be rendered with the new BS5 template
|
||||||
|
bs5_template = "menu/menu-item-bs5.html"
|
||||||
|
|
||||||
|
rendered_items: Dict[int, RenderedMenuItem] = {}
|
||||||
|
menu_items: QuerySet[MenuItem] = MenuItem.objects.order_by(
|
||||||
|
"parent", "order", "text"
|
||||||
|
)
|
||||||
|
for item in menu_items:
|
||||||
|
if item.is_hidden:
|
||||||
|
continue # do not render hidden items
|
||||||
|
|
||||||
|
if item.is_app_item:
|
||||||
|
rendered_item = _render_app_item(request, hook_items, item, bs5_template)
|
||||||
|
elif item.is_link_item:
|
||||||
|
rendered_item = _render_link_item(request, item, bs5_template)
|
||||||
|
elif item.is_folder:
|
||||||
|
rendered_item = RenderedMenuItem(item) # we render these items later
|
||||||
|
else:
|
||||||
|
raise NotImplementedError("Unknown menu item type")
|
||||||
|
|
||||||
|
if item.is_child:
|
||||||
|
try:
|
||||||
|
parent = rendered_items[item.parent_id]
|
||||||
|
except KeyError:
|
||||||
|
continue # do not render children of hidden folders
|
||||||
|
|
||||||
|
parent.children.append(rendered_item)
|
||||||
|
if rendered_item.count is not None:
|
||||||
|
if parent.count is None:
|
||||||
|
parent.count = 0
|
||||||
|
parent.count += rendered_item.count
|
||||||
|
|
||||||
|
else:
|
||||||
|
rendered_items[item.id] = rendered_item
|
||||||
|
|
||||||
|
_remove_empty_folders(rendered_items)
|
||||||
|
|
||||||
|
_render_folder_items(request, rendered_items, bs5_template)
|
||||||
|
|
||||||
|
return list(rendered_items.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _gather_menu_items_from_hooks() -> Dict[str, MenuItemHook]:
|
||||||
|
hook_items = {}
|
||||||
|
for hook in get_hooks("menu_item_hook"):
|
||||||
|
f = hook()
|
||||||
|
hook_items[menu_item_hooks.generate_hash(f)] = f
|
||||||
|
return hook_items
|
||||||
|
|
||||||
|
|
||||||
|
def _render_app_item(
|
||||||
|
request: HttpRequest, hook_items: dict, item: MenuItem, new_template: str
|
||||||
|
) -> RenderedMenuItem:
|
||||||
|
# This is a module hook, so we need to render it as the developer intended
|
||||||
|
# TODO add a new attribute for apps that want to override it in the new theme
|
||||||
|
hook_item = hook_items[item.hook_hash]
|
||||||
|
hook_item.template = new_template
|
||||||
|
html = hook_item.render(request)
|
||||||
|
count = hook_item.count
|
||||||
|
rendered_item = RenderedMenuItem(menu_item=item, count=count, html=html)
|
||||||
|
return rendered_item
|
||||||
|
|
||||||
|
|
||||||
|
def _render_link_item(
|
||||||
|
request: HttpRequest, item: MenuItem, new_template: str
|
||||||
|
) -> RenderedMenuItem:
|
||||||
|
rendered_item = RenderedMenuItem(menu_item=item)
|
||||||
|
rendered_item.update_html(request, template=new_template)
|
||||||
|
return rendered_item
|
||||||
|
|
||||||
|
|
||||||
|
def _render_folder_items(
|
||||||
|
request: HttpRequest, rendered_items: Dict[int, RenderedMenuItem], new_template: str
|
||||||
|
):
|
||||||
|
for item in rendered_items.values():
|
||||||
|
if item.menu_item.is_folder:
|
||||||
|
item.update_html(request=request, template=new_template)
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_empty_folders(rendered_items: Dict[int, RenderedMenuItem]):
|
||||||
|
ids_to_remove = []
|
||||||
|
for item_id, item in rendered_items.items():
|
||||||
|
if item.is_folder and not item.children:
|
||||||
|
ids_to_remove.append(item_id)
|
||||||
|
|
||||||
|
for item_id in ids_to_remove:
|
||||||
|
del rendered_items[item_id]
|
||||||
|
0
allianceauth/menu/tests/__init__.py
Normal file
0
allianceauth/menu/tests/__init__.py
Normal file
0
allianceauth/menu/tests/core/__init__.py
Normal file
0
allianceauth/menu/tests/core/__init__.py
Normal file
63
allianceauth/menu/tests/core/test_menu_item_hooks.py
Normal file
63
allianceauth/menu/tests/core/test_menu_item_hooks.py
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from allianceauth.menu.core.menu_item_hooks import (
|
||||||
|
MenuItemHookCustom,
|
||||||
|
gather_params,
|
||||||
|
generate_hash,
|
||||||
|
)
|
||||||
|
from allianceauth.menu.tests.factories import create_menu_item_hook_function
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenerateHash(TestCase):
|
||||||
|
def test_should_generate_same_hash(self):
|
||||||
|
# given
|
||||||
|
hook = create_menu_item_hook_function()
|
||||||
|
|
||||||
|
# when
|
||||||
|
result_1 = generate_hash(hook())
|
||||||
|
result_2 = generate_hash(hook())
|
||||||
|
|
||||||
|
# then
|
||||||
|
self.assertIsInstance(result_1, str)
|
||||||
|
self.assertEqual(result_1, result_2)
|
||||||
|
|
||||||
|
def test_should_generate_different_hashes(self):
|
||||||
|
# given
|
||||||
|
hook_1 = create_menu_item_hook_function()
|
||||||
|
hook_2 = create_menu_item_hook_function()
|
||||||
|
|
||||||
|
# when
|
||||||
|
result_1 = generate_hash(hook_1())
|
||||||
|
result_2 = generate_hash(hook_2())
|
||||||
|
|
||||||
|
# then
|
||||||
|
self.assertNotEqual(result_1, result_2)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractParams(TestCase):
|
||||||
|
def test_should_return_params(self):
|
||||||
|
# given
|
||||||
|
hook = create_menu_item_hook_function(text="Alpha", order=42)
|
||||||
|
|
||||||
|
# when
|
||||||
|
result = gather_params(hook())
|
||||||
|
|
||||||
|
# then
|
||||||
|
self.assertEqual(result.text, "Alpha")
|
||||||
|
self.assertEqual(result.order, 42)
|
||||||
|
self.assertIsInstance(result.hash, str)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMenuItemHookCustom(TestCase):
|
||||||
|
def test_should_create_minimal(self):
|
||||||
|
# when
|
||||||
|
obj = MenuItemHookCustom(text="text", classes="classes", url_name="url_name")
|
||||||
|
|
||||||
|
# then
|
||||||
|
self.assertEqual(obj.text, "text")
|
||||||
|
self.assertEqual(obj.classes, "classes")
|
||||||
|
self.assertEqual(obj.url_name, "url_name")
|
||||||
|
self.assertEqual(obj.url, "")
|
||||||
|
self.assertIsNone(obj.is_folder)
|
||||||
|
self.assertEqual(obj.html_id, "")
|
||||||
|
self.assertListEqual(obj.children, [])
|
42
allianceauth/menu/tests/core/test_smart_sync.py
Normal file
42
allianceauth/menu/tests/core/test_smart_sync.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from allianceauth.menu.core import smart_sync
|
||||||
|
from allianceauth.menu.tests.factories import create_link_menu_item
|
||||||
|
from allianceauth.menu.tests.utils import PACKAGE_PATH
|
||||||
|
|
||||||
|
|
||||||
|
@patch(PACKAGE_PATH + ".models.MenuItem.objects.sync_all", spec=True)
|
||||||
|
class TestSmartSync(TestCase):
|
||||||
|
def test_should_sync_after_reset(self, mock_sync_all):
|
||||||
|
# given
|
||||||
|
smart_sync.reset_menu_items_sync()
|
||||||
|
mock_sync_all.reset_mock()
|
||||||
|
|
||||||
|
# when
|
||||||
|
smart_sync.sync_menu()
|
||||||
|
|
||||||
|
# then
|
||||||
|
self.assertTrue(mock_sync_all.called)
|
||||||
|
|
||||||
|
def test_should_sync_when_sync_flag_is_set_but_no_items_in_db(self, mock_sync_all):
|
||||||
|
# given
|
||||||
|
smart_sync._record_menu_was_synced()
|
||||||
|
|
||||||
|
# when
|
||||||
|
smart_sync.sync_menu()
|
||||||
|
|
||||||
|
# then
|
||||||
|
self.assertTrue(mock_sync_all.called)
|
||||||
|
|
||||||
|
def test_should_not_sync_when_sync_flag_is_set_and_items_in_db(self, mock_sync_all):
|
||||||
|
# given
|
||||||
|
smart_sync._record_menu_was_synced()
|
||||||
|
create_link_menu_item()
|
||||||
|
|
||||||
|
# when
|
||||||
|
smart_sync.sync_menu()
|
||||||
|
|
||||||
|
# then
|
||||||
|
self.assertFalse(mock_sync_all.called)
|
95
allianceauth/menu/tests/factories.py
Normal file
95
allianceauth/menu/tests/factories.py
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
from itertools import count
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
|
from allianceauth.menu.core import menu_item_hooks
|
||||||
|
from allianceauth.menu.models import MenuItem
|
||||||
|
from allianceauth.menu.templatetags.menu_menu_items import RenderedMenuItem
|
||||||
|
from allianceauth.services.auth_hooks import MenuItemHook
|
||||||
|
from allianceauth.tests.auth_utils import AuthUtils
|
||||||
|
|
||||||
|
|
||||||
|
def create_user(permissions=None, **kwargs) -> User:
|
||||||
|
num = next(counter_user)
|
||||||
|
params = {"username": f"test_user_{num}"}
|
||||||
|
params.update(kwargs)
|
||||||
|
user = User.objects.create(**params)
|
||||||
|
if permissions:
|
||||||
|
user = AuthUtils.add_permissions_to_user_by_name(perms=permissions, user=user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def create_menu_item_hook(**kwargs) -> MenuItemHook:
|
||||||
|
num = next(counter_menu_item_hook)
|
||||||
|
new_class = type(f"GeneratedMenuItem{num}", (MenuItemHook,), {})
|
||||||
|
|
||||||
|
count = kwargs.pop("count", None)
|
||||||
|
params = {
|
||||||
|
"text": f"Dummy App #{num}",
|
||||||
|
"classes": "fa-solid fa-users-gear",
|
||||||
|
"url_name": "groupmanagement:management",
|
||||||
|
}
|
||||||
|
params.update(kwargs)
|
||||||
|
obj = new_class(**params)
|
||||||
|
for key, value in params.items():
|
||||||
|
setattr(obj, key, value)
|
||||||
|
|
||||||
|
obj.count = count
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def create_menu_item_hook_function(**kwargs):
|
||||||
|
obj = create_menu_item_hook(**kwargs)
|
||||||
|
return lambda: obj
|
||||||
|
|
||||||
|
|
||||||
|
def create_link_menu_item(**kwargs) -> MenuItem:
|
||||||
|
num = next(counter_menu_item)
|
||||||
|
params = {
|
||||||
|
"url": f"https://www.example.com/{num}",
|
||||||
|
}
|
||||||
|
params.update(kwargs)
|
||||||
|
return _create_menu_item(**params)
|
||||||
|
|
||||||
|
|
||||||
|
def create_app_menu_item(**kwargs) -> MenuItem:
|
||||||
|
params = {"hook_hash": "hook_hash"}
|
||||||
|
params.update(kwargs)
|
||||||
|
return _create_menu_item(**params)
|
||||||
|
|
||||||
|
|
||||||
|
def create_folder_menu_item(**kwargs) -> MenuItem:
|
||||||
|
return _create_menu_item(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def create_menu_item_from_hook(hook, **kwargs) -> MenuItem:
|
||||||
|
item = hook()
|
||||||
|
hook_params = menu_item_hooks.gather_params(item)
|
||||||
|
params = {
|
||||||
|
"text": hook_params.text,
|
||||||
|
"hook_hash": hook_params.hash,
|
||||||
|
"order": hook_params.order,
|
||||||
|
}
|
||||||
|
params.update(kwargs)
|
||||||
|
return _create_menu_item(**params)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_menu_item(**kwargs) -> MenuItem:
|
||||||
|
num = next(counter_menu_item)
|
||||||
|
params = {
|
||||||
|
"text": f"text #{num}",
|
||||||
|
}
|
||||||
|
params.update(kwargs)
|
||||||
|
return MenuItem.objects.create(**params)
|
||||||
|
|
||||||
|
|
||||||
|
def create_rendered_menu_item(**kwargs) -> RenderedMenuItem:
|
||||||
|
if "menu_item" not in kwargs:
|
||||||
|
kwargs["menu_item"] = create_link_menu_item()
|
||||||
|
|
||||||
|
return RenderedMenuItem(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
counter_menu_item = count(1, 1)
|
||||||
|
counter_menu_item_hook = count(1, 1)
|
||||||
|
counter_user = count(1, 1)
|
0
allianceauth/menu/tests/integration/__init__.py
Normal file
0
allianceauth/menu/tests/integration/__init__.py
Normal file
178
allianceauth/menu/tests/integration/test_admin.py
Normal file
178
allianceauth/menu/tests/integration/test_admin.py
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from allianceauth.menu.constants import MenuItemType
|
||||||
|
from allianceauth.menu.forms import (
|
||||||
|
AppMenuItemAdminForm,
|
||||||
|
FolderMenuItemAdminForm,
|
||||||
|
LinkMenuItemAdminForm,
|
||||||
|
)
|
||||||
|
from allianceauth.menu.models import MenuItem
|
||||||
|
from allianceauth.menu.tests.factories import (
|
||||||
|
create_app_menu_item,
|
||||||
|
create_folder_menu_item,
|
||||||
|
create_link_menu_item,
|
||||||
|
create_user,
|
||||||
|
)
|
||||||
|
from allianceauth.menu.tests.utils import extract_html
|
||||||
|
|
||||||
|
|
||||||
|
def extract_menu_item_texts(response):
|
||||||
|
"""Extract labels of menu items shown in change list."""
|
||||||
|
soup = extract_html(response)
|
||||||
|
items = soup.find_all("th", {"class": "field-_text"})
|
||||||
|
labels = {elem.text for elem in items}
|
||||||
|
return labels
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdminSite(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
super().setUpClass()
|
||||||
|
cls.user = create_user(is_superuser=True, is_staff=True)
|
||||||
|
cls.changelist_url = reverse("admin:menu_menuitem_changelist")
|
||||||
|
cls.add_url = reverse("admin:menu_menuitem_add")
|
||||||
|
|
||||||
|
def change_url(self, id_):
|
||||||
|
return reverse("admin:menu_menuitem_change", args=[id_])
|
||||||
|
|
||||||
|
def test_changelist_should_show_all_types(self):
|
||||||
|
# given
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
create_app_menu_item(text="app")
|
||||||
|
create_folder_menu_item(text="folder")
|
||||||
|
create_link_menu_item(text="link")
|
||||||
|
|
||||||
|
# when
|
||||||
|
response = self.client.get(self.changelist_url)
|
||||||
|
|
||||||
|
# then
|
||||||
|
self.assertEqual(response.status_code, HTTPStatus.OK)
|
||||||
|
labels = extract_menu_item_texts(response)
|
||||||
|
self.assertSetEqual(labels, {"app", "[folder]", "link"})
|
||||||
|
|
||||||
|
def test_should_create_new_link_item(self):
|
||||||
|
# given
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
# when
|
||||||
|
response = self.client.post(
|
||||||
|
self.add_url,
|
||||||
|
{"text": "alpha", "url": "http://www.example.com", "order": 99},
|
||||||
|
)
|
||||||
|
# then
|
||||||
|
self.assertEqual(response.status_code, HTTPStatus.FOUND)
|
||||||
|
self.assertEqual(response.url, self.changelist_url)
|
||||||
|
self.assertEqual(MenuItem.objects.count(), 1)
|
||||||
|
obj = MenuItem.objects.first()
|
||||||
|
self.assertEqual(obj.text, "alpha")
|
||||||
|
self.assertEqual(obj.item_type, MenuItemType.LINK)
|
||||||
|
|
||||||
|
def test_should_create_new_folder_item(self):
|
||||||
|
# given
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
# when
|
||||||
|
response = self.client.post(
|
||||||
|
self.add_url + "?type=folder", {"text": "alpha", "order": 99}
|
||||||
|
)
|
||||||
|
# then
|
||||||
|
self.assertEqual(response.status_code, HTTPStatus.FOUND)
|
||||||
|
self.assertEqual(response.url, self.changelist_url)
|
||||||
|
self.assertEqual(MenuItem.objects.count(), 1)
|
||||||
|
obj = MenuItem.objects.first()
|
||||||
|
self.assertEqual(obj.text, "alpha")
|
||||||
|
self.assertEqual(obj.item_type, MenuItemType.FOLDER)
|
||||||
|
|
||||||
|
def test_should_change_app_item(self):
|
||||||
|
# given
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
item = create_app_menu_item(text="alpha", order=1)
|
||||||
|
form_data = AppMenuItemAdminForm(instance=item).initial
|
||||||
|
form_data["order"] = 99
|
||||||
|
form_data["parent"] = ""
|
||||||
|
|
||||||
|
# when
|
||||||
|
response = self.client.post(self.change_url(item.id), form_data)
|
||||||
|
|
||||||
|
# then
|
||||||
|
self.assertEqual(response.status_code, HTTPStatus.FOUND)
|
||||||
|
self.assertEqual(response.url, self.changelist_url)
|
||||||
|
self.assertEqual(MenuItem.objects.count(), 1)
|
||||||
|
obj = MenuItem.objects.first()
|
||||||
|
self.assertEqual(obj.order, 99)
|
||||||
|
self.assertEqual(obj.item_type, MenuItemType.APP)
|
||||||
|
|
||||||
|
def test_should_change_link_item(self):
|
||||||
|
# given
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
item = create_link_menu_item(text="alpha")
|
||||||
|
form_data = LinkMenuItemAdminForm(instance=item).initial
|
||||||
|
form_data["text"] = "bravo"
|
||||||
|
form_data["parent"] = ""
|
||||||
|
|
||||||
|
# when
|
||||||
|
response = self.client.post(self.change_url(item.id), form_data)
|
||||||
|
|
||||||
|
# then
|
||||||
|
self.assertEqual(response.status_code, HTTPStatus.FOUND)
|
||||||
|
self.assertEqual(response.url, self.changelist_url)
|
||||||
|
self.assertEqual(MenuItem.objects.count(), 1)
|
||||||
|
obj = MenuItem.objects.first()
|
||||||
|
self.assertEqual(obj.text, "bravo")
|
||||||
|
self.assertEqual(obj.item_type, MenuItemType.LINK)
|
||||||
|
|
||||||
|
def test_should_change_folder_item(self):
|
||||||
|
# given
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
item = create_folder_menu_item(text="alpha")
|
||||||
|
form_data = FolderMenuItemAdminForm(instance=item).initial
|
||||||
|
form_data["text"] = "bravo"
|
||||||
|
|
||||||
|
# when
|
||||||
|
response = self.client.post(self.change_url(item.id), form_data)
|
||||||
|
|
||||||
|
# then
|
||||||
|
self.assertEqual(response.status_code, HTTPStatus.FOUND)
|
||||||
|
self.assertEqual(response.url, self.changelist_url)
|
||||||
|
self.assertEqual(MenuItem.objects.count(), 1)
|
||||||
|
obj = MenuItem.objects.first()
|
||||||
|
self.assertEqual(obj.text, "bravo")
|
||||||
|
self.assertEqual(obj.item_type, MenuItemType.FOLDER)
|
||||||
|
|
||||||
|
def test_should_move_item_into_folder(self):
|
||||||
|
# given
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
link = create_link_menu_item(text="alpha")
|
||||||
|
folder = create_folder_menu_item(text="folder")
|
||||||
|
form_data = LinkMenuItemAdminForm(instance=link).initial
|
||||||
|
form_data["parent"] = folder.id
|
||||||
|
|
||||||
|
# when
|
||||||
|
response = self.client.post(self.change_url(link.id), form_data)
|
||||||
|
|
||||||
|
# then
|
||||||
|
self.assertEqual(response.status_code, HTTPStatus.FOUND)
|
||||||
|
self.assertEqual(response.url, self.changelist_url)
|
||||||
|
link.refresh_from_db()
|
||||||
|
self.assertEqual(link.parent, folder)
|
||||||
|
|
||||||
|
def test_should_filter_items_by_type(self):
|
||||||
|
# given
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
create_app_menu_item(text="app")
|
||||||
|
create_folder_menu_item(text="folder")
|
||||||
|
create_link_menu_item(text="link")
|
||||||
|
|
||||||
|
# when
|
||||||
|
cases = [("link", "link"), ("app", "app"), ("folder", "[folder]")]
|
||||||
|
for filter_name, expected_label in cases:
|
||||||
|
with self.subTest(filter_name=filter_name):
|
||||||
|
response = self.client.get(self.changelist_url + f"?type={filter_name}")
|
||||||
|
|
||||||
|
# then
|
||||||
|
self.assertEqual(response.status_code, HTTPStatus.OK)
|
||||||
|
labels = extract_menu_item_texts(response)
|
||||||
|
self.assertSetEqual(labels, {expected_label})
|
102
allianceauth/menu/tests/integration/test_dashboard.py
Normal file
102
allianceauth/menu/tests/integration/test_dashboard.py
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from allianceauth.menu.core.smart_sync import reset_menu_items_sync
|
||||||
|
from allianceauth.menu.tests.factories import (
|
||||||
|
create_folder_menu_item,
|
||||||
|
create_link_menu_item,
|
||||||
|
create_user,
|
||||||
|
)
|
||||||
|
from allianceauth.menu.tests.utils import extract_links
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefaultDashboardWithSideMenu(TestCase):
|
||||||
|
def test_should_show_all_types_of_menu_entries(self):
|
||||||
|
# given
|
||||||
|
user = create_user(permissions=["auth.group_management"])
|
||||||
|
self.client.force_login(user)
|
||||||
|
create_link_menu_item(text="Alpha", url="http://www.example.com/alpha")
|
||||||
|
folder = create_folder_menu_item(text="Folder")
|
||||||
|
create_link_menu_item(
|
||||||
|
text="Bravo", url="http://www.example.com/bravo", parent=folder
|
||||||
|
)
|
||||||
|
reset_menu_items_sync() # this simulates startup
|
||||||
|
|
||||||
|
# when
|
||||||
|
response = self.client.get("/dashboard/")
|
||||||
|
|
||||||
|
# then
|
||||||
|
self.assertEqual(response.status_code, HTTPStatus.OK)
|
||||||
|
links = extract_links(response)
|
||||||
|
# open_page_in_browser(response)
|
||||||
|
self.assertEqual(links["/dashboard/"], "Dashboard")
|
||||||
|
self.assertEqual(links["/groups/"], "Groups")
|
||||||
|
self.assertEqual(links["/groupmanagement/requests/"], "Group Management")
|
||||||
|
self.assertEqual(links["http://www.example.com/alpha"], "Alpha")
|
||||||
|
self.assertEqual(links["http://www.example.com/bravo"], "Bravo")
|
||||||
|
|
||||||
|
def test_should_not_show_menu_entry_when_user_has_no_permission(self):
|
||||||
|
# given
|
||||||
|
user = create_user()
|
||||||
|
self.client.force_login(user)
|
||||||
|
reset_menu_items_sync()
|
||||||
|
|
||||||
|
# when
|
||||||
|
response = self.client.get("/dashboard/")
|
||||||
|
|
||||||
|
# then
|
||||||
|
self.assertEqual(response.status_code, HTTPStatus.OK)
|
||||||
|
links = extract_links(response)
|
||||||
|
self.assertEqual(links["/dashboard/"], "Dashboard")
|
||||||
|
self.assertEqual(links["/groups/"], "Groups")
|
||||||
|
self.assertNotIn("/groupmanagement/requests/", links)
|
||||||
|
|
||||||
|
def test_should_not_show_menu_entry_when_hidden(self):
|
||||||
|
# given
|
||||||
|
user = create_user()
|
||||||
|
self.client.force_login(user)
|
||||||
|
create_link_menu_item(text="Alpha", url="http://www.example.com/")
|
||||||
|
reset_menu_items_sync()
|
||||||
|
|
||||||
|
# when
|
||||||
|
response = self.client.get("/dashboard/")
|
||||||
|
|
||||||
|
# then
|
||||||
|
self.assertEqual(response.status_code, HTTPStatus.OK)
|
||||||
|
links = extract_links(response)
|
||||||
|
self.assertEqual(links["/dashboard/"], "Dashboard")
|
||||||
|
self.assertEqual(links["/groups/"], "Groups")
|
||||||
|
self.assertNotIn("http://www.example.com/alpha", links)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBS3DashboardWithSideMenu(TestCase):
|
||||||
|
def test_should_not_show_group_management_when_user_has_no_permission(self):
|
||||||
|
# given
|
||||||
|
user = create_user()
|
||||||
|
self.client.force_login(user)
|
||||||
|
|
||||||
|
# when
|
||||||
|
response = self.client.get("/dashboard_bs3/")
|
||||||
|
|
||||||
|
# then
|
||||||
|
self.assertEqual(response.status_code, HTTPStatus.OK)
|
||||||
|
links = extract_links(response)
|
||||||
|
self.assertEqual(links["/dashboard/"], "Dashboard")
|
||||||
|
self.assertEqual(links["/groups/"], "Groups")
|
||||||
|
self.assertNotIn("/groupmanagement/requests/", links)
|
||||||
|
|
||||||
|
def test_should_show_group_management_when_user_has_permission(self):
|
||||||
|
# given
|
||||||
|
user = create_user(permissions=["auth.group_management"])
|
||||||
|
self.client.force_login(user)
|
||||||
|
|
||||||
|
# when
|
||||||
|
response = self.client.get("/dashboard_bs3/")
|
||||||
|
|
||||||
|
# then
|
||||||
|
self.assertEqual(response.status_code, HTTPStatus.OK)
|
||||||
|
links = extract_links(response)
|
||||||
|
self.assertEqual(links["/dashboard/"], "Dashboard")
|
||||||
|
self.assertEqual(links["/groups/"], "Groups")
|
||||||
|
self.assertEqual(links["/groupmanagement/requests/"], "Group Management")
|
0
allianceauth/menu/tests/templatetags/__init__.py
Normal file
0
allianceauth/menu/tests/templatetags/__init__.py
Normal file
54
allianceauth/menu/tests/templatetags/test_menu_items.py
Normal file
54
allianceauth/menu/tests/templatetags/test_menu_items.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.test import RequestFactory, TestCase
|
||||||
|
|
||||||
|
from allianceauth.menu.templatetags.menu_items import render_menu
|
||||||
|
from allianceauth.menu.tests.factories import create_menu_item_hook_function
|
||||||
|
from allianceauth.menu.tests.utils import PACKAGE_PATH, render_template
|
||||||
|
|
||||||
|
MODULE_PATH = PACKAGE_PATH + ".templatetags.menu_items"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTemplateTags(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
super().setUpClass()
|
||||||
|
cls.factory = RequestFactory()
|
||||||
|
|
||||||
|
@patch(MODULE_PATH + ".render_menu", spec=True)
|
||||||
|
def test_menu_items(self, mock_render_menu):
|
||||||
|
# given
|
||||||
|
mock_render_menu.return_value = ["Alpha"]
|
||||||
|
request = self.factory.get("/")
|
||||||
|
|
||||||
|
# when
|
||||||
|
rendered = render_template(
|
||||||
|
"{% load menu_items %}{% menu_items %}",
|
||||||
|
context={"request": request},
|
||||||
|
)
|
||||||
|
self.assertIn("Alpha", rendered)
|
||||||
|
|
||||||
|
|
||||||
|
@patch(MODULE_PATH + ".get_hooks", spec=True)
|
||||||
|
class TestRenderMenu(TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.factory = RequestFactory()
|
||||||
|
|
||||||
|
def test_should_render_menu_in_order(self, mock_get_hooks):
|
||||||
|
# given
|
||||||
|
mock_get_hooks.return_value = [
|
||||||
|
create_menu_item_hook_function(text="Charlie"),
|
||||||
|
create_menu_item_hook_function(text="Alpha", order=1),
|
||||||
|
create_menu_item_hook_function(text="Bravo", order=2),
|
||||||
|
]
|
||||||
|
request = self.factory.get("/")
|
||||||
|
|
||||||
|
# when
|
||||||
|
result = render_menu(request)
|
||||||
|
|
||||||
|
# then
|
||||||
|
menu = list(result)
|
||||||
|
self.assertEqual(len(menu), 3)
|
||||||
|
self.assertIn("Alpha", menu[0])
|
||||||
|
self.assertIn("Bravo", menu[1])
|
||||||
|
self.assertIn("Charlie", menu[2])
|
326
allianceauth/menu/tests/templatetags/test_menu_menu_items.py
Normal file
326
allianceauth/menu/tests/templatetags/test_menu_menu_items.py
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
from typing import List, NamedTuple, Optional
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
from django.test import RequestFactory, TestCase
|
||||||
|
|
||||||
|
from allianceauth.menu.templatetags.menu_menu_items import (
|
||||||
|
RenderedMenuItem,
|
||||||
|
render_menu,
|
||||||
|
)
|
||||||
|
from allianceauth.menu.tests.factories import (
|
||||||
|
create_app_menu_item,
|
||||||
|
create_folder_menu_item,
|
||||||
|
create_link_menu_item,
|
||||||
|
create_menu_item_from_hook,
|
||||||
|
create_menu_item_hook_function,
|
||||||
|
create_rendered_menu_item,
|
||||||
|
)
|
||||||
|
from allianceauth.menu.tests.utils import (
|
||||||
|
PACKAGE_PATH,
|
||||||
|
remove_whitespaces,
|
||||||
|
render_template,
|
||||||
|
)
|
||||||
|
|
||||||
|
MODULE_PATH = PACKAGE_PATH + ".templatetags.menu_menu_items"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTemplateTags(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
super().setUpClass()
|
||||||
|
cls.factory = RequestFactory()
|
||||||
|
|
||||||
|
@patch(MODULE_PATH + ".render_menu", spec=True)
|
||||||
|
@patch(MODULE_PATH + ".smart_sync.sync_menu", spec=True)
|
||||||
|
def test_sorted_menu_items(self, mock_sync_menu, mock_render_menu):
|
||||||
|
# given
|
||||||
|
fake_item = {"html": "Alpha"}
|
||||||
|
mock_render_menu.return_value = [fake_item]
|
||||||
|
request = self.factory.get("/")
|
||||||
|
|
||||||
|
# when
|
||||||
|
rendered = render_template(
|
||||||
|
"{% load menu_menu_items %}{% menu_items %}",
|
||||||
|
context={"request": request},
|
||||||
|
)
|
||||||
|
self.assertIn("Alpha", rendered)
|
||||||
|
self.assertTrue(mock_sync_menu.called)
|
||||||
|
|
||||||
|
|
||||||
|
@patch(MODULE_PATH + ".get_hooks", spec=True)
|
||||||
|
class TestRenderDefaultMenu(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
super().setUpClass()
|
||||||
|
cls.factory = RequestFactory()
|
||||||
|
|
||||||
|
def test_should_render_app_menu_items(self, mock_get_hooks):
|
||||||
|
# given
|
||||||
|
menu = [
|
||||||
|
create_menu_item_hook_function(text="Charlie", count=42),
|
||||||
|
create_menu_item_hook_function(text="Alpha", order=1),
|
||||||
|
create_menu_item_hook_function(text="Bravo", order=2),
|
||||||
|
]
|
||||||
|
mock_get_hooks.return_value = menu
|
||||||
|
for hook in menu:
|
||||||
|
create_menu_item_from_hook(hook)
|
||||||
|
|
||||||
|
request = self.factory.get("/")
|
||||||
|
|
||||||
|
# when
|
||||||
|
result = render_menu(request)
|
||||||
|
|
||||||
|
# then
|
||||||
|
menu = list(result)
|
||||||
|
self.assertEqual(len(menu), 3)
|
||||||
|
self.assertEqual(menu[0].menu_item.text, "Alpha")
|
||||||
|
self.assertEqual(menu[1].menu_item.text, "Bravo")
|
||||||
|
self.assertEqual(menu[2].menu_item.text, "Charlie")
|
||||||
|
self.assertEqual(menu[2].count, 42)
|
||||||
|
attrs = parse_html(menu[2])
|
||||||
|
self.assertEqual(attrs.count, 42)
|
||||||
|
self.assertEqual(attrs.text, "Charlie")
|
||||||
|
|
||||||
|
def test_should_render_link_menu_items(self, mock_get_hooks):
|
||||||
|
# given
|
||||||
|
mock_get_hooks.return_value = []
|
||||||
|
create_link_menu_item(text="Charlie"),
|
||||||
|
create_link_menu_item(text="Alpha", order=1),
|
||||||
|
create_link_menu_item(text="Bravo", order=2),
|
||||||
|
|
||||||
|
request = self.factory.get("/")
|
||||||
|
|
||||||
|
# when
|
||||||
|
result = render_menu(request)
|
||||||
|
|
||||||
|
# then
|
||||||
|
menu = list(result)
|
||||||
|
self.assertEqual(len(menu), 3)
|
||||||
|
self.assertEqual(menu[0].menu_item.text, "Alpha")
|
||||||
|
self.assertEqual(menu[1].menu_item.text, "Bravo")
|
||||||
|
self.assertEqual(menu[2].menu_item.text, "Charlie")
|
||||||
|
attrs = parse_html(menu[2])
|
||||||
|
self.assertEqual(attrs.text, "Charlie")
|
||||||
|
|
||||||
|
def test_should_render_folders(self, mock_get_hooks):
|
||||||
|
# given
|
||||||
|
mock_get_hooks.return_value = []
|
||||||
|
folder = create_folder_menu_item(text="Folder", order=2)
|
||||||
|
create_link_menu_item(text="Alpha", order=1)
|
||||||
|
create_link_menu_item(text="Bravo", order=3)
|
||||||
|
create_link_menu_item(text="Charlie", parent=folder)
|
||||||
|
|
||||||
|
request = self.factory.get("/")
|
||||||
|
|
||||||
|
# when
|
||||||
|
result = render_menu(request)
|
||||||
|
|
||||||
|
# then
|
||||||
|
menu = list(result)
|
||||||
|
self.assertEqual(len(menu), 3)
|
||||||
|
self.assertEqual(menu[0].menu_item.text, "Alpha")
|
||||||
|
self.assertEqual(menu[1].menu_item.text, "Folder")
|
||||||
|
self.assertEqual(menu[2].menu_item.text, "Bravo")
|
||||||
|
|
||||||
|
self.assertEqual(menu[1].children[0].menu_item.text, "Charlie")
|
||||||
|
attrs = parse_html(menu[1].children[0])
|
||||||
|
self.assertEqual(attrs.text, "Charlie")
|
||||||
|
|
||||||
|
def test_should_render_folder_properties(self, mock_get_hooks):
|
||||||
|
# given
|
||||||
|
# given
|
||||||
|
menu = [
|
||||||
|
create_menu_item_hook_function(text="Charlie", count=42),
|
||||||
|
create_menu_item_hook_function(text="Alpha", count=5),
|
||||||
|
create_menu_item_hook_function(text="Bravo"),
|
||||||
|
]
|
||||||
|
mock_get_hooks.return_value = menu
|
||||||
|
|
||||||
|
folder = create_folder_menu_item(text="Folder", order=1)
|
||||||
|
for hook in menu:
|
||||||
|
create_menu_item_from_hook(hook, parent=folder)
|
||||||
|
|
||||||
|
request = self.factory.get("/")
|
||||||
|
|
||||||
|
# when
|
||||||
|
result = render_menu(request)
|
||||||
|
|
||||||
|
# then
|
||||||
|
menu = list(result)
|
||||||
|
self.assertEqual(len(menu), 1)
|
||||||
|
item = menu[0]
|
||||||
|
self.assertEqual(item.menu_item.text, "Folder")
|
||||||
|
self.assertEqual(item.count, 47)
|
||||||
|
self.assertTrue(item.is_folder)
|
||||||
|
self.assertEqual(len(item.children), 3)
|
||||||
|
attrs = parse_html(item)
|
||||||
|
self.assertEqual(attrs.count, 47)
|
||||||
|
self.assertIn("fa-folder", attrs.classes)
|
||||||
|
|
||||||
|
def test_should_remove_empty_folders(self, mock_get_hooks):
|
||||||
|
# given
|
||||||
|
mock_get_hooks.return_value = []
|
||||||
|
create_folder_menu_item(text="Folder", order=2)
|
||||||
|
create_link_menu_item(text="Alpha", order=1)
|
||||||
|
create_link_menu_item(text="Bravo", order=3)
|
||||||
|
|
||||||
|
request = self.factory.get("/")
|
||||||
|
|
||||||
|
# when
|
||||||
|
result = render_menu(request)
|
||||||
|
|
||||||
|
# then
|
||||||
|
menu = list(result)
|
||||||
|
self.assertEqual(len(menu), 2)
|
||||||
|
self.assertEqual(menu[0].menu_item.text, "Alpha")
|
||||||
|
self.assertEqual(menu[1].menu_item.text, "Bravo")
|
||||||
|
|
||||||
|
def test_should_not_include_hidden_items(self, mock_get_hooks):
|
||||||
|
# given
|
||||||
|
mock_get_hooks.return_value = []
|
||||||
|
create_link_menu_item(text="Charlie"),
|
||||||
|
create_link_menu_item(text="Alpha", order=1),
|
||||||
|
create_link_menu_item(text="Bravo", order=2, is_hidden=True),
|
||||||
|
|
||||||
|
request = self.factory.get("/")
|
||||||
|
|
||||||
|
# when
|
||||||
|
result = render_menu(request)
|
||||||
|
|
||||||
|
# then
|
||||||
|
menu = list(result)
|
||||||
|
self.assertEqual(len(menu), 2)
|
||||||
|
self.assertEqual(menu[0].menu_item.text, "Alpha")
|
||||||
|
self.assertEqual(menu[1].menu_item.text, "Charlie")
|
||||||
|
|
||||||
|
def test_should_not_render_hidden_folders(self, mock_get_hooks):
|
||||||
|
# given
|
||||||
|
# given
|
||||||
|
menu = [
|
||||||
|
create_menu_item_hook_function(text="Charlie", count=42),
|
||||||
|
create_menu_item_hook_function(text="Alpha", count=5),
|
||||||
|
create_menu_item_hook_function(text="Bravo"),
|
||||||
|
]
|
||||||
|
mock_get_hooks.return_value = menu
|
||||||
|
|
||||||
|
folder = create_folder_menu_item(text="Folder", order=1, is_hidden=True)
|
||||||
|
for hook in menu:
|
||||||
|
create_menu_item_from_hook(hook, parent=folder)
|
||||||
|
|
||||||
|
request = self.factory.get("/")
|
||||||
|
|
||||||
|
# when
|
||||||
|
result = render_menu(request)
|
||||||
|
|
||||||
|
# then
|
||||||
|
menu = list(result)
|
||||||
|
self.assertEqual(len(menu), 0)
|
||||||
|
|
||||||
|
def test_should_allow_several_items_with_same_text(self, mock_get_hooks):
|
||||||
|
# given
|
||||||
|
mock_get_hooks.return_value = []
|
||||||
|
create_link_menu_item(text="Alpha", order=1),
|
||||||
|
create_link_menu_item(text="Alpha", order=2),
|
||||||
|
|
||||||
|
request = self.factory.get("/")
|
||||||
|
|
||||||
|
# when
|
||||||
|
result = render_menu(request)
|
||||||
|
|
||||||
|
# then
|
||||||
|
menu = list(result)
|
||||||
|
self.assertEqual(len(menu), 2)
|
||||||
|
self.assertEqual(menu[0].menu_item.text, "Alpha")
|
||||||
|
self.assertEqual(menu[1].menu_item.text, "Alpha")
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderedMenuItem(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
super().setUpClass()
|
||||||
|
cls.factory = RequestFactory()
|
||||||
|
cls.template = "menu/menu-item-bs5.html"
|
||||||
|
|
||||||
|
def test_create_from_menu_item_with_defaults(self):
|
||||||
|
# given
|
||||||
|
item = create_link_menu_item()
|
||||||
|
|
||||||
|
# when
|
||||||
|
obj = RenderedMenuItem(menu_item=item)
|
||||||
|
|
||||||
|
# then
|
||||||
|
self.assertEqual(obj.menu_item, item)
|
||||||
|
self.assertIsNone(obj.count)
|
||||||
|
self.assertEqual(obj.html, "")
|
||||||
|
self.assertEqual(obj.html_id, "")
|
||||||
|
self.assertListEqual(obj.children, [])
|
||||||
|
|
||||||
|
def test_should_identify_if_item_is_a_folder(self):
|
||||||
|
# given
|
||||||
|
app_item = create_rendered_menu_item(menu_item=create_app_menu_item())
|
||||||
|
link_item = create_rendered_menu_item(menu_item=create_link_menu_item())
|
||||||
|
folder_item = create_rendered_menu_item(menu_item=create_folder_menu_item())
|
||||||
|
|
||||||
|
cases = [
|
||||||
|
(app_item, False),
|
||||||
|
(link_item, False),
|
||||||
|
(folder_item, True),
|
||||||
|
]
|
||||||
|
# when
|
||||||
|
for obj, expected in cases:
|
||||||
|
with self.subTest(type=expected):
|
||||||
|
self.assertIs(obj.is_folder, expected)
|
||||||
|
|
||||||
|
def test_should_update_html_for_link_item(self):
|
||||||
|
# given
|
||||||
|
obj = create_rendered_menu_item(menu_item=create_link_menu_item(text="Alpha"))
|
||||||
|
request = self.factory.get("/")
|
||||||
|
|
||||||
|
# when
|
||||||
|
obj.update_html(request, self.template)
|
||||||
|
|
||||||
|
# then
|
||||||
|
parsed = parse_html(obj)
|
||||||
|
self.assertEqual(parsed.text, "Alpha")
|
||||||
|
self.assertIsNone(parsed.count)
|
||||||
|
self.assertFalse(obj.html_id)
|
||||||
|
|
||||||
|
def test_should_update_html_for_folder_item(self):
|
||||||
|
# given
|
||||||
|
request = self.factory.get("/")
|
||||||
|
folder_item = create_folder_menu_item(text="Alpha")
|
||||||
|
link_item = create_link_menu_item(text="Bravo", parent=folder_item)
|
||||||
|
obj = create_rendered_menu_item(menu_item=folder_item, count=42)
|
||||||
|
rendered_link = create_rendered_menu_item(menu_item=link_item)
|
||||||
|
rendered_link.update_html(request, self.template)
|
||||||
|
obj.children.append(rendered_link)
|
||||||
|
|
||||||
|
# when
|
||||||
|
obj.update_html(request, self.template)
|
||||||
|
|
||||||
|
# then
|
||||||
|
self.assertTrue(obj.html_id)
|
||||||
|
parsed_parent = parse_html(obj)
|
||||||
|
self.assertEqual(parsed_parent.text, "Alpha")
|
||||||
|
self.assertEqual(parsed_parent.count, 42)
|
||||||
|
self.assertIn("Bravo", obj.html)
|
||||||
|
|
||||||
|
|
||||||
|
class _ParsedMenuItem(NamedTuple):
|
||||||
|
classes: List[str]
|
||||||
|
text: str
|
||||||
|
count: Optional[int]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_html(obj: RenderedMenuItem) -> _ParsedMenuItem:
|
||||||
|
soup = BeautifulSoup(obj.html, "html.parser")
|
||||||
|
classes = soup.li.i.attrs["class"]
|
||||||
|
text = remove_whitespaces(soup.li.a.text)
|
||||||
|
try:
|
||||||
|
count = int(remove_whitespaces(soup.li.span.text))
|
||||||
|
except (AttributeError, ValueError):
|
||||||
|
count = None
|
||||||
|
|
||||||
|
return _ParsedMenuItem(classes=classes, text=text, count=count)
|
28
allianceauth/menu/tests/test_forms.py
Normal file
28
allianceauth/menu/tests/test_forms.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from allianceauth.menu.constants import DEFAULT_FOLDER_ICON_CLASSES
|
||||||
|
from allianceauth.menu.forms import FolderMenuItemAdminForm
|
||||||
|
|
||||||
|
|
||||||
|
class TestFolderMenuItemAdminForm(TestCase):
|
||||||
|
def test_should_set_default_icon_classes(self):
|
||||||
|
# given
|
||||||
|
form_data = {"text": "Alpha", "order": 1}
|
||||||
|
form = FolderMenuItemAdminForm(data=form_data)
|
||||||
|
|
||||||
|
# when
|
||||||
|
obj = form.save(commit=False)
|
||||||
|
|
||||||
|
# then
|
||||||
|
self.assertEqual(obj.classes, DEFAULT_FOLDER_ICON_CLASSES)
|
||||||
|
|
||||||
|
def test_should_use_icon_classes_from_input(self):
|
||||||
|
# given
|
||||||
|
form_data = {"text": "Alpha", "order": 1, "classes": "dummy"}
|
||||||
|
form = FolderMenuItemAdminForm(data=form_data)
|
||||||
|
|
||||||
|
# when
|
||||||
|
obj = form.save(commit=False)
|
||||||
|
|
||||||
|
# then
|
||||||
|
self.assertEqual(obj.classes, "dummy")
|
82
allianceauth/menu/tests/test_hooks.py
Normal file
82
allianceauth/menu/tests/test_hooks.py
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
from django.test import RequestFactory, TestCase
|
||||||
|
|
||||||
|
from allianceauth.menu.hooks import MenuItemHook
|
||||||
|
|
||||||
|
from .factories import create_menu_item_hook
|
||||||
|
|
||||||
|
|
||||||
|
class TestMenuItemHook(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
super().setUpClass()
|
||||||
|
cls.factory = RequestFactory()
|
||||||
|
|
||||||
|
def test_should_create_obj_with_minimal_params(self):
|
||||||
|
# when
|
||||||
|
obj = MenuItemHook("text", "classes", "url-name")
|
||||||
|
|
||||||
|
# then
|
||||||
|
self.assertEqual(obj.text, "text")
|
||||||
|
self.assertEqual(obj.classes, "classes")
|
||||||
|
self.assertEqual(obj.url_name, "url-name")
|
||||||
|
self.assertEqual(obj.template, "public/menuitem.html")
|
||||||
|
self.assertEqual(obj.order, 9999)
|
||||||
|
self.assertListEqual(obj.navactive, ["url-name"])
|
||||||
|
self.assertIsNone(obj.count)
|
||||||
|
|
||||||
|
def test_should_create_obj_with_full_params_1(self):
|
||||||
|
# when
|
||||||
|
obj = MenuItemHook("text", "classes", "url-name", 5, ["navactive"])
|
||||||
|
|
||||||
|
# then
|
||||||
|
self.assertEqual(obj.text, "text")
|
||||||
|
self.assertEqual(obj.classes, "classes")
|
||||||
|
self.assertEqual(obj.url_name, "url-name")
|
||||||
|
self.assertEqual(obj.template, "public/menuitem.html")
|
||||||
|
self.assertEqual(obj.order, 5)
|
||||||
|
self.assertListEqual(obj.navactive, ["navactive", "url-name"])
|
||||||
|
self.assertIsNone(obj.count)
|
||||||
|
|
||||||
|
def test_should_create_obj_with_full_params_2(self):
|
||||||
|
# when
|
||||||
|
obj = MenuItemHook(
|
||||||
|
text="text",
|
||||||
|
classes="classes",
|
||||||
|
url_name="url-name",
|
||||||
|
order=5,
|
||||||
|
navactive=["navactive"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# then
|
||||||
|
self.assertEqual(obj.text, "text")
|
||||||
|
self.assertEqual(obj.classes, "classes")
|
||||||
|
self.assertEqual(obj.url_name, "url-name")
|
||||||
|
self.assertEqual(obj.template, "public/menuitem.html")
|
||||||
|
self.assertEqual(obj.order, 5)
|
||||||
|
self.assertListEqual(obj.navactive, ["navactive", "url-name"])
|
||||||
|
self.assertIsNone(obj.count)
|
||||||
|
|
||||||
|
def test_should_render_menu_item(self):
|
||||||
|
# given
|
||||||
|
request = self.factory.get("/")
|
||||||
|
hook = create_menu_item_hook(text="Alpha")
|
||||||
|
|
||||||
|
# when
|
||||||
|
result = hook.render(request)
|
||||||
|
|
||||||
|
# then
|
||||||
|
self.assertIn("Alpha", result)
|
||||||
|
|
||||||
|
def test_str(self):
|
||||||
|
# given
|
||||||
|
hook = create_menu_item_hook(text="Alpha")
|
||||||
|
|
||||||
|
# when/then
|
||||||
|
self.assertEqual(str(hook), "Alpha")
|
||||||
|
|
||||||
|
def test_repr(self):
|
||||||
|
# given
|
||||||
|
hook = create_menu_item_hook(text="Alpha")
|
||||||
|
|
||||||
|
# when/then
|
||||||
|
self.assertIn("Alpha", repr(hook))
|
103
allianceauth/menu/tests/test_managers.py
Normal file
103
allianceauth/menu/tests/test_managers.py
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from allianceauth.menu.constants import MenuItemType
|
||||||
|
from allianceauth.menu.models import MenuItem
|
||||||
|
|
||||||
|
from .factories import (
|
||||||
|
create_app_menu_item,
|
||||||
|
create_folder_menu_item,
|
||||||
|
create_link_menu_item,
|
||||||
|
create_menu_item_from_hook,
|
||||||
|
create_menu_item_hook_function,
|
||||||
|
)
|
||||||
|
from .utils import PACKAGE_PATH
|
||||||
|
|
||||||
|
|
||||||
|
class TestMenuItemQuerySet(TestCase):
|
||||||
|
def test_should_add_item_type_field(self):
|
||||||
|
# given
|
||||||
|
app_item = create_app_menu_item()
|
||||||
|
link_item = create_link_menu_item()
|
||||||
|
folder_item = create_folder_menu_item()
|
||||||
|
|
||||||
|
# when
|
||||||
|
result: QuerySet[MenuItem] = MenuItem.objects.annotate_item_type_2()
|
||||||
|
|
||||||
|
# then
|
||||||
|
for obj in [app_item, link_item, folder_item]:
|
||||||
|
obj = result.get(pk=app_item.pk)
|
||||||
|
self.assertEqual(obj.item_type_2, obj.item_type)
|
||||||
|
|
||||||
|
def test_should_filter_folders(self):
|
||||||
|
# given
|
||||||
|
create_app_menu_item()
|
||||||
|
create_link_menu_item()
|
||||||
|
folder_item = create_folder_menu_item()
|
||||||
|
|
||||||
|
# when
|
||||||
|
result: QuerySet[MenuItem] = MenuItem.objects.filter_folders()
|
||||||
|
|
||||||
|
# then
|
||||||
|
item_pks = set(result.values_list("pk", flat=True))
|
||||||
|
self.assertSetEqual(item_pks, {folder_item.pk})
|
||||||
|
|
||||||
|
|
||||||
|
@patch(PACKAGE_PATH + ".managers.get_hooks", spec=True)
|
||||||
|
class TestMenuItemManagerSyncAll(TestCase):
|
||||||
|
def test_should_create_new_items_from_hooks_when_they_do_not_exist(
|
||||||
|
self, mock_get_hooks
|
||||||
|
):
|
||||||
|
# given
|
||||||
|
mock_get_hooks.return_value = [create_menu_item_hook_function(text="Alpha")]
|
||||||
|
|
||||||
|
# when
|
||||||
|
MenuItem.objects.sync_all()
|
||||||
|
|
||||||
|
# then
|
||||||
|
self.assertEqual(MenuItem.objects.count(), 1)
|
||||||
|
obj = MenuItem.objects.first()
|
||||||
|
self.assertEqual(obj.item_type, MenuItemType.APP)
|
||||||
|
self.assertEqual(obj.text, "Alpha")
|
||||||
|
|
||||||
|
def test_should_update_existing_app_items_when_changed_only(self, mock_get_hooks):
|
||||||
|
# given
|
||||||
|
menu_hook_1 = create_menu_item_hook_function(text="Alpha", order=1)
|
||||||
|
menu_hook_2 = create_menu_item_hook_function(text="Bravo", order=2)
|
||||||
|
mock_get_hooks.return_value = [menu_hook_1, menu_hook_2]
|
||||||
|
create_menu_item_from_hook(menu_hook_1, text="name has changed", order=99)
|
||||||
|
create_menu_item_from_hook(menu_hook_2)
|
||||||
|
|
||||||
|
# when
|
||||||
|
MenuItem.objects.sync_all()
|
||||||
|
|
||||||
|
# then
|
||||||
|
self.assertEqual(MenuItem.objects.count(), 2)
|
||||||
|
|
||||||
|
obj = MenuItem.objects.get(text="Alpha")
|
||||||
|
self.assertEqual(obj.item_type, MenuItemType.APP)
|
||||||
|
self.assertEqual(obj.order, 99)
|
||||||
|
|
||||||
|
obj = MenuItem.objects.get(text="Bravo")
|
||||||
|
self.assertEqual(obj.item_type, MenuItemType.APP)
|
||||||
|
self.assertEqual(obj.order, 2)
|
||||||
|
|
||||||
|
def test_should_remove_obsolete_app_items_but_keep_user_items(self, mock_get_hooks):
|
||||||
|
# given
|
||||||
|
menu_hook = create_menu_item_hook_function(text="Alpha")
|
||||||
|
mock_get_hooks.return_value = [menu_hook]
|
||||||
|
create_app_menu_item(text="Bravo") # obsolete item
|
||||||
|
link_item = create_link_menu_item()
|
||||||
|
folder_item = create_folder_menu_item()
|
||||||
|
|
||||||
|
# when
|
||||||
|
MenuItem.objects.sync_all()
|
||||||
|
|
||||||
|
# then
|
||||||
|
self.assertEqual(MenuItem.objects.count(), 3)
|
||||||
|
obj = MenuItem.objects.get(text="Alpha")
|
||||||
|
self.assertTrue(obj.item_type, MenuItemType.APP)
|
||||||
|
self.assertIn(link_item, MenuItem.objects.all())
|
||||||
|
self.assertIn(folder_item, MenuItem.objects.all())
|
166
allianceauth/menu/tests/test_models.py
Normal file
166
allianceauth/menu/tests/test_models.py
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from allianceauth.menu.constants import MenuItemType
|
||||||
|
|
||||||
|
from .factories import (
|
||||||
|
create_app_menu_item,
|
||||||
|
create_folder_menu_item,
|
||||||
|
create_link_menu_item,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMenuItem(TestCase):
|
||||||
|
def test_str(self):
|
||||||
|
# given
|
||||||
|
obj = create_link_menu_item()
|
||||||
|
# when
|
||||||
|
result = str(obj)
|
||||||
|
# then
|
||||||
|
self.assertIsInstance(result, str)
|
||||||
|
|
||||||
|
def test_should_return_item_type(self):
|
||||||
|
# given
|
||||||
|
app_item = create_app_menu_item()
|
||||||
|
link_item = create_link_menu_item()
|
||||||
|
folder_item = create_folder_menu_item()
|
||||||
|
|
||||||
|
cases = [
|
||||||
|
(app_item, MenuItemType.APP),
|
||||||
|
(link_item, MenuItemType.LINK),
|
||||||
|
(folder_item, MenuItemType.FOLDER),
|
||||||
|
]
|
||||||
|
# when
|
||||||
|
for obj, expected in cases:
|
||||||
|
with self.subTest(type=expected):
|
||||||
|
self.assertEqual(obj.item_type, expected)
|
||||||
|
|
||||||
|
def test_should_identify_if_item_is_a_child(self):
|
||||||
|
# given
|
||||||
|
folder = create_folder_menu_item()
|
||||||
|
child = create_link_menu_item(parent=folder)
|
||||||
|
not_child = create_link_menu_item()
|
||||||
|
|
||||||
|
cases = [(child, True), (not_child, False)]
|
||||||
|
# when
|
||||||
|
for obj, expected in cases:
|
||||||
|
with self.subTest(type=expected):
|
||||||
|
self.assertIs(obj.is_child, expected)
|
||||||
|
|
||||||
|
def test_should_identify_if_item_is_a_folder(self):
|
||||||
|
# given
|
||||||
|
app_item = create_app_menu_item()
|
||||||
|
link_item = create_link_menu_item()
|
||||||
|
folder_item = create_folder_menu_item()
|
||||||
|
|
||||||
|
cases = [
|
||||||
|
(app_item, False),
|
||||||
|
(link_item, False),
|
||||||
|
(folder_item, True),
|
||||||
|
]
|
||||||
|
# when
|
||||||
|
for obj, expected in cases:
|
||||||
|
with self.subTest(type=expected):
|
||||||
|
self.assertIs(obj.is_folder, expected)
|
||||||
|
|
||||||
|
def test_should_identify_if_item_is_user_defined(self):
|
||||||
|
# given
|
||||||
|
app_item = create_app_menu_item()
|
||||||
|
link_item = create_link_menu_item()
|
||||||
|
folder_item = create_folder_menu_item()
|
||||||
|
|
||||||
|
cases = [
|
||||||
|
(app_item, False),
|
||||||
|
(link_item, True),
|
||||||
|
(folder_item, True),
|
||||||
|
]
|
||||||
|
# when
|
||||||
|
for obj, expected in cases:
|
||||||
|
with self.subTest(type=expected):
|
||||||
|
self.assertIs(obj.is_user_defined, expected)
|
||||||
|
|
||||||
|
def test_should_identify_if_item_is_an_app_item(self):
|
||||||
|
# given
|
||||||
|
app_item = create_app_menu_item()
|
||||||
|
link_item = create_link_menu_item()
|
||||||
|
folder_item = create_folder_menu_item()
|
||||||
|
|
||||||
|
cases = [
|
||||||
|
(app_item, True),
|
||||||
|
(link_item, False),
|
||||||
|
(folder_item, False),
|
||||||
|
]
|
||||||
|
# when
|
||||||
|
for obj, expected in cases:
|
||||||
|
with self.subTest(type=expected):
|
||||||
|
self.assertIs(obj.is_app_item, expected)
|
||||||
|
|
||||||
|
def test_should_identify_if_item_is_a_link_item(self):
|
||||||
|
# given
|
||||||
|
app_item = create_app_menu_item()
|
||||||
|
link_item = create_link_menu_item()
|
||||||
|
folder_item = create_folder_menu_item()
|
||||||
|
|
||||||
|
cases = [
|
||||||
|
(app_item, False),
|
||||||
|
(link_item, True),
|
||||||
|
(folder_item, False),
|
||||||
|
]
|
||||||
|
# when
|
||||||
|
for obj, expected in cases:
|
||||||
|
with self.subTest(type=expected):
|
||||||
|
self.assertIs(obj.is_link_item, expected)
|
||||||
|
|
||||||
|
def test_should_not_allow_creating_invalid_app_item(self):
|
||||||
|
# when
|
||||||
|
obj = create_app_menu_item(hook_hash="")
|
||||||
|
|
||||||
|
# then
|
||||||
|
obj.refresh_from_db()
|
||||||
|
self.assertIsNone(obj.hook_hash)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMenuItemToHookObj(TestCase):
|
||||||
|
def test_should_create_from_link_item(self):
|
||||||
|
# given
|
||||||
|
obj = create_link_menu_item(text="Alpha")
|
||||||
|
|
||||||
|
# when
|
||||||
|
hook_obj = obj.to_hook_obj()
|
||||||
|
|
||||||
|
# then
|
||||||
|
self.assertEqual(hook_obj.text, "Alpha")
|
||||||
|
self.assertEqual(hook_obj.url, obj.url)
|
||||||
|
self.assertEqual(hook_obj.html_id, "")
|
||||||
|
self.assertFalse(hook_obj.is_folder)
|
||||||
|
|
||||||
|
def test_should_create_from_folder(self):
|
||||||
|
# given
|
||||||
|
obj = create_folder_menu_item(text="Alpha", classes="dummy")
|
||||||
|
|
||||||
|
# when
|
||||||
|
hook_obj = obj.to_hook_obj()
|
||||||
|
|
||||||
|
# then
|
||||||
|
self.assertEqual(hook_obj.text, "Alpha")
|
||||||
|
self.assertEqual(hook_obj.classes, "dummy")
|
||||||
|
self.assertEqual(hook_obj.url, "")
|
||||||
|
self.assertTrue(hook_obj.html_id)
|
||||||
|
self.assertTrue(hook_obj.is_folder)
|
||||||
|
|
||||||
|
def test_should_create_from_folder_and_use_default_icon_classes(self):
|
||||||
|
# given
|
||||||
|
obj = create_folder_menu_item(classes="")
|
||||||
|
|
||||||
|
# when
|
||||||
|
hook_obj = obj.to_hook_obj()
|
||||||
|
|
||||||
|
# then
|
||||||
|
self.assertEqual(hook_obj.classes, "fa-solid fa-folder")
|
||||||
|
|
||||||
|
def test_should_create_from_app_item(self):
|
||||||
|
# given
|
||||||
|
obj = create_app_menu_item(text="Alpha")
|
||||||
|
|
||||||
|
# when
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
obj.to_hook_obj()
|
47
allianceauth/menu/tests/utils.py
Normal file
47
allianceauth/menu/tests/utils.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import tempfile
|
||||||
|
import webbrowser
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.template import Context, Template
|
||||||
|
|
||||||
|
PACKAGE_PATH = "allianceauth.menu"
|
||||||
|
|
||||||
|
|
||||||
|
def extract_links(response: HttpResponse) -> dict:
|
||||||
|
soup = extract_html(response)
|
||||||
|
links = {
|
||||||
|
link["href"]: "".join(link.stripped_strings)
|
||||||
|
for link in soup.find_all("a", href=True)
|
||||||
|
}
|
||||||
|
return links
|
||||||
|
|
||||||
|
|
||||||
|
def extract_html(response: HttpResponse) -> BeautifulSoup:
|
||||||
|
soup = BeautifulSoup(response.content, "html.parser")
|
||||||
|
return soup
|
||||||
|
|
||||||
|
|
||||||
|
def open_page_in_browser(response: HttpResponse):
|
||||||
|
"""Open the response in the system's default browser.
|
||||||
|
|
||||||
|
This will create a temporary file in the user's home.
|
||||||
|
"""
|
||||||
|
path = Path.home() / "temp"
|
||||||
|
path.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(dir=path, delete=False) as file:
|
||||||
|
file.write(response.content)
|
||||||
|
webbrowser.open(file.name)
|
||||||
|
|
||||||
|
|
||||||
|
def render_template(string, context=None):
|
||||||
|
context = context or {}
|
||||||
|
context = Context(context)
|
||||||
|
return Template(string).render(context)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_whitespaces(s) -> str:
|
||||||
|
return s.replace("\n", "").strip()
|
@ -8,9 +8,10 @@ If you wish to make changes, overload the setting in your project's settings fil
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from django.contrib import messages
|
|
||||||
from celery.schedules import crontab
|
from celery.schedules import crontab
|
||||||
|
|
||||||
|
from django.contrib import messages
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
'allianceauth', # needs to be on top of this list to support favicons in Django admin (see https://gitlab.com/allianceauth/allianceauth/-/issues/1301)
|
'allianceauth', # needs to be on top of this list to support favicons in Django admin (see https://gitlab.com/allianceauth/allianceauth/-/issues/1301)
|
||||||
'django.contrib.admin',
|
'django.contrib.admin',
|
||||||
@ -73,7 +74,6 @@ PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|||||||
BASE_DIR = os.path.dirname(PROJECT_DIR)
|
BASE_DIR = os.path.dirname(PROJECT_DIR)
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
'allianceauth.menu.middleware.MenuSyncMiddleware',
|
|
||||||
'django.middleware.security.SecurityMiddleware',
|
'django.middleware.security.SecurityMiddleware',
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
'allianceauth.authentication.middleware.UserSettingsMiddleware',
|
'allianceauth.authentication.middleware.UserSettingsMiddleware',
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load navactive %}
|
{% load navactive %}
|
||||||
{% load menu_menu_items %}
|
{% load menu_items %}
|
||||||
|
|
||||||
<div class="col-sm-2 auth-side-navbar" role="navigation">
|
<div class="col-sm-2 auth-side-navbar" role="navigation">
|
||||||
<div class="collapse navbar-collapse auth-menus-collapse auth-side-navbar-collapse">
|
<div class="collapse navbar-collapse auth-menus-collapse auth-side-navbar-collapse">
|
||||||
|
@ -2,20 +2,21 @@ import datetime
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth.mixins import (
|
||||||
|
LoginRequiredMixin, PermissionRequiredMixin,
|
||||||
|
)
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.shortcuts import render, redirect
|
|
||||||
from django.views import View
|
|
||||||
from django.urls import reverse_lazy
|
|
||||||
from django.views.generic import CreateView, UpdateView, DeleteView
|
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
|
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
|
from django.urls import reverse_lazy
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.views import View
|
||||||
|
from django.views.generic import CreateView, DeleteView, UpdateView
|
||||||
|
|
||||||
|
from allianceauth.eveonline.models import EveCorporationInfo
|
||||||
from allianceauth.timerboard.form import TimerForm
|
from allianceauth.timerboard.form import TimerForm
|
||||||
from allianceauth.timerboard.models import Timer
|
from allianceauth.timerboard.models import Timer
|
||||||
from allianceauth.eveonline.models import EveCorporationInfo
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -94,7 +95,7 @@ class RemoveTimerView(TimerManagementView, DeleteView):
|
|||||||
def dashboard_timers(request):
|
def dashboard_timers(request):
|
||||||
try:
|
try:
|
||||||
corp = request.user.profile.main_character.corporation
|
corp = request.user.profile.main_character.corporation
|
||||||
except EveCorporationInfo.DoesNotExist:
|
except (EveCorporationInfo.DoesNotExist, AttributeError):
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
timers = Timer.objects.select_related('eve_character').filter((Q(eve_corp__isnull=True) | Q(eve_corp=corp)) ,eve_time__gte=timezone.now())[:5]
|
timers = Timer.objects.select_related('eve_character').filter((Q(eve_corp__isnull=True) | Q(eve_corp=corp)) ,eve_time__gte=timezone.now())[:5]
|
||||||
|
@ -41,6 +41,13 @@ CACHES = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
##########################
|
||||||
|
# Django ESI Configuration
|
||||||
|
##########################
|
||||||
|
ESI_SSO_CLIENT_ID = "dummy"
|
||||||
|
ESI_SSO_CLIENT_SECRET = "dummy"
|
||||||
|
ESI_SSO_CALLBACK_URL = f"{SITE_URL}/sso/callback"
|
||||||
|
|
||||||
########################
|
########################
|
||||||
# XenForo Configuration
|
# XenForo Configuration
|
||||||
########################
|
########################
|
||||||
|
@ -29,3 +29,11 @@ PASSWORD_HASHERS = [
|
|||||||
LOGGING = None # Comment out to enable logging for debugging
|
LOGGING = None # Comment out to enable logging for debugging
|
||||||
|
|
||||||
ALLIANCEAUTH_DASHBOARD_TASK_STATISTICS_DISABLED = True # disable for tests
|
ALLIANCEAUTH_DASHBOARD_TASK_STATISTICS_DISABLED = True # disable for tests
|
||||||
|
|
||||||
|
|
||||||
|
##########################
|
||||||
|
# Django ESI Configuration
|
||||||
|
##########################
|
||||||
|
ESI_SSO_CLIENT_ID = "dummy"
|
||||||
|
ESI_SSO_CLIENT_SECRET = "dummy"
|
||||||
|
ESI_SSO_CALLBACK_URL = f"{SITE_URL}/sso/callback"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user