diff --git a/.coveragerc b/.coveragerc
index a63754a1..e06501b3 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -19,5 +19,6 @@ exclude_lines =
if __name__ == .__main__.:
def __repr__
raise AssertionError
+ if TYPE_CHECKING:
ignore_errors = True
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 0987ae36..41582a43 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -26,11 +26,11 @@ pre-commit-check:
<<: *only-default
stage: pre-commit
image: python:3.11-bullseye
- variables:
- PRE_COMMIT_HOME: ${CI_PROJECT_DIR}/.cache/pre-commit
- cache:
- paths:
- - ${PRE_COMMIT_HOME}
+ # variables:
+ # PRE_COMMIT_HOME: ${CI_PROJECT_DIR}/.cache/pre-commit
+ # cache:
+ # paths:
+ # - ${PRE_COMMIT_HOME}
script:
- pip install pre-commit
- pre-commit run --all-files
diff --git a/allianceauth/authentication/templates/authentication/dashboard_bs3.html b/allianceauth/authentication/templates/authentication/dashboard_bs3.html
new file mode 100644
index 00000000..14bb8cbc
--- /dev/null
+++ b/allianceauth/authentication/templates/authentication/dashboard_bs3.html
@@ -0,0 +1,10 @@
+{% extends 'allianceauth/base.html' %}
+
+
+{% block page_title %}Dashboard{% endblock page_title %}
+
+{% block content %}
+
+
Dashboard Dummy
+
+{% endblock %}
diff --git a/allianceauth/authentication/urls.py b/allianceauth/authentication/urls.py
index a7dc66e3..994fca37 100644
--- a/allianceauth/authentication/urls.py
+++ b/allianceauth/authentication/urls.py
@@ -38,5 +38,6 @@ urlpatterns = [
name='token_refresh'
),
path('dashboard/', views.dashboard, name='dashboard'),
+ path('dashboard_bs3/', views.dashboard_bs3, name='dashboard_bs3'),
path('task-counts/', views.task_counts, name='task_counts'),
]
diff --git a/allianceauth/authentication/views.py b/allianceauth/authentication/views.py
index fce8d4ee..2ecafa0b 100644
--- a/allianceauth/authentication/views.py
+++ b/allianceauth/authentication/views.py
@@ -1,5 +1,4 @@
import logging
-from allianceauth.hooks import get_hooks
from django_registration.backends.activation.views import (
REGISTRATION_SALT, ActivationView as BaseActivationView,
@@ -23,6 +22,7 @@ from esi.decorators import token_required
from esi.models import Token
from allianceauth.eveonline.models import EveCharacter
+from allianceauth.hooks import get_hooks
from .core.celery_workers import active_tasks_count, queued_tasks_count
from .forms import RegistrationForm
@@ -349,3 +349,12 @@ def task_counts(request) -> JsonResponse:
"tasks_queued": queued_tasks_count()
}
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')
diff --git a/allianceauth/framework/static/allianceauth/framework/css/auth-framework.css b/allianceauth/framework/static/allianceauth/framework/css/auth-framework.css
index ff3aba71..d377370d 100644
--- a/allianceauth/framework/static/allianceauth/framework/css/auth-framework.css
+++ b/allianceauth/framework/static/allianceauth/framework/css/auth-framework.css
@@ -20,6 +20,7 @@
width: 325px;
}
+ /* Menu items in general */
#sidebar-menu li > a,
#sidebar-menu li > ul > li > a {
text-overflow: ellipsis;
@@ -28,13 +29,21 @@
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 {
max-width: 189px;
}
+ /* Chevron icons */
#sidebar-menu [data-bs-toggle="collapse"] > i.fa-chevron-down,
#sidebar-menu [data-bs-toggle="collapse"].collapsed > i.fa-chevron-right {
display: block;
+ width: 16px;
}
#sidebar-menu [data-bs-toggle="collapse"] > i.fa-chevron-right,
diff --git a/allianceauth/menu/admin.py b/allianceauth/menu/admin.py
index 0c81c669..49f590be 100644
--- a/allianceauth/menu/admin.py
+++ b/allianceauth/menu/admin.py
@@ -1,9 +1,111 @@
+"""Admin site for menu app."""
+
+from typing import Optional
+
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):
- list_display = ['text', 'hide', 'parent', 'url', 'icon_classes', 'rank']
- ordering = ('rank',)
+ list_display = (
+ "_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
diff --git a/allianceauth/menu/apps.py b/allianceauth/menu/apps.py
index 93e1b081..9e13c6b8 100644
--- a/allianceauth/menu/apps.py
+++ b/allianceauth/menu/apps.py
@@ -1,19 +1,19 @@
import logging
from django.apps import AppConfig
-from django.db.utils import ProgrammingError, OperationalError
-
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):
name = "allianceauth.menu"
label = "menu"
def ready(self):
- try:
- from allianceauth.menu.providers import menu_provider
- menu_provider.clear_synced_flag()
- except (ProgrammingError, OperationalError):
- logger.warning("Migrations not completed for MenuItems")
+ from allianceauth.menu.core import smart_sync
+
+ smart_sync.reset_menu_items_sync()
diff --git a/allianceauth/menu/constants.py b/allianceauth/menu/constants.py
new file mode 100644
index 00000000..6360e2f6
--- /dev/null
+++ b/allianceauth/menu/constants.py
@@ -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")
diff --git a/allianceauth/menu/core/__init__.py b/allianceauth/menu/core/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/allianceauth/menu/core/menu_item_hooks.py b/allianceauth/menu/core/menu_item_hooks.py
new file mode 100644
index 00000000..4307b3a1
--- /dev/null
+++ b/allianceauth/menu/core/menu_item_hooks.py
@@ -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)
diff --git a/allianceauth/menu/core/smart_sync.py b/allianceauth/menu/core/smart_sync.py
new file mode 100644
index 00000000..4c9b0ee9
--- /dev/null
+++ b/allianceauth/menu/core/smart_sync.py
@@ -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)
diff --git a/allianceauth/menu/filters.py b/allianceauth/menu/filters.py
new file mode 100644
index 00000000..26afffa5
--- /dev/null
+++ b/allianceauth/menu/filters.py
@@ -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
diff --git a/allianceauth/menu/forms.py b/allianceauth/menu/forms.py
new file mode 100644
index 00000000..564ca172
--- /dev/null
+++ b/allianceauth/menu/forms.py
@@ -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"}),
+ }
diff --git a/allianceauth/menu/hooks.py b/allianceauth/menu/hooks.py
index 94ea033d..d751c8e5 100644
--- a/allianceauth/menu/hooks.py
+++ b/allianceauth/menu/hooks.py
@@ -1,42 +1,58 @@
-from django.template.loader import render_to_string
-
+"""Menu item hooks."""
from typing import List, Optional
+from django.template.loader import render_to_string
+
+from allianceauth.menu.constants import DEFAULT_MENU_ITEM_ORDER
+
class MenuItemHook:
- """
- 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
- """
+ """Auth Hook for generating side menu items.
+ 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.classes = classes
self.url_name = url_name
- self.template = 'public/menuitem.html'
- self.order = order if order is not None else 9999
-
- # count is an integer shown next to the menu item as badge when count != None
- # apps need to set the count in their child class, e.g. in render() method
+ self.template = "public/menuitem.html"
+ self.order = order if order is not None else DEFAULT_MENU_ITEM_ORDER
self.count = None
navactive = navactive or []
navactive.append(url_name)
self.navactive = navactive
- def render(self, request):
- return render_to_string(self.template,
- {'item': self},
- request=request)
+ def __str__(self) -> str:
+ return self.text
+
+ 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)
diff --git a/allianceauth/menu/managers.py b/allianceauth/menu/managers.py
new file mode 100644
index 00000000..2299d82b
--- /dev/null
+++ b/allianceauth/menu/managers.py
@@ -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)
diff --git a/allianceauth/menu/middleware.py b/allianceauth/menu/middleware.py
deleted file mode 100644
index 440befb3..00000000
--- a/allianceauth/menu/middleware.py
+++ /dev/null
@@ -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)
diff --git a/allianceauth/menu/migrations/0001_initial.py b/allianceauth/menu/migrations/0001_initial.py
index 05ab8143..1594566b 100644
--- a/allianceauth/menu/migrations/0001_initial.py
+++ b/allianceauth/menu/migrations/0001_initial.py
@@ -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
import django.db.models.deletion
@@ -8,21 +8,88 @@ class Migration(migrations.Migration):
initial = True
- dependencies = [
- ]
+ dependencies = []
operations = [
migrations.CreateModel(
- name='MenuItem',
+ name="MenuItem",
fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('hook_function', models.CharField(max_length=500)),
- ('icon_classes', models.CharField(max_length=150)),
- ('text', models.CharField(max_length=150)),
- ('url', models.CharField(blank=True, default=None, max_length=2048, null=True)),
- ('rank', models.IntegerField(default=1000)),
- ('hide', models.BooleanField(default=False)),
- ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='menu.menuitem')),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "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. fa-solid fa-house
",
+ 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",
+ ),
+ ),
],
),
]
diff --git a/allianceauth/menu/migrations/0002_alter_menuitem_hook_function_and_more.py b/allianceauth/menu/migrations/0002_alter_menuitem_hook_function_and_more.py
deleted file mode 100644
index 9bf03f2a..00000000
--- a/allianceauth/menu/migrations/0002_alter_menuitem_hook_function_and_more.py
+++ /dev/null
@@ -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),
- ),
- ]
diff --git a/allianceauth/menu/migrations/0003_menuitem_menu_menuit_rank_e880ab_idx.py b/allianceauth/menu/migrations/0003_menuitem_menu_menuit_rank_e880ab_idx.py
deleted file mode 100644
index 30c848bf..00000000
--- a/allianceauth/menu/migrations/0003_menuitem_menu_menuit_rank_e880ab_idx.py
+++ /dev/null
@@ -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'),
- ),
- ]
diff --git a/allianceauth/menu/migrations/0004_alter_menuitem_hide_alter_menuitem_icon_classes_and_more.py b/allianceauth/menu/migrations/0004_alter_menuitem_hide_alter_menuitem_icon_classes_and_more.py
deleted file mode 100644
index 30112a71..00000000
--- a/allianceauth/menu/migrations/0004_alter_menuitem_hide_alter_menuitem_icon_classes_and_more.py
+++ /dev/null
@@ -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),
- ),
- ]
diff --git a/allianceauth/menu/models.py b/allianceauth/menu/models.py
index cfcebdd3..b2984842 100644
--- a/allianceauth/menu/models.py
+++ b/allianceauth/menu/models.py
@@ -1,174 +1,132 @@
-import logging
-from allianceauth.hooks import get_hooks
-
from django.db import models
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):
- # Auto Generated model from an auth_hook
- hook_function = models.CharField(
- max_length=500, default=None, null=True, blank=True)
+ """An item in the sidebar menu.
+
+ 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(
- max_length=150, default=None, null=True, blank=True, help_text="Text to show on menu")
- url = models.CharField(max_length=2048, default=None,
- null=True, blank=True)
-
- # Put it under a header?
+ max_length=150,
+ db_index=True,
+ verbose_name=_("text"),
+ help_text=_("Text to show on menu"),
+ )
+ 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(
- '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
- rank = models.IntegerField(default=1000, help_text="Order of the menu. Lowest First.")
+ # app related properties
+ 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
- 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.")
+ # user defined properties
+ 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. fa-solid fa-house
"
+ ),
+ )
+ url = models.TextField(
+ default="",
+ verbose_name=_("url"),
+ help_text=_("External URL this menu items will link to"),
+ )
- class Meta:
- indexes = [
- models.Index(fields=['rank', ]),
- ]
+ objects = MenuItemManager()
def __str__(self) -> str:
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
- def classes(self): # Helper function to make this model closer to the hook functions
- return self.icon_classes
+ def item_type(self) -> MenuItemType:
+ """Return the type of this menu item."""
+ if self.hook_hash:
+ return MenuItemType.APP
- @staticmethod
- def hook_to_name(mh):
- return f"{mh.__class__.__module__}.{mh.__class__.__name__}"
+ if not self.url:
+ return MenuItemType.FOLDER
- @staticmethod
- 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)
+ return MenuItemType.LINK
- # Get rid of any legacy hooks from modules removed
- MenuItem.objects.filter(hook_function__isnull=False).exclude(
- hook_function__in=hook_functions).delete()
+ @property
+ def is_app_item(self) -> bool:
+ """Return True if this is an app item, else False."""
+ return self.item_type is MenuItemType.APP
- @classmethod
- def filter_items(cls, menu_item: dict):
- """
- filter any items with no valid children from a menu
- """
- 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
+ @property
+ def is_child(self) -> bool:
+ """Return True if this item is a child, else False."""
+ return bool(self.parent_id)
- @classmethod
- def render_menu(cls, request):
- """
- Return the sorted side menu items with any items the user can't see removed.
- """
- # Override all the items to the bs5 theme
- template = "menu/menu-item-bs5.html"
- # TODO discuss permissions for user defined links
+ @property
+ def is_folder(self) -> bool:
+ """Return True if this item is a folder, else False."""
+ return self.item_type is MenuItemType.FOLDER
- # Turn all the hooks into functions
- menu_hooks = get_hooks('menu_item_hook')
- items = {}
- for fn in menu_hooks:
- f = fn()
- items[cls.hook_to_name(f)] = f
+ @property
+ def is_link_item(self) -> bool:
+ """Return True if this item is a link item, else False."""
+ return self.item_type is MenuItemType.LINK
- 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 = {}
- for mi in menu_items:
- if mi.hide:
- # hidden item, skip it completely
- 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)
+ def to_hook_obj(self) -> MenuItemHookCustom:
+ """Convert to hook object for rendering."""
+ if self.is_app_item:
+ raise ValueError("The related hook objects should be used for app items.")
- parent = mi.id
- if mi.parent_id: # Set it if present
- parent = mi.parent_id
+ hook_obj = MenuItemHookCustom(
+ text=self.text, classes=self.classes, url_name="", order=self.order
+ )
+ 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
- menu[parent] = {"items": [],
- "count": 0,
- "render": None,
- "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
+ hook_obj.url = self.url
+ hook_obj.is_folder = self.is_folder
+ hook_obj.html_id = f"id-folder-{self.id}" if self.is_folder else ""
+ return hook_obj
diff --git a/allianceauth/menu/providers.py b/allianceauth/menu/providers.py
deleted file mode 100644
index a5e3702d..00000000
--- a/allianceauth/menu/providers.py
+++ /dev/null
@@ -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()
diff --git a/allianceauth/menu/templates/admin/menu/menuitem/change_list.html b/allianceauth/menu/templates/admin/menu/menuitem/change_list.html
new file mode 100644
index 00000000..3a1391fa
--- /dev/null
+++ b/allianceauth/menu/templates/admin/menu/menuitem/change_list.html
@@ -0,0 +1,13 @@
+{% extends "admin/change_list.html" %}
+
+{% load i18n %}
+
+{% block object-tools-items %}
+
+{{ block.super }}
+
+
+ {% translate "Add folder" %}
+
+
+{% endblock %}
diff --git a/allianceauth/menu/templates/menu/menu-block.html b/allianceauth/menu/templates/menu/menu-block.html
index f7124bb1..eb624bea 100644
--- a/allianceauth/menu/templates/menu/menu-block.html
+++ b/allianceauth/menu/templates/menu/menu-block.html
@@ -1,7 +1,3 @@
-{% for data in menu_items %}
- {% if data.items|length > 0 %}
- {% include "menu/menu-item-bs5.html" with item=data %}
- {% else %}
- {{ data.render }}
- {% endif %}
+{% for item in menu_items %}
+ {{ item.html }}
{% endfor %}
diff --git a/allianceauth/menu/templates/menu/menu-item-bs5.html b/allianceauth/menu/templates/menu/menu-item-bs5.html
index e400e5b2..7158ec54 100644
--- a/allianceauth/menu/templates/menu/menu-item-bs5.html
+++ b/allianceauth/menu/templates/menu/menu-item-bs5.html
@@ -1,40 +1,57 @@
{% load i18n %}
{% load navactive %}
-{% if not item.hide %}
-
-
-
- {% translate item.text %}
-
- {% if item.count >= 1 %}
-
- {{ item.count }}
-
- {% elif item.url %}
-
+
+
+
+
+ {% translate item.text %}
+
- {% if item.items|length > 0 %}
-
-
-
-
-
-
- {% for sub_item in item.items %}
- {{ sub_item.render }}
- {% endfor %}
-
- {% endif %}
-
-{% endif %}
+ {% if item.count >= 1 %}
+
+ {{ item.count }}
+
+ {% elif item.url %}
+
+ {% endif %}
+
+ {% if item.is_folder %}
+
+
+
+
+
+ {% for sub_item in item.children %}
+ {{ sub_item }}
+ {% endfor %}
+
+ {% endif %}
+
diff --git a/allianceauth/menu/templates/menu/sortable-side-menu.html b/allianceauth/menu/templates/menu/sortable-side-menu.html
index 793481f8..8b2a7e14 100644
--- a/allianceauth/menu/templates/menu/sortable-side-menu.html
+++ b/allianceauth/menu/templates/menu/sortable-side-menu.html
@@ -15,7 +15,7 @@
- {% sorted_menu_items %}
+ {% menu_items %}
{% include 'menu/menu-logo.html' %}
diff --git a/allianceauth/menu/templatetags/menu_items.py b/allianceauth/menu/templatetags/menu_items.py
new file mode 100644
index 00000000..0832cd3c
--- /dev/null
+++ b/allianceauth/menu/templatetags/menu_items.py
@@ -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
diff --git a/allianceauth/menu/templatetags/menu_menu_items.py b/allianceauth/menu/templatetags/menu_menu_items.py
index 3b6eec62..8cc7af5d 100644
--- a/allianceauth/menu/templatetags/menu_menu_items.py
+++ b/allianceauth/menu/templatetags/menu_menu_items.py
@@ -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.db.models import QuerySet
+from django.http import HttpRequest
from allianceauth.hooks import get_hooks
+from allianceauth.menu.core import menu_item_hooks, smart_sync
from allianceauth.menu.models import MenuItem
-
+from allianceauth.services.auth_hooks import MenuItemHook
register = template.Library()
-def process_menu_items(hooks, request):
- _menu_items = list()
- items = [fn() for fn in hooks]
- items.sort(key=lambda i: i.order)
- for item in items:
- _menu_items.append(item.render(request))
- return _menu_items
+@register.inclusion_tag("menu/menu-block.html", takes_context=True)
+def menu_items(context: dict) -> dict:
+ """Render menu items for new dashboards."""
+ smart_sync.sync_menu()
+
+ items = render_menu(context["request"])
+ return {"menu_items": items}
-@register.inclusion_tag('public/menublock.html', takes_context=True)
-def menu_items(context):
- request = context['request']
+@dataclass
+class RenderedMenuItem:
+ """A rendered menu item.
- return {
- 'menu_items': process_menu_items(get_hooks('menu_item_hook'), request),
- }
+ These objects can be rendered with the menu-block template.
+ """
+
+ 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 sorted_menu_items(context):
- request = context['request']
- menu_items = MenuItem.render_menu(request)
- return {
- 'menu_items':menu_items
- }
+def render_menu(request: HttpRequest) -> List[RenderedMenuItem]:
+ """Return the rendered side menu for including in a template.
+
+ This function is creating BS5 style menus.
+ """
+ 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]
diff --git a/allianceauth/menu/tests/__init__.py b/allianceauth/menu/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/allianceauth/menu/tests/core/__init__.py b/allianceauth/menu/tests/core/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/allianceauth/menu/tests/core/test_menu_item_hooks.py b/allianceauth/menu/tests/core/test_menu_item_hooks.py
new file mode 100644
index 00000000..9d607ac2
--- /dev/null
+++ b/allianceauth/menu/tests/core/test_menu_item_hooks.py
@@ -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, [])
diff --git a/allianceauth/menu/tests/core/test_smart_sync.py b/allianceauth/menu/tests/core/test_smart_sync.py
new file mode 100644
index 00000000..f34c24e0
--- /dev/null
+++ b/allianceauth/menu/tests/core/test_smart_sync.py
@@ -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)
diff --git a/allianceauth/menu/tests/factories.py b/allianceauth/menu/tests/factories.py
new file mode 100644
index 00000000..9e7351be
--- /dev/null
+++ b/allianceauth/menu/tests/factories.py
@@ -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)
diff --git a/allianceauth/menu/tests/integration/__init__.py b/allianceauth/menu/tests/integration/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/allianceauth/menu/tests/integration/test_admin.py b/allianceauth/menu/tests/integration/test_admin.py
new file mode 100644
index 00000000..d896cd7f
--- /dev/null
+++ b/allianceauth/menu/tests/integration/test_admin.py
@@ -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})
diff --git a/allianceauth/menu/tests/integration/test_dashboard.py b/allianceauth/menu/tests/integration/test_dashboard.py
new file mode 100644
index 00000000..876f3173
--- /dev/null
+++ b/allianceauth/menu/tests/integration/test_dashboard.py
@@ -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")
diff --git a/allianceauth/menu/tests/templatetags/__init__.py b/allianceauth/menu/tests/templatetags/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/allianceauth/menu/tests/templatetags/test_menu_items.py b/allianceauth/menu/tests/templatetags/test_menu_items.py
new file mode 100644
index 00000000..c329f3c6
--- /dev/null
+++ b/allianceauth/menu/tests/templatetags/test_menu_items.py
@@ -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])
diff --git a/allianceauth/menu/tests/templatetags/test_menu_menu_items.py b/allianceauth/menu/tests/templatetags/test_menu_menu_items.py
new file mode 100644
index 00000000..99363211
--- /dev/null
+++ b/allianceauth/menu/tests/templatetags/test_menu_menu_items.py
@@ -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)
diff --git a/allianceauth/menu/tests/test_forms.py b/allianceauth/menu/tests/test_forms.py
new file mode 100644
index 00000000..7d16dd2b
--- /dev/null
+++ b/allianceauth/menu/tests/test_forms.py
@@ -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")
diff --git a/allianceauth/menu/tests/test_hooks.py b/allianceauth/menu/tests/test_hooks.py
new file mode 100644
index 00000000..d703c6cb
--- /dev/null
+++ b/allianceauth/menu/tests/test_hooks.py
@@ -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))
diff --git a/allianceauth/menu/tests/test_managers.py b/allianceauth/menu/tests/test_managers.py
new file mode 100644
index 00000000..f460e11c
--- /dev/null
+++ b/allianceauth/menu/tests/test_managers.py
@@ -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())
diff --git a/allianceauth/menu/tests/test_models.py b/allianceauth/menu/tests/test_models.py
new file mode 100644
index 00000000..f4b9bf62
--- /dev/null
+++ b/allianceauth/menu/tests/test_models.py
@@ -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()
diff --git a/allianceauth/menu/tests/utils.py b/allianceauth/menu/tests/utils.py
new file mode 100644
index 00000000..d033f63e
--- /dev/null
+++ b/allianceauth/menu/tests/utils.py
@@ -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()
diff --git a/allianceauth/project_template/project_name/settings/base.py b/allianceauth/project_template/project_name/settings/base.py
index 03ac92c7..bca4afff 100644
--- a/allianceauth/project_template/project_name/settings/base.py
+++ b/allianceauth/project_template/project_name/settings/base.py
@@ -8,9 +8,10 @@ If you wish to make changes, overload the setting in your project's settings fil
import os
-from django.contrib import messages
from celery.schedules import crontab
+from django.contrib import messages
+
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)
'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)
MIDDLEWARE = [
- 'allianceauth.menu.middleware.MenuSyncMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'allianceauth.authentication.middleware.UserSettingsMiddleware',
diff --git a/allianceauth/templates/allianceauth/side-menu.html b/allianceauth/templates/allianceauth/side-menu.html
index 77c2715b..408b9d95 100644
--- a/allianceauth/templates/allianceauth/side-menu.html
+++ b/allianceauth/templates/allianceauth/side-menu.html
@@ -1,6 +1,6 @@
{% load i18n %}
{% load navactive %}
-{% load menu_menu_items %}
+{% load menu_items %}