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 %} -
  • - - - {% if item.count >= 1 %} - - {{ item.count }} - - {% elif item.url %} - +
  • + + - {% if item.items|length > 0 %} - - - - {% endif %} -
  • -{% endif %} + {% if item.count >= 1 %} + + {{ item.count }} + + {% elif item.url %} + + {% endif %} + + {% if item.is_folder %} + + + {% 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 %}