mirror of
https://gitlab.com/allianceauth/allianceauth.git
synced 2025-07-08 20:10:17 +02:00
Improve menu app
This commit is contained in:
parent
2a762df9b3
commit
62c936f1c0
@ -19,5 +19,6 @@ exclude_lines =
|
||||
if __name__ == .__main__.:
|
||||
def __repr__
|
||||
raise AssertionError
|
||||
if TYPE_CHECKING:
|
||||
|
||||
ignore_errors = True
|
||||
|
@ -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
|
||||
|
@ -0,0 +1,10 @@
|
||||
{% extends 'allianceauth/base.html' %}
|
||||
|
||||
|
||||
{% block page_title %}Dashboard{% endblock page_title %}
|
||||
|
||||
{% block content %}
|
||||
<div>
|
||||
<h1>Dashboard Dummy</h1>
|
||||
</div>
|
||||
{% endblock %}
|
@ -38,5 +38,6 @@ urlpatterns = [
|
||||
name='token_refresh'
|
||||
),
|
||||
path('dashboard/', views.dashboard, name='dashboard'),
|
||||
path('dashboard_bs3/', views.dashboard_bs3, name='dashboard_bs3'),
|
||||
path('task-counts/', views.task_counts, name='task_counts'),
|
||||
]
|
||||
|
@ -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')
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
18
allianceauth/menu/constants.py
Normal file
18
allianceauth/menu/constants.py
Normal file
@ -0,0 +1,18 @@
|
||||
"""Global constants for the menu app."""
|
||||
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
DEFAULT_FOLDER_ICON_CLASSES = "fa-solid fa-folder" # TODO: Make this a setting?
|
||||
"""Default icon class for folders."""
|
||||
|
||||
DEFAULT_MENU_ITEM_ORDER = 9999
|
||||
"""Default order for any menu item."""
|
||||
|
||||
|
||||
class MenuItemType(models.TextChoices):
|
||||
"""The type of a menu item."""
|
||||
|
||||
APP = "app", _("app")
|
||||
FOLDER = "folder", _("folder")
|
||||
LINK = "link", _("link")
|
0
allianceauth/menu/core/__init__.py
Normal file
0
allianceauth/menu/core/__init__.py
Normal file
48
allianceauth/menu/core/menu_item_hooks.py
Normal file
48
allianceauth/menu/core/menu_item_hooks.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""Logic for handling MenuItemHook objects."""
|
||||
|
||||
import hashlib
|
||||
from typing import List, NamedTuple, Optional
|
||||
|
||||
from allianceauth.menu.hooks import MenuItemHook
|
||||
|
||||
|
||||
class MenuItemHookCustom(MenuItemHook):
|
||||
"""A user defined menu item that can be rendered with the standard template."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
text: str,
|
||||
classes: str,
|
||||
url_name: str,
|
||||
order: Optional[int] = None,
|
||||
navactive: Optional[List[str]] = None,
|
||||
):
|
||||
super().__init__(text, classes, url_name, order, navactive)
|
||||
self.url = ""
|
||||
self.is_folder = None
|
||||
self.html_id = ""
|
||||
self.children = []
|
||||
|
||||
|
||||
class MenuItemHookParams(NamedTuple):
|
||||
"""Immutable container for params about a menu item hook."""
|
||||
|
||||
text: str
|
||||
order: int
|
||||
hash: str
|
||||
|
||||
|
||||
def generate_hash(obj: MenuItemHook) -> str:
|
||||
"""Return the hash for a menu item hook."""
|
||||
my_class = obj.__class__
|
||||
name = f"{my_class.__module__}.{my_class.__name__}"
|
||||
hash_value = hashlib.sha256(name.encode("utf-8")).hexdigest()
|
||||
return hash_value
|
||||
|
||||
|
||||
def gather_params(obj: MenuItemHook) -> MenuItemHookParams:
|
||||
"""Return params from a menu item hook."""
|
||||
text = getattr(obj, "text", obj.__class__.__name__)
|
||||
order = getattr(obj, "order", None)
|
||||
hash = generate_hash(obj)
|
||||
return MenuItemHookParams(text=text, hash=hash, order=order)
|
30
allianceauth/menu/core/smart_sync.py
Normal file
30
allianceauth/menu/core/smart_sync.py
Normal file
@ -0,0 +1,30 @@
|
||||
"""Provide capability to sync menu items when needed only."""
|
||||
|
||||
from django.core.cache import cache
|
||||
|
||||
_MENU_SYNC_CACHE_KEY = "ALLIANCEAUTH-MENU-SYNCED"
|
||||
|
||||
|
||||
def sync_menu() -> None:
|
||||
"""Sync menu items if needed only."""
|
||||
from allianceauth.menu.models import MenuItem
|
||||
|
||||
is_sync_needed = not _is_menu_synced() or not MenuItem.objects.exists()
|
||||
# need to also check for existence of MenuItems in database
|
||||
# to ensure the menu is synced during tests
|
||||
if is_sync_needed:
|
||||
MenuItem.objects.sync_all()
|
||||
_record_menu_was_synced()
|
||||
|
||||
|
||||
def _is_menu_synced() -> bool:
|
||||
return cache.get(_MENU_SYNC_CACHE_KEY, False)
|
||||
|
||||
|
||||
def _record_menu_was_synced() -> None:
|
||||
cache.set(_MENU_SYNC_CACHE_KEY, True, timeout=None) # no timeout
|
||||
|
||||
|
||||
def reset_menu_items_sync() -> None:
|
||||
"""Ensure menu items are synced, e.g. after a Django restart."""
|
||||
cache.delete(_MENU_SYNC_CACHE_KEY)
|
24
allianceauth/menu/filters.py
Normal file
24
allianceauth/menu/filters.py
Normal file
@ -0,0 +1,24 @@
|
||||
"""Filters for the menu app."""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.utils.translation import gettext_noop as _
|
||||
|
||||
from allianceauth.menu.constants import MenuItemType
|
||||
|
||||
|
||||
class MenuItemTypeListFilter(admin.SimpleListFilter):
|
||||
"""Allow filtering admin changelist by menu item type."""
|
||||
|
||||
title = _("type")
|
||||
parameter_name = "type"
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return [(obj.value, obj.label.title()) for obj in MenuItemType]
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
if value := self.value():
|
||||
return queryset.annotate_item_type_2().filter(
|
||||
item_type_2=MenuItemType(value).value
|
||||
)
|
||||
|
||||
return None
|
49
allianceauth/menu/forms.py
Normal file
49
allianceauth/menu/forms.py
Normal file
@ -0,0 +1,49 @@
|
||||
from django import forms
|
||||
|
||||
from .constants import DEFAULT_FOLDER_ICON_CLASSES
|
||||
from .models import MenuItem
|
||||
|
||||
|
||||
class FolderMenuItemAdminForm(forms.ModelForm):
|
||||
"""A form for changing folder items."""
|
||||
|
||||
class Meta:
|
||||
model = MenuItem
|
||||
fields = ["text", "classes", "order", "is_hidden"]
|
||||
|
||||
def clean(self):
|
||||
data = super().clean()
|
||||
if not data["classes"]:
|
||||
data["classes"] = DEFAULT_FOLDER_ICON_CLASSES
|
||||
return data
|
||||
|
||||
|
||||
class _BasedMenuItemAdminForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["parent"].queryset = MenuItem.objects.filter_folders().order_by(
|
||||
"text"
|
||||
)
|
||||
self.fields["parent"].required = False
|
||||
self.fields["parent"].widget = forms.Select(
|
||||
choices=self.fields["parent"].widget.choices
|
||||
) # disable modify buttons
|
||||
|
||||
|
||||
class AppMenuItemAdminForm(_BasedMenuItemAdminForm):
|
||||
"""A form for changing app items."""
|
||||
|
||||
class Meta:
|
||||
model = MenuItem
|
||||
fields = ["order", "parent", "is_hidden"]
|
||||
|
||||
|
||||
class LinkMenuItemAdminForm(_BasedMenuItemAdminForm):
|
||||
"""A form for changing link items."""
|
||||
|
||||
class Meta:
|
||||
model = MenuItem
|
||||
fields = ["text", "url", "classes", "order", "parent", "is_hidden"]
|
||||
widgets = {
|
||||
"url": forms.TextInput(attrs={"size": "100"}),
|
||||
}
|
@ -1,42 +1,58 @@
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
"""Menu item hooks."""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from 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)
|
||||
|
67
allianceauth/menu/managers.py
Normal file
67
allianceauth/menu/managers.py
Normal file
@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import Case, Q, Value, When
|
||||
|
||||
from allianceauth.hooks import get_hooks
|
||||
|
||||
from .constants import MenuItemType
|
||||
from .core.menu_item_hooks import MenuItemHookParams, gather_params
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .models import MenuItem
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MenuItemQuerySet(models.QuerySet):
|
||||
def filter_folders(self):
|
||||
"""Add filter to include folders only."""
|
||||
return self.filter(hook_hash__isnull=True, url="")
|
||||
|
||||
def annotate_item_type_2(self):
|
||||
"""Add calculated field with item type."""
|
||||
return self.annotate(
|
||||
item_type_2=Case(
|
||||
When(~Q(hook_hash__isnull=True), then=Value(MenuItemType.APP.value)),
|
||||
When(url="", then=Value(MenuItemType.FOLDER.value)),
|
||||
default=Value(MenuItemType.LINK.value),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class MenuItemManagerBase(models.Manager):
|
||||
def sync_all(self):
|
||||
"""Sync all menu items from hooks."""
|
||||
hook_params = self._gather_menu_item_hook_params()
|
||||
self._delete_obsolete_app_items(hook_params)
|
||||
self._update_or_create_app_items(hook_params)
|
||||
|
||||
def _gather_menu_item_hook_params(self) -> list[MenuItemHookParams]:
|
||||
params = [gather_params(hook()) for hook in get_hooks("menu_item_hook")]
|
||||
return params
|
||||
|
||||
def _delete_obsolete_app_items(self, params: list[MenuItemHookParams]):
|
||||
hashes = [obj.hash for obj in params]
|
||||
self.exclude(hook_hash__isnull=True).exclude(hook_hash__in=hashes).delete()
|
||||
|
||||
def _update_or_create_app_items(self, params: list[MenuItemHookParams]):
|
||||
for param in params:
|
||||
try:
|
||||
obj: MenuItem = self.get(hook_hash=param.hash)
|
||||
except self.model.DoesNotExist:
|
||||
self.create(hook_hash=param.hash, order=param.order, text=param.text)
|
||||
else:
|
||||
# if it exists update the text only
|
||||
if obj.text != param.text:
|
||||
obj.text = param.text
|
||||
obj.save()
|
||||
|
||||
logger.debug("Updated menu items from %d menu item hooks", len(params))
|
||||
|
||||
|
||||
MenuItemManager = MenuItemManagerBase.from_queryset(MenuItemQuerySet)
|
@ -1,15 +0,0 @@
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
|
||||
import logging
|
||||
|
||||
from allianceauth.menu.providers import menu_provider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MenuSyncMiddleware(MiddlewareMixin):
|
||||
|
||||
def __call__(self, request):
|
||||
"""Alliance Auth Menu Sync Middleware"""
|
||||
menu_provider.check_and_sync_menu()
|
||||
return super().__call__(request)
|
@ -1,4 +1,4 @@
|
||||
# Generated by Django 4.0.2 on 2022-08-28 14:00
|
||||
# Generated by Django 4.2.9 on 2024-02-15 00:01
|
||||
|
||||
from django.db import migrations, models
|
||||
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. <code>fa-solid fa-house</code>",
|
||||
max_length=150,
|
||||
verbose_name="icon classes",
|
||||
),
|
||||
),
|
||||
(
|
||||
"url",
|
||||
models.TextField(
|
||||
default="",
|
||||
help_text="External URL this menu items will link to",
|
||||
verbose_name="url",
|
||||
),
|
||||
),
|
||||
(
|
||||
"parent",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="Folder this item is in (optional)",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="children",
|
||||
to="menu.menuitem",
|
||||
verbose_name="folder",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
@ -1,28 +0,0 @@
|
||||
# Generated by Django 4.0.2 on 2022-08-28 14:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('menu', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='menuitem',
|
||||
name='hook_function',
|
||||
field=models.CharField(blank=True, default=None, max_length=500, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='menuitem',
|
||||
name='icon_classes',
|
||||
field=models.CharField(blank=True, default=None, max_length=150, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='menuitem',
|
||||
name='text',
|
||||
field=models.CharField(blank=True, default=None, max_length=150, null=True),
|
||||
),
|
||||
]
|
@ -1,17 +0,0 @@
|
||||
# Generated by Django 4.0.8 on 2023-02-05 07:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('menu', '0002_alter_menuitem_hook_function_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name='menuitem',
|
||||
index=models.Index(fields=['rank'], name='menu_menuit_rank_e880ab_idx'),
|
||||
),
|
||||
]
|
@ -1,39 +0,0 @@
|
||||
# Generated by Django 4.0.10 on 2023-07-16 11:41
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('menu', '0003_menuitem_menu_menuit_rank_e880ab_idx'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='menuitem',
|
||||
name='hide',
|
||||
field=models.BooleanField(default=False, help_text='Hide this menu item. If this item is a header all items under it will be hidden too.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='menuitem',
|
||||
name='icon_classes',
|
||||
field=models.CharField(blank=True, default=None, help_text='Font Awesome classes to show as icon on menu', max_length=150, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='menuitem',
|
||||
name='parent',
|
||||
field=models.ForeignKey(blank=True, help_text='Parent Header. (Optional)', null=True, on_delete=django.db.models.deletion.SET_NULL, to='menu.menuitem'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='menuitem',
|
||||
name='rank',
|
||||
field=models.IntegerField(default=1000, help_text='Order of the menu. Lowest First.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='menuitem',
|
||||
name='text',
|
||||
field=models.CharField(blank=True, default=None, help_text='Text to show on menu', max_length=150, null=True),
|
||||
),
|
||||
]
|
@ -1,174 +1,132 @@
|
||||
import logging
|
||||
from allianceauth.hooks import get_hooks
|
||||
|
||||
from django.db import models
|
||||
from django.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. <code>fa-solid fa-house</code>"
|
||||
),
|
||||
)
|
||||
url = models.TextField(
|
||||
default="",
|
||||
verbose_name=_("url"),
|
||||
help_text=_("External URL this menu items will link to"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
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
|
||||
|
@ -1,49 +0,0 @@
|
||||
import logging
|
||||
|
||||
from django.core.cache import cache
|
||||
|
||||
from allianceauth.menu.models import MenuItem
|
||||
from allianceauth.utils.django import StartupCommand
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MENU_SYNC_CACHE_KEY = "ALLIANCEAUTH-MENU-SYNCED"
|
||||
MENU_CACHE_KEY = "ALLIANCEAUTH-MENU-CACHE"
|
||||
|
||||
|
||||
class MenuProvider():
|
||||
|
||||
def clear_synced_flag(self) -> bool:
|
||||
return cache.delete(MENU_SYNC_CACHE_KEY)
|
||||
|
||||
def set_synced_flag(self) -> bool:
|
||||
return cache.set(MENU_SYNC_CACHE_KEY, True)
|
||||
|
||||
def get_synced_flag(self) -> bool:
|
||||
return cache.get(MENU_SYNC_CACHE_KEY, False)
|
||||
|
||||
def sync_menu_models(self):
|
||||
MenuItem.sync_hook_models()
|
||||
self.set_synced_flag()
|
||||
|
||||
def check_and_sync_menu(self) -> None:
|
||||
if self.get_synced_flag():
|
||||
# performance hit to each page view to ensure tests work.
|
||||
# tests clear DB but not cache.
|
||||
# TODO rethink all of this?
|
||||
if MenuItem.objects.all().count() > 0:
|
||||
logger.debug("Menu Hooks Synced")
|
||||
else:
|
||||
self.sync_menu_models()
|
||||
else:
|
||||
logger.debug("Syncing Menu Hooks")
|
||||
self.sync_menu_models()
|
||||
|
||||
def get_and_cache_menu(self):
|
||||
pass
|
||||
|
||||
def clear_menu_cache(self):
|
||||
pass
|
||||
|
||||
|
||||
menu_provider = MenuProvider()
|
@ -0,0 +1,13 @@
|
||||
{% extends "admin/change_list.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block object-tools-items %}
|
||||
|
||||
{{ block.super }}
|
||||
<li>
|
||||
<a href="{% url 'admin:menu_menuitem_add' %}?type={{ folder_type }}" class="addlink">
|
||||
{% translate "Add folder" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endblock %}
|
@ -1,7 +1,3 @@
|
||||
{% for data in menu_items %}
|
||||
{% 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 %}
|
||||
|
@ -1,40 +1,57 @@
|
||||
{% load i18n %}
|
||||
{% load navactive %}
|
||||
|
||||
{% if not item.hide %}
|
||||
<li class="d-flex flex-wrap m-2 p-2 pt-0 pb-0 mt-0 mb-0 me-0 pe-0">
|
||||
<i class="nav-link {{ item.classes }} fa-fw align-self-center me-3 {% if item.navactive %}{% navactive request item.navactive|join:' ' %}{% endif %}" {% if item.items|length %} type="button" data-bs-toggle="collapse" data-bs-target="#id-{{ item.text|slugify }}" aria-expanded="false" aria-controls="" {% endif %}></i>
|
||||
<a class="nav-link flex-fill align-self-center" {% if item.items|length %} type="button" data-bs-toggle="collapse" data-bs-target="#id-{{ item.text|slugify }}" aria-expanded="false" aria-controls="" {% endif %}
|
||||
href="{% if item.url_name %}{% url item.url_name %}{% else %}{{ item.url }}{% endif %}">
|
||||
{% translate item.text %}
|
||||
</a>
|
||||
|
||||
{% if item.count >= 1 %}
|
||||
<span class="badge bg-primary m-2 align-self-center {% if item.items|length == 0 %}me-2{% endif %}">
|
||||
{{ item.count }}
|
||||
</span>
|
||||
{% elif item.url %}
|
||||
<span class="pill m-2 me-2 align-self-center fas fa-external-link-alt" title="{% translate 'External link' %}"></span>
|
||||
<li class="d-flex flex-wrap m-2 p-2 pt-0 pb-0 mt-0 mb-0 me-0 pe-0">
|
||||
<i
|
||||
class="nav-link {{ item.classes }} fa-fw align-self-center me-3 {% if item.navactive %}{% navactive request item.navactive|join:' ' %}{% endif %}"
|
||||
{% if item.is_folder %}
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#{{ item.html_id }}"
|
||||
aria-expanded="false"
|
||||
aria-controls=""
|
||||
{% endif %}>
|
||||
</i>
|
||||
<a
|
||||
class="nav-link flex-fill align-self-center me-auto"
|
||||
{% if item.is_folder %}
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#{{ item.html_id }}"
|
||||
aria-expanded="false"
|
||||
aria-controls=""
|
||||
{% endif %}
|
||||
href="{% if item.url_name %}{% url item.url_name %}{% else %}{{ item.url }}{% endif %}">
|
||||
{% translate item.text %}
|
||||
</a>
|
||||
|
||||
{% if item.items|length > 0 %}
|
||||
<span
|
||||
class="pill m-2 me-2 align-self-center collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#id-{{ item.text|slugify }}"
|
||||
aria-expanded="false"
|
||||
aria-controls=""
|
||||
>
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</span>
|
||||
<!--<hr class="m-0 w-100">-->
|
||||
<ul class="collapse ps-1 w-100 border-start rounded-start border-light border-3" id="id-{{ item.text|slugify }}">
|
||||
{% for sub_item in item.items %}
|
||||
{{ sub_item.render }}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if item.count >= 1 %}
|
||||
<span class="badge bg-primary m-2 align-self-center{% if not item.is_folder %} me-2{% endif %}">
|
||||
{{ item.count }}
|
||||
</span>
|
||||
{% elif item.url %}
|
||||
<span class="pill m-2 me-4 align-self-center fas fa-external-link-alt"></span>
|
||||
{% endif %}
|
||||
|
||||
{% if item.is_folder %}
|
||||
<span
|
||||
class="pill m-2 align-self-center collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#{{ item.html_id }}"
|
||||
aria-expanded="false"
|
||||
aria-controls=""
|
||||
>
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</span>
|
||||
<ul
|
||||
class="collapse ps-1 w-100 border-start rounded-start border-light border-3"
|
||||
id="{{ item.html_id }}">
|
||||
{% for sub_item in item.children %}
|
||||
{{ sub_item }}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</li>
|
||||
|
@ -15,7 +15,7 @@
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% sorted_menu_items %}
|
||||
{% menu_items %}
|
||||
</ul>
|
||||
|
||||
{% include 'menu/menu-logo.html' %}
|
||||
|
32
allianceauth/menu/templatetags/menu_items.py
Normal file
32
allianceauth/menu/templatetags/menu_items.py
Normal file
@ -0,0 +1,32 @@
|
||||
"""Template tags for rendering the classic side menu."""
|
||||
|
||||
from django import template
|
||||
from django.http import HttpRequest
|
||||
|
||||
from allianceauth.hooks import get_hooks
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
# TODO: Show user created menu items
|
||||
# TODO: Apply is_hidden feature for BS3 type items
|
||||
|
||||
|
||||
@register.inclusion_tag("public/menublock.html", takes_context=True)
|
||||
def menu_items(context: dict) -> dict:
|
||||
"""Render menu items for classic dashboard."""
|
||||
items = render_menu(context["request"])
|
||||
return {"menu_items": items}
|
||||
|
||||
|
||||
def render_menu(request: HttpRequest):
|
||||
"""Return the rendered side menu for including in a template.
|
||||
|
||||
This function is creating a BS3 style menu.
|
||||
"""
|
||||
|
||||
hooks = get_hooks("menu_item_hook")
|
||||
raw_items = [fn() for fn in hooks]
|
||||
raw_items.sort(key=lambda i: i.order)
|
||||
menu_items = [item.render(request) for item in raw_items]
|
||||
return menu_items
|
@ -1,34 +1,174 @@
|
||||
"""Template tags for rendering the new side menu.
|
||||
|
||||
Documentation of the render logic
|
||||
---------------------------------
|
||||
|
||||
The are 3 types of menu items:
|
||||
|
||||
- App entries: Generated by hooks from Django apps
|
||||
- Link entries: Linking to external pages. User created.
|
||||
- Folder: Grouping together several app or link entries. User created.
|
||||
|
||||
The MenuItem model holds the current list of all menu items.
|
||||
|
||||
App entries are linked to a `MenuItemHook` object in the respective Django app.
|
||||
Those hook objects contain dynamic logic in a `render()` method,
|
||||
which must be executed when rendering for the current request.
|
||||
|
||||
Since the same template must be used to render all items, link entries and folders
|
||||
are converted to `MenuItemHookCustom` objects, a sub class of `MenuItemHook`.
|
||||
This ensures the template only rendered objects of one specific type or sub-type.
|
||||
|
||||
The rendered menu items are finally collected in a list of RenderedMenuItem objects,
|
||||
which is used to render the complete menu.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from django import template
|
||||
from django.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]
|
||||
|
0
allianceauth/menu/tests/__init__.py
Normal file
0
allianceauth/menu/tests/__init__.py
Normal file
0
allianceauth/menu/tests/core/__init__.py
Normal file
0
allianceauth/menu/tests/core/__init__.py
Normal file
63
allianceauth/menu/tests/core/test_menu_item_hooks.py
Normal file
63
allianceauth/menu/tests/core/test_menu_item_hooks.py
Normal file
@ -0,0 +1,63 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from allianceauth.menu.core.menu_item_hooks import (
|
||||
MenuItemHookCustom,
|
||||
gather_params,
|
||||
generate_hash,
|
||||
)
|
||||
from allianceauth.menu.tests.factories import create_menu_item_hook_function
|
||||
|
||||
|
||||
class TestGenerateHash(TestCase):
|
||||
def test_should_generate_same_hash(self):
|
||||
# given
|
||||
hook = create_menu_item_hook_function()
|
||||
|
||||
# when
|
||||
result_1 = generate_hash(hook())
|
||||
result_2 = generate_hash(hook())
|
||||
|
||||
# then
|
||||
self.assertIsInstance(result_1, str)
|
||||
self.assertEqual(result_1, result_2)
|
||||
|
||||
def test_should_generate_different_hashes(self):
|
||||
# given
|
||||
hook_1 = create_menu_item_hook_function()
|
||||
hook_2 = create_menu_item_hook_function()
|
||||
|
||||
# when
|
||||
result_1 = generate_hash(hook_1())
|
||||
result_2 = generate_hash(hook_2())
|
||||
|
||||
# then
|
||||
self.assertNotEqual(result_1, result_2)
|
||||
|
||||
|
||||
class TestExtractParams(TestCase):
|
||||
def test_should_return_params(self):
|
||||
# given
|
||||
hook = create_menu_item_hook_function(text="Alpha", order=42)
|
||||
|
||||
# when
|
||||
result = gather_params(hook())
|
||||
|
||||
# then
|
||||
self.assertEqual(result.text, "Alpha")
|
||||
self.assertEqual(result.order, 42)
|
||||
self.assertIsInstance(result.hash, str)
|
||||
|
||||
|
||||
class TestMenuItemHookCustom(TestCase):
|
||||
def test_should_create_minimal(self):
|
||||
# when
|
||||
obj = MenuItemHookCustom(text="text", classes="classes", url_name="url_name")
|
||||
|
||||
# then
|
||||
self.assertEqual(obj.text, "text")
|
||||
self.assertEqual(obj.classes, "classes")
|
||||
self.assertEqual(obj.url_name, "url_name")
|
||||
self.assertEqual(obj.url, "")
|
||||
self.assertIsNone(obj.is_folder)
|
||||
self.assertEqual(obj.html_id, "")
|
||||
self.assertListEqual(obj.children, [])
|
42
allianceauth/menu/tests/core/test_smart_sync.py
Normal file
42
allianceauth/menu/tests/core/test_smart_sync.py
Normal file
@ -0,0 +1,42 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from allianceauth.menu.core import smart_sync
|
||||
from allianceauth.menu.tests.factories import create_link_menu_item
|
||||
from allianceauth.menu.tests.utils import PACKAGE_PATH
|
||||
|
||||
|
||||
@patch(PACKAGE_PATH + ".models.MenuItem.objects.sync_all", spec=True)
|
||||
class TestSmartSync(TestCase):
|
||||
def test_should_sync_after_reset(self, mock_sync_all):
|
||||
# given
|
||||
smart_sync.reset_menu_items_sync()
|
||||
mock_sync_all.reset_mock()
|
||||
|
||||
# when
|
||||
smart_sync.sync_menu()
|
||||
|
||||
# then
|
||||
self.assertTrue(mock_sync_all.called)
|
||||
|
||||
def test_should_sync_when_sync_flag_is_set_but_no_items_in_db(self, mock_sync_all):
|
||||
# given
|
||||
smart_sync._record_menu_was_synced()
|
||||
|
||||
# when
|
||||
smart_sync.sync_menu()
|
||||
|
||||
# then
|
||||
self.assertTrue(mock_sync_all.called)
|
||||
|
||||
def test_should_not_sync_when_sync_flag_is_set_and_items_in_db(self, mock_sync_all):
|
||||
# given
|
||||
smart_sync._record_menu_was_synced()
|
||||
create_link_menu_item()
|
||||
|
||||
# when
|
||||
smart_sync.sync_menu()
|
||||
|
||||
# then
|
||||
self.assertFalse(mock_sync_all.called)
|
95
allianceauth/menu/tests/factories.py
Normal file
95
allianceauth/menu/tests/factories.py
Normal file
@ -0,0 +1,95 @@
|
||||
from itertools import count
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from allianceauth.menu.core import menu_item_hooks
|
||||
from allianceauth.menu.models import MenuItem
|
||||
from allianceauth.menu.templatetags.menu_menu_items import RenderedMenuItem
|
||||
from allianceauth.services.auth_hooks import MenuItemHook
|
||||
from allianceauth.tests.auth_utils import AuthUtils
|
||||
|
||||
|
||||
def create_user(permissions=None, **kwargs) -> User:
|
||||
num = next(counter_user)
|
||||
params = {"username": f"test_user_{num}"}
|
||||
params.update(kwargs)
|
||||
user = User.objects.create(**params)
|
||||
if permissions:
|
||||
user = AuthUtils.add_permissions_to_user_by_name(perms=permissions, user=user)
|
||||
return user
|
||||
|
||||
|
||||
def create_menu_item_hook(**kwargs) -> MenuItemHook:
|
||||
num = next(counter_menu_item_hook)
|
||||
new_class = type(f"GeneratedMenuItem{num}", (MenuItemHook,), {})
|
||||
|
||||
count = kwargs.pop("count", None)
|
||||
params = {
|
||||
"text": f"Dummy App #{num}",
|
||||
"classes": "fa-solid fa-users-gear",
|
||||
"url_name": "groupmanagement:management",
|
||||
}
|
||||
params.update(kwargs)
|
||||
obj = new_class(**params)
|
||||
for key, value in params.items():
|
||||
setattr(obj, key, value)
|
||||
|
||||
obj.count = count
|
||||
return obj
|
||||
|
||||
|
||||
def create_menu_item_hook_function(**kwargs):
|
||||
obj = create_menu_item_hook(**kwargs)
|
||||
return lambda: obj
|
||||
|
||||
|
||||
def create_link_menu_item(**kwargs) -> MenuItem:
|
||||
num = next(counter_menu_item)
|
||||
params = {
|
||||
"url": f"https://www.example.com/{num}",
|
||||
}
|
||||
params.update(kwargs)
|
||||
return _create_menu_item(**params)
|
||||
|
||||
|
||||
def create_app_menu_item(**kwargs) -> MenuItem:
|
||||
params = {"hook_hash": "hook_hash"}
|
||||
params.update(kwargs)
|
||||
return _create_menu_item(**params)
|
||||
|
||||
|
||||
def create_folder_menu_item(**kwargs) -> MenuItem:
|
||||
return _create_menu_item(**kwargs)
|
||||
|
||||
|
||||
def create_menu_item_from_hook(hook, **kwargs) -> MenuItem:
|
||||
item = hook()
|
||||
hook_params = menu_item_hooks.gather_params(item)
|
||||
params = {
|
||||
"text": hook_params.text,
|
||||
"hook_hash": hook_params.hash,
|
||||
"order": hook_params.order,
|
||||
}
|
||||
params.update(kwargs)
|
||||
return _create_menu_item(**params)
|
||||
|
||||
|
||||
def _create_menu_item(**kwargs) -> MenuItem:
|
||||
num = next(counter_menu_item)
|
||||
params = {
|
||||
"text": f"text #{num}",
|
||||
}
|
||||
params.update(kwargs)
|
||||
return MenuItem.objects.create(**params)
|
||||
|
||||
|
||||
def create_rendered_menu_item(**kwargs) -> RenderedMenuItem:
|
||||
if "menu_item" not in kwargs:
|
||||
kwargs["menu_item"] = create_link_menu_item()
|
||||
|
||||
return RenderedMenuItem(**kwargs)
|
||||
|
||||
|
||||
counter_menu_item = count(1, 1)
|
||||
counter_menu_item_hook = count(1, 1)
|
||||
counter_user = count(1, 1)
|
0
allianceauth/menu/tests/integration/__init__.py
Normal file
0
allianceauth/menu/tests/integration/__init__.py
Normal file
178
allianceauth/menu/tests/integration/test_admin.py
Normal file
178
allianceauth/menu/tests/integration/test_admin.py
Normal file
@ -0,0 +1,178 @@
|
||||
from http import HTTPStatus
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from allianceauth.menu.constants import MenuItemType
|
||||
from allianceauth.menu.forms import (
|
||||
AppMenuItemAdminForm,
|
||||
FolderMenuItemAdminForm,
|
||||
LinkMenuItemAdminForm,
|
||||
)
|
||||
from allianceauth.menu.models import MenuItem
|
||||
from allianceauth.menu.tests.factories import (
|
||||
create_app_menu_item,
|
||||
create_folder_menu_item,
|
||||
create_link_menu_item,
|
||||
create_user,
|
||||
)
|
||||
from allianceauth.menu.tests.utils import extract_html
|
||||
|
||||
|
||||
def extract_menu_item_texts(response):
|
||||
"""Extract labels of menu items shown in change list."""
|
||||
soup = extract_html(response)
|
||||
items = soup.find_all("th", {"class": "field-_text"})
|
||||
labels = {elem.text for elem in items}
|
||||
return labels
|
||||
|
||||
|
||||
class TestAdminSite(TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
super().setUpClass()
|
||||
cls.user = create_user(is_superuser=True, is_staff=True)
|
||||
cls.changelist_url = reverse("admin:menu_menuitem_changelist")
|
||||
cls.add_url = reverse("admin:menu_menuitem_add")
|
||||
|
||||
def change_url(self, id_):
|
||||
return reverse("admin:menu_menuitem_change", args=[id_])
|
||||
|
||||
def test_changelist_should_show_all_types(self):
|
||||
# given
|
||||
self.client.force_login(self.user)
|
||||
create_app_menu_item(text="app")
|
||||
create_folder_menu_item(text="folder")
|
||||
create_link_menu_item(text="link")
|
||||
|
||||
# when
|
||||
response = self.client.get(self.changelist_url)
|
||||
|
||||
# then
|
||||
self.assertEqual(response.status_code, HTTPStatus.OK)
|
||||
labels = extract_menu_item_texts(response)
|
||||
self.assertSetEqual(labels, {"app", "[folder]", "link"})
|
||||
|
||||
def test_should_create_new_link_item(self):
|
||||
# given
|
||||
self.client.force_login(self.user)
|
||||
|
||||
# when
|
||||
response = self.client.post(
|
||||
self.add_url,
|
||||
{"text": "alpha", "url": "http://www.example.com", "order": 99},
|
||||
)
|
||||
# then
|
||||
self.assertEqual(response.status_code, HTTPStatus.FOUND)
|
||||
self.assertEqual(response.url, self.changelist_url)
|
||||
self.assertEqual(MenuItem.objects.count(), 1)
|
||||
obj = MenuItem.objects.first()
|
||||
self.assertEqual(obj.text, "alpha")
|
||||
self.assertEqual(obj.item_type, MenuItemType.LINK)
|
||||
|
||||
def test_should_create_new_folder_item(self):
|
||||
# given
|
||||
self.client.force_login(self.user)
|
||||
|
||||
# when
|
||||
response = self.client.post(
|
||||
self.add_url + "?type=folder", {"text": "alpha", "order": 99}
|
||||
)
|
||||
# then
|
||||
self.assertEqual(response.status_code, HTTPStatus.FOUND)
|
||||
self.assertEqual(response.url, self.changelist_url)
|
||||
self.assertEqual(MenuItem.objects.count(), 1)
|
||||
obj = MenuItem.objects.first()
|
||||
self.assertEqual(obj.text, "alpha")
|
||||
self.assertEqual(obj.item_type, MenuItemType.FOLDER)
|
||||
|
||||
def test_should_change_app_item(self):
|
||||
# given
|
||||
self.client.force_login(self.user)
|
||||
item = create_app_menu_item(text="alpha", order=1)
|
||||
form_data = AppMenuItemAdminForm(instance=item).initial
|
||||
form_data["order"] = 99
|
||||
form_data["parent"] = ""
|
||||
|
||||
# when
|
||||
response = self.client.post(self.change_url(item.id), form_data)
|
||||
|
||||
# then
|
||||
self.assertEqual(response.status_code, HTTPStatus.FOUND)
|
||||
self.assertEqual(response.url, self.changelist_url)
|
||||
self.assertEqual(MenuItem.objects.count(), 1)
|
||||
obj = MenuItem.objects.first()
|
||||
self.assertEqual(obj.order, 99)
|
||||
self.assertEqual(obj.item_type, MenuItemType.APP)
|
||||
|
||||
def test_should_change_link_item(self):
|
||||
# given
|
||||
self.client.force_login(self.user)
|
||||
item = create_link_menu_item(text="alpha")
|
||||
form_data = LinkMenuItemAdminForm(instance=item).initial
|
||||
form_data["text"] = "bravo"
|
||||
form_data["parent"] = ""
|
||||
|
||||
# when
|
||||
response = self.client.post(self.change_url(item.id), form_data)
|
||||
|
||||
# then
|
||||
self.assertEqual(response.status_code, HTTPStatus.FOUND)
|
||||
self.assertEqual(response.url, self.changelist_url)
|
||||
self.assertEqual(MenuItem.objects.count(), 1)
|
||||
obj = MenuItem.objects.first()
|
||||
self.assertEqual(obj.text, "bravo")
|
||||
self.assertEqual(obj.item_type, MenuItemType.LINK)
|
||||
|
||||
def test_should_change_folder_item(self):
|
||||
# given
|
||||
self.client.force_login(self.user)
|
||||
item = create_folder_menu_item(text="alpha")
|
||||
form_data = FolderMenuItemAdminForm(instance=item).initial
|
||||
form_data["text"] = "bravo"
|
||||
|
||||
# when
|
||||
response = self.client.post(self.change_url(item.id), form_data)
|
||||
|
||||
# then
|
||||
self.assertEqual(response.status_code, HTTPStatus.FOUND)
|
||||
self.assertEqual(response.url, self.changelist_url)
|
||||
self.assertEqual(MenuItem.objects.count(), 1)
|
||||
obj = MenuItem.objects.first()
|
||||
self.assertEqual(obj.text, "bravo")
|
||||
self.assertEqual(obj.item_type, MenuItemType.FOLDER)
|
||||
|
||||
def test_should_move_item_into_folder(self):
|
||||
# given
|
||||
self.client.force_login(self.user)
|
||||
link = create_link_menu_item(text="alpha")
|
||||
folder = create_folder_menu_item(text="folder")
|
||||
form_data = LinkMenuItemAdminForm(instance=link).initial
|
||||
form_data["parent"] = folder.id
|
||||
|
||||
# when
|
||||
response = self.client.post(self.change_url(link.id), form_data)
|
||||
|
||||
# then
|
||||
self.assertEqual(response.status_code, HTTPStatus.FOUND)
|
||||
self.assertEqual(response.url, self.changelist_url)
|
||||
link.refresh_from_db()
|
||||
self.assertEqual(link.parent, folder)
|
||||
|
||||
def test_should_filter_items_by_type(self):
|
||||
# given
|
||||
self.client.force_login(self.user)
|
||||
create_app_menu_item(text="app")
|
||||
create_folder_menu_item(text="folder")
|
||||
create_link_menu_item(text="link")
|
||||
|
||||
# when
|
||||
cases = [("link", "link"), ("app", "app"), ("folder", "[folder]")]
|
||||
for filter_name, expected_label in cases:
|
||||
with self.subTest(filter_name=filter_name):
|
||||
response = self.client.get(self.changelist_url + f"?type={filter_name}")
|
||||
|
||||
# then
|
||||
self.assertEqual(response.status_code, HTTPStatus.OK)
|
||||
labels = extract_menu_item_texts(response)
|
||||
self.assertSetEqual(labels, {expected_label})
|
102
allianceauth/menu/tests/integration/test_dashboard.py
Normal file
102
allianceauth/menu/tests/integration/test_dashboard.py
Normal file
@ -0,0 +1,102 @@
|
||||
from http import HTTPStatus
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from allianceauth.menu.core.smart_sync import reset_menu_items_sync
|
||||
from allianceauth.menu.tests.factories import (
|
||||
create_folder_menu_item,
|
||||
create_link_menu_item,
|
||||
create_user,
|
||||
)
|
||||
from allianceauth.menu.tests.utils import extract_links
|
||||
|
||||
|
||||
class TestDefaultDashboardWithSideMenu(TestCase):
|
||||
def test_should_show_all_types_of_menu_entries(self):
|
||||
# given
|
||||
user = create_user(permissions=["auth.group_management"])
|
||||
self.client.force_login(user)
|
||||
create_link_menu_item(text="Alpha", url="http://www.example.com/alpha")
|
||||
folder = create_folder_menu_item(text="Folder")
|
||||
create_link_menu_item(
|
||||
text="Bravo", url="http://www.example.com/bravo", parent=folder
|
||||
)
|
||||
reset_menu_items_sync() # this simulates startup
|
||||
|
||||
# when
|
||||
response = self.client.get("/dashboard/")
|
||||
|
||||
# then
|
||||
self.assertEqual(response.status_code, HTTPStatus.OK)
|
||||
links = extract_links(response)
|
||||
# open_page_in_browser(response)
|
||||
self.assertEqual(links["/dashboard/"], "Dashboard")
|
||||
self.assertEqual(links["/groups/"], "Groups")
|
||||
self.assertEqual(links["/groupmanagement/requests/"], "Group Management")
|
||||
self.assertEqual(links["http://www.example.com/alpha"], "Alpha")
|
||||
self.assertEqual(links["http://www.example.com/bravo"], "Bravo")
|
||||
|
||||
def test_should_not_show_menu_entry_when_user_has_no_permission(self):
|
||||
# given
|
||||
user = create_user()
|
||||
self.client.force_login(user)
|
||||
reset_menu_items_sync()
|
||||
|
||||
# when
|
||||
response = self.client.get("/dashboard/")
|
||||
|
||||
# then
|
||||
self.assertEqual(response.status_code, HTTPStatus.OK)
|
||||
links = extract_links(response)
|
||||
self.assertEqual(links["/dashboard/"], "Dashboard")
|
||||
self.assertEqual(links["/groups/"], "Groups")
|
||||
self.assertNotIn("/groupmanagement/requests/", links)
|
||||
|
||||
def test_should_not_show_menu_entry_when_hidden(self):
|
||||
# given
|
||||
user = create_user()
|
||||
self.client.force_login(user)
|
||||
create_link_menu_item(text="Alpha", url="http://www.example.com/")
|
||||
reset_menu_items_sync()
|
||||
|
||||
# when
|
||||
response = self.client.get("/dashboard/")
|
||||
|
||||
# then
|
||||
self.assertEqual(response.status_code, HTTPStatus.OK)
|
||||
links = extract_links(response)
|
||||
self.assertEqual(links["/dashboard/"], "Dashboard")
|
||||
self.assertEqual(links["/groups/"], "Groups")
|
||||
self.assertNotIn("http://www.example.com/alpha", links)
|
||||
|
||||
|
||||
class TestBS3DashboardWithSideMenu(TestCase):
|
||||
def test_should_not_show_group_management_when_user_has_no_permission(self):
|
||||
# given
|
||||
user = create_user()
|
||||
self.client.force_login(user)
|
||||
|
||||
# when
|
||||
response = self.client.get("/dashboard_bs3/")
|
||||
|
||||
# then
|
||||
self.assertEqual(response.status_code, HTTPStatus.OK)
|
||||
links = extract_links(response)
|
||||
self.assertEqual(links["/dashboard/"], "Dashboard")
|
||||
self.assertEqual(links["/groups/"], "Groups")
|
||||
self.assertNotIn("/groupmanagement/requests/", links)
|
||||
|
||||
def test_should_show_group_management_when_user_has_permission(self):
|
||||
# given
|
||||
user = create_user(permissions=["auth.group_management"])
|
||||
self.client.force_login(user)
|
||||
|
||||
# when
|
||||
response = self.client.get("/dashboard_bs3/")
|
||||
|
||||
# then
|
||||
self.assertEqual(response.status_code, HTTPStatus.OK)
|
||||
links = extract_links(response)
|
||||
self.assertEqual(links["/dashboard/"], "Dashboard")
|
||||
self.assertEqual(links["/groups/"], "Groups")
|
||||
self.assertEqual(links["/groupmanagement/requests/"], "Group Management")
|
0
allianceauth/menu/tests/templatetags/__init__.py
Normal file
0
allianceauth/menu/tests/templatetags/__init__.py
Normal file
54
allianceauth/menu/tests/templatetags/test_menu_items.py
Normal file
54
allianceauth/menu/tests/templatetags/test_menu_items.py
Normal file
@ -0,0 +1,54 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import RequestFactory, TestCase
|
||||
|
||||
from allianceauth.menu.templatetags.menu_items import render_menu
|
||||
from allianceauth.menu.tests.factories import create_menu_item_hook_function
|
||||
from allianceauth.menu.tests.utils import PACKAGE_PATH, render_template
|
||||
|
||||
MODULE_PATH = PACKAGE_PATH + ".templatetags.menu_items"
|
||||
|
||||
|
||||
class TestTemplateTags(TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
super().setUpClass()
|
||||
cls.factory = RequestFactory()
|
||||
|
||||
@patch(MODULE_PATH + ".render_menu", spec=True)
|
||||
def test_menu_items(self, mock_render_menu):
|
||||
# given
|
||||
mock_render_menu.return_value = ["Alpha"]
|
||||
request = self.factory.get("/")
|
||||
|
||||
# when
|
||||
rendered = render_template(
|
||||
"{% load menu_items %}{% menu_items %}",
|
||||
context={"request": request},
|
||||
)
|
||||
self.assertIn("Alpha", rendered)
|
||||
|
||||
|
||||
@patch(MODULE_PATH + ".get_hooks", spec=True)
|
||||
class TestRenderMenu(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def test_should_render_menu_in_order(self, mock_get_hooks):
|
||||
# given
|
||||
mock_get_hooks.return_value = [
|
||||
create_menu_item_hook_function(text="Charlie"),
|
||||
create_menu_item_hook_function(text="Alpha", order=1),
|
||||
create_menu_item_hook_function(text="Bravo", order=2),
|
||||
]
|
||||
request = self.factory.get("/")
|
||||
|
||||
# when
|
||||
result = render_menu(request)
|
||||
|
||||
# then
|
||||
menu = list(result)
|
||||
self.assertEqual(len(menu), 3)
|
||||
self.assertIn("Alpha", menu[0])
|
||||
self.assertIn("Bravo", menu[1])
|
||||
self.assertIn("Charlie", menu[2])
|
326
allianceauth/menu/tests/templatetags/test_menu_menu_items.py
Normal file
326
allianceauth/menu/tests/templatetags/test_menu_menu_items.py
Normal file
@ -0,0 +1,326 @@
|
||||
from typing import List, NamedTuple, Optional
|
||||
from unittest.mock import patch
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from django.test import RequestFactory, TestCase
|
||||
|
||||
from allianceauth.menu.templatetags.menu_menu_items import (
|
||||
RenderedMenuItem,
|
||||
render_menu,
|
||||
)
|
||||
from allianceauth.menu.tests.factories import (
|
||||
create_app_menu_item,
|
||||
create_folder_menu_item,
|
||||
create_link_menu_item,
|
||||
create_menu_item_from_hook,
|
||||
create_menu_item_hook_function,
|
||||
create_rendered_menu_item,
|
||||
)
|
||||
from allianceauth.menu.tests.utils import (
|
||||
PACKAGE_PATH,
|
||||
remove_whitespaces,
|
||||
render_template,
|
||||
)
|
||||
|
||||
MODULE_PATH = PACKAGE_PATH + ".templatetags.menu_menu_items"
|
||||
|
||||
|
||||
class TestTemplateTags(TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
super().setUpClass()
|
||||
cls.factory = RequestFactory()
|
||||
|
||||
@patch(MODULE_PATH + ".render_menu", spec=True)
|
||||
@patch(MODULE_PATH + ".smart_sync.sync_menu", spec=True)
|
||||
def test_sorted_menu_items(self, mock_sync_menu, mock_render_menu):
|
||||
# given
|
||||
fake_item = {"html": "Alpha"}
|
||||
mock_render_menu.return_value = [fake_item]
|
||||
request = self.factory.get("/")
|
||||
|
||||
# when
|
||||
rendered = render_template(
|
||||
"{% load menu_menu_items %}{% menu_items %}",
|
||||
context={"request": request},
|
||||
)
|
||||
self.assertIn("Alpha", rendered)
|
||||
self.assertTrue(mock_sync_menu.called)
|
||||
|
||||
|
||||
@patch(MODULE_PATH + ".get_hooks", spec=True)
|
||||
class TestRenderDefaultMenu(TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
super().setUpClass()
|
||||
cls.factory = RequestFactory()
|
||||
|
||||
def test_should_render_app_menu_items(self, mock_get_hooks):
|
||||
# given
|
||||
menu = [
|
||||
create_menu_item_hook_function(text="Charlie", count=42),
|
||||
create_menu_item_hook_function(text="Alpha", order=1),
|
||||
create_menu_item_hook_function(text="Bravo", order=2),
|
||||
]
|
||||
mock_get_hooks.return_value = menu
|
||||
for hook in menu:
|
||||
create_menu_item_from_hook(hook)
|
||||
|
||||
request = self.factory.get("/")
|
||||
|
||||
# when
|
||||
result = render_menu(request)
|
||||
|
||||
# then
|
||||
menu = list(result)
|
||||
self.assertEqual(len(menu), 3)
|
||||
self.assertEqual(menu[0].menu_item.text, "Alpha")
|
||||
self.assertEqual(menu[1].menu_item.text, "Bravo")
|
||||
self.assertEqual(menu[2].menu_item.text, "Charlie")
|
||||
self.assertEqual(menu[2].count, 42)
|
||||
attrs = parse_html(menu[2])
|
||||
self.assertEqual(attrs.count, 42)
|
||||
self.assertEqual(attrs.text, "Charlie")
|
||||
|
||||
def test_should_render_link_menu_items(self, mock_get_hooks):
|
||||
# given
|
||||
mock_get_hooks.return_value = []
|
||||
create_link_menu_item(text="Charlie"),
|
||||
create_link_menu_item(text="Alpha", order=1),
|
||||
create_link_menu_item(text="Bravo", order=2),
|
||||
|
||||
request = self.factory.get("/")
|
||||
|
||||
# when
|
||||
result = render_menu(request)
|
||||
|
||||
# then
|
||||
menu = list(result)
|
||||
self.assertEqual(len(menu), 3)
|
||||
self.assertEqual(menu[0].menu_item.text, "Alpha")
|
||||
self.assertEqual(menu[1].menu_item.text, "Bravo")
|
||||
self.assertEqual(menu[2].menu_item.text, "Charlie")
|
||||
attrs = parse_html(menu[2])
|
||||
self.assertEqual(attrs.text, "Charlie")
|
||||
|
||||
def test_should_render_folders(self, mock_get_hooks):
|
||||
# given
|
||||
mock_get_hooks.return_value = []
|
||||
folder = create_folder_menu_item(text="Folder", order=2)
|
||||
create_link_menu_item(text="Alpha", order=1)
|
||||
create_link_menu_item(text="Bravo", order=3)
|
||||
create_link_menu_item(text="Charlie", parent=folder)
|
||||
|
||||
request = self.factory.get("/")
|
||||
|
||||
# when
|
||||
result = render_menu(request)
|
||||
|
||||
# then
|
||||
menu = list(result)
|
||||
self.assertEqual(len(menu), 3)
|
||||
self.assertEqual(menu[0].menu_item.text, "Alpha")
|
||||
self.assertEqual(menu[1].menu_item.text, "Folder")
|
||||
self.assertEqual(menu[2].menu_item.text, "Bravo")
|
||||
|
||||
self.assertEqual(menu[1].children[0].menu_item.text, "Charlie")
|
||||
attrs = parse_html(menu[1].children[0])
|
||||
self.assertEqual(attrs.text, "Charlie")
|
||||
|
||||
def test_should_render_folder_properties(self, mock_get_hooks):
|
||||
# given
|
||||
# given
|
||||
menu = [
|
||||
create_menu_item_hook_function(text="Charlie", count=42),
|
||||
create_menu_item_hook_function(text="Alpha", count=5),
|
||||
create_menu_item_hook_function(text="Bravo"),
|
||||
]
|
||||
mock_get_hooks.return_value = menu
|
||||
|
||||
folder = create_folder_menu_item(text="Folder", order=1)
|
||||
for hook in menu:
|
||||
create_menu_item_from_hook(hook, parent=folder)
|
||||
|
||||
request = self.factory.get("/")
|
||||
|
||||
# when
|
||||
result = render_menu(request)
|
||||
|
||||
# then
|
||||
menu = list(result)
|
||||
self.assertEqual(len(menu), 1)
|
||||
item = menu[0]
|
||||
self.assertEqual(item.menu_item.text, "Folder")
|
||||
self.assertEqual(item.count, 47)
|
||||
self.assertTrue(item.is_folder)
|
||||
self.assertEqual(len(item.children), 3)
|
||||
attrs = parse_html(item)
|
||||
self.assertEqual(attrs.count, 47)
|
||||
self.assertIn("fa-folder", attrs.classes)
|
||||
|
||||
def test_should_remove_empty_folders(self, mock_get_hooks):
|
||||
# given
|
||||
mock_get_hooks.return_value = []
|
||||
create_folder_menu_item(text="Folder", order=2)
|
||||
create_link_menu_item(text="Alpha", order=1)
|
||||
create_link_menu_item(text="Bravo", order=3)
|
||||
|
||||
request = self.factory.get("/")
|
||||
|
||||
# when
|
||||
result = render_menu(request)
|
||||
|
||||
# then
|
||||
menu = list(result)
|
||||
self.assertEqual(len(menu), 2)
|
||||
self.assertEqual(menu[0].menu_item.text, "Alpha")
|
||||
self.assertEqual(menu[1].menu_item.text, "Bravo")
|
||||
|
||||
def test_should_not_include_hidden_items(self, mock_get_hooks):
|
||||
# given
|
||||
mock_get_hooks.return_value = []
|
||||
create_link_menu_item(text="Charlie"),
|
||||
create_link_menu_item(text="Alpha", order=1),
|
||||
create_link_menu_item(text="Bravo", order=2, is_hidden=True),
|
||||
|
||||
request = self.factory.get("/")
|
||||
|
||||
# when
|
||||
result = render_menu(request)
|
||||
|
||||
# then
|
||||
menu = list(result)
|
||||
self.assertEqual(len(menu), 2)
|
||||
self.assertEqual(menu[0].menu_item.text, "Alpha")
|
||||
self.assertEqual(menu[1].menu_item.text, "Charlie")
|
||||
|
||||
def test_should_not_render_hidden_folders(self, mock_get_hooks):
|
||||
# given
|
||||
# given
|
||||
menu = [
|
||||
create_menu_item_hook_function(text="Charlie", count=42),
|
||||
create_menu_item_hook_function(text="Alpha", count=5),
|
||||
create_menu_item_hook_function(text="Bravo"),
|
||||
]
|
||||
mock_get_hooks.return_value = menu
|
||||
|
||||
folder = create_folder_menu_item(text="Folder", order=1, is_hidden=True)
|
||||
for hook in menu:
|
||||
create_menu_item_from_hook(hook, parent=folder)
|
||||
|
||||
request = self.factory.get("/")
|
||||
|
||||
# when
|
||||
result = render_menu(request)
|
||||
|
||||
# then
|
||||
menu = list(result)
|
||||
self.assertEqual(len(menu), 0)
|
||||
|
||||
def test_should_allow_several_items_with_same_text(self, mock_get_hooks):
|
||||
# given
|
||||
mock_get_hooks.return_value = []
|
||||
create_link_menu_item(text="Alpha", order=1),
|
||||
create_link_menu_item(text="Alpha", order=2),
|
||||
|
||||
request = self.factory.get("/")
|
||||
|
||||
# when
|
||||
result = render_menu(request)
|
||||
|
||||
# then
|
||||
menu = list(result)
|
||||
self.assertEqual(len(menu), 2)
|
||||
self.assertEqual(menu[0].menu_item.text, "Alpha")
|
||||
self.assertEqual(menu[1].menu_item.text, "Alpha")
|
||||
|
||||
|
||||
class TestRenderedMenuItem(TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
super().setUpClass()
|
||||
cls.factory = RequestFactory()
|
||||
cls.template = "menu/menu-item-bs5.html"
|
||||
|
||||
def test_create_from_menu_item_with_defaults(self):
|
||||
# given
|
||||
item = create_link_menu_item()
|
||||
|
||||
# when
|
||||
obj = RenderedMenuItem(menu_item=item)
|
||||
|
||||
# then
|
||||
self.assertEqual(obj.menu_item, item)
|
||||
self.assertIsNone(obj.count)
|
||||
self.assertEqual(obj.html, "")
|
||||
self.assertEqual(obj.html_id, "")
|
||||
self.assertListEqual(obj.children, [])
|
||||
|
||||
def test_should_identify_if_item_is_a_folder(self):
|
||||
# given
|
||||
app_item = create_rendered_menu_item(menu_item=create_app_menu_item())
|
||||
link_item = create_rendered_menu_item(menu_item=create_link_menu_item())
|
||||
folder_item = create_rendered_menu_item(menu_item=create_folder_menu_item())
|
||||
|
||||
cases = [
|
||||
(app_item, False),
|
||||
(link_item, False),
|
||||
(folder_item, True),
|
||||
]
|
||||
# when
|
||||
for obj, expected in cases:
|
||||
with self.subTest(type=expected):
|
||||
self.assertIs(obj.is_folder, expected)
|
||||
|
||||
def test_should_update_html_for_link_item(self):
|
||||
# given
|
||||
obj = create_rendered_menu_item(menu_item=create_link_menu_item(text="Alpha"))
|
||||
request = self.factory.get("/")
|
||||
|
||||
# when
|
||||
obj.update_html(request, self.template)
|
||||
|
||||
# then
|
||||
parsed = parse_html(obj)
|
||||
self.assertEqual(parsed.text, "Alpha")
|
||||
self.assertIsNone(parsed.count)
|
||||
self.assertFalse(obj.html_id)
|
||||
|
||||
def test_should_update_html_for_folder_item(self):
|
||||
# given
|
||||
request = self.factory.get("/")
|
||||
folder_item = create_folder_menu_item(text="Alpha")
|
||||
link_item = create_link_menu_item(text="Bravo", parent=folder_item)
|
||||
obj = create_rendered_menu_item(menu_item=folder_item, count=42)
|
||||
rendered_link = create_rendered_menu_item(menu_item=link_item)
|
||||
rendered_link.update_html(request, self.template)
|
||||
obj.children.append(rendered_link)
|
||||
|
||||
# when
|
||||
obj.update_html(request, self.template)
|
||||
|
||||
# then
|
||||
self.assertTrue(obj.html_id)
|
||||
parsed_parent = parse_html(obj)
|
||||
self.assertEqual(parsed_parent.text, "Alpha")
|
||||
self.assertEqual(parsed_parent.count, 42)
|
||||
self.assertIn("Bravo", obj.html)
|
||||
|
||||
|
||||
class _ParsedMenuItem(NamedTuple):
|
||||
classes: List[str]
|
||||
text: str
|
||||
count: Optional[int]
|
||||
|
||||
|
||||
def parse_html(obj: RenderedMenuItem) -> _ParsedMenuItem:
|
||||
soup = BeautifulSoup(obj.html, "html.parser")
|
||||
classes = soup.li.i.attrs["class"]
|
||||
text = remove_whitespaces(soup.li.a.text)
|
||||
try:
|
||||
count = int(remove_whitespaces(soup.li.span.text))
|
||||
except (AttributeError, ValueError):
|
||||
count = None
|
||||
|
||||
return _ParsedMenuItem(classes=classes, text=text, count=count)
|
28
allianceauth/menu/tests/test_forms.py
Normal file
28
allianceauth/menu/tests/test_forms.py
Normal file
@ -0,0 +1,28 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from allianceauth.menu.constants import DEFAULT_FOLDER_ICON_CLASSES
|
||||
from allianceauth.menu.forms import FolderMenuItemAdminForm
|
||||
|
||||
|
||||
class TestFolderMenuItemAdminForm(TestCase):
|
||||
def test_should_set_default_icon_classes(self):
|
||||
# given
|
||||
form_data = {"text": "Alpha", "order": 1}
|
||||
form = FolderMenuItemAdminForm(data=form_data)
|
||||
|
||||
# when
|
||||
obj = form.save(commit=False)
|
||||
|
||||
# then
|
||||
self.assertEqual(obj.classes, DEFAULT_FOLDER_ICON_CLASSES)
|
||||
|
||||
def test_should_use_icon_classes_from_input(self):
|
||||
# given
|
||||
form_data = {"text": "Alpha", "order": 1, "classes": "dummy"}
|
||||
form = FolderMenuItemAdminForm(data=form_data)
|
||||
|
||||
# when
|
||||
obj = form.save(commit=False)
|
||||
|
||||
# then
|
||||
self.assertEqual(obj.classes, "dummy")
|
82
allianceauth/menu/tests/test_hooks.py
Normal file
82
allianceauth/menu/tests/test_hooks.py
Normal file
@ -0,0 +1,82 @@
|
||||
from django.test import RequestFactory, TestCase
|
||||
|
||||
from allianceauth.menu.hooks import MenuItemHook
|
||||
|
||||
from .factories import create_menu_item_hook
|
||||
|
||||
|
||||
class TestMenuItemHook(TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
super().setUpClass()
|
||||
cls.factory = RequestFactory()
|
||||
|
||||
def test_should_create_obj_with_minimal_params(self):
|
||||
# when
|
||||
obj = MenuItemHook("text", "classes", "url-name")
|
||||
|
||||
# then
|
||||
self.assertEqual(obj.text, "text")
|
||||
self.assertEqual(obj.classes, "classes")
|
||||
self.assertEqual(obj.url_name, "url-name")
|
||||
self.assertEqual(obj.template, "public/menuitem.html")
|
||||
self.assertEqual(obj.order, 9999)
|
||||
self.assertListEqual(obj.navactive, ["url-name"])
|
||||
self.assertIsNone(obj.count)
|
||||
|
||||
def test_should_create_obj_with_full_params_1(self):
|
||||
# when
|
||||
obj = MenuItemHook("text", "classes", "url-name", 5, ["navactive"])
|
||||
|
||||
# then
|
||||
self.assertEqual(obj.text, "text")
|
||||
self.assertEqual(obj.classes, "classes")
|
||||
self.assertEqual(obj.url_name, "url-name")
|
||||
self.assertEqual(obj.template, "public/menuitem.html")
|
||||
self.assertEqual(obj.order, 5)
|
||||
self.assertListEqual(obj.navactive, ["navactive", "url-name"])
|
||||
self.assertIsNone(obj.count)
|
||||
|
||||
def test_should_create_obj_with_full_params_2(self):
|
||||
# when
|
||||
obj = MenuItemHook(
|
||||
text="text",
|
||||
classes="classes",
|
||||
url_name="url-name",
|
||||
order=5,
|
||||
navactive=["navactive"],
|
||||
)
|
||||
|
||||
# then
|
||||
self.assertEqual(obj.text, "text")
|
||||
self.assertEqual(obj.classes, "classes")
|
||||
self.assertEqual(obj.url_name, "url-name")
|
||||
self.assertEqual(obj.template, "public/menuitem.html")
|
||||
self.assertEqual(obj.order, 5)
|
||||
self.assertListEqual(obj.navactive, ["navactive", "url-name"])
|
||||
self.assertIsNone(obj.count)
|
||||
|
||||
def test_should_render_menu_item(self):
|
||||
# given
|
||||
request = self.factory.get("/")
|
||||
hook = create_menu_item_hook(text="Alpha")
|
||||
|
||||
# when
|
||||
result = hook.render(request)
|
||||
|
||||
# then
|
||||
self.assertIn("Alpha", result)
|
||||
|
||||
def test_str(self):
|
||||
# given
|
||||
hook = create_menu_item_hook(text="Alpha")
|
||||
|
||||
# when/then
|
||||
self.assertEqual(str(hook), "Alpha")
|
||||
|
||||
def test_repr(self):
|
||||
# given
|
||||
hook = create_menu_item_hook(text="Alpha")
|
||||
|
||||
# when/then
|
||||
self.assertIn("Alpha", repr(hook))
|
103
allianceauth/menu/tests/test_managers.py
Normal file
103
allianceauth/menu/tests/test_managers.py
Normal file
@ -0,0 +1,103 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.db.models import QuerySet
|
||||
from django.test import TestCase
|
||||
|
||||
from allianceauth.menu.constants import MenuItemType
|
||||
from allianceauth.menu.models import MenuItem
|
||||
|
||||
from .factories import (
|
||||
create_app_menu_item,
|
||||
create_folder_menu_item,
|
||||
create_link_menu_item,
|
||||
create_menu_item_from_hook,
|
||||
create_menu_item_hook_function,
|
||||
)
|
||||
from .utils import PACKAGE_PATH
|
||||
|
||||
|
||||
class TestMenuItemQuerySet(TestCase):
|
||||
def test_should_add_item_type_field(self):
|
||||
# given
|
||||
app_item = create_app_menu_item()
|
||||
link_item = create_link_menu_item()
|
||||
folder_item = create_folder_menu_item()
|
||||
|
||||
# when
|
||||
result: QuerySet[MenuItem] = MenuItem.objects.annotate_item_type_2()
|
||||
|
||||
# then
|
||||
for obj in [app_item, link_item, folder_item]:
|
||||
obj = result.get(pk=app_item.pk)
|
||||
self.assertEqual(obj.item_type_2, obj.item_type)
|
||||
|
||||
def test_should_filter_folders(self):
|
||||
# given
|
||||
create_app_menu_item()
|
||||
create_link_menu_item()
|
||||
folder_item = create_folder_menu_item()
|
||||
|
||||
# when
|
||||
result: QuerySet[MenuItem] = MenuItem.objects.filter_folders()
|
||||
|
||||
# then
|
||||
item_pks = set(result.values_list("pk", flat=True))
|
||||
self.assertSetEqual(item_pks, {folder_item.pk})
|
||||
|
||||
|
||||
@patch(PACKAGE_PATH + ".managers.get_hooks", spec=True)
|
||||
class TestMenuItemManagerSyncAll(TestCase):
|
||||
def test_should_create_new_items_from_hooks_when_they_do_not_exist(
|
||||
self, mock_get_hooks
|
||||
):
|
||||
# given
|
||||
mock_get_hooks.return_value = [create_menu_item_hook_function(text="Alpha")]
|
||||
|
||||
# when
|
||||
MenuItem.objects.sync_all()
|
||||
|
||||
# then
|
||||
self.assertEqual(MenuItem.objects.count(), 1)
|
||||
obj = MenuItem.objects.first()
|
||||
self.assertEqual(obj.item_type, MenuItemType.APP)
|
||||
self.assertEqual(obj.text, "Alpha")
|
||||
|
||||
def test_should_update_existing_app_items_when_changed_only(self, mock_get_hooks):
|
||||
# given
|
||||
menu_hook_1 = create_menu_item_hook_function(text="Alpha", order=1)
|
||||
menu_hook_2 = create_menu_item_hook_function(text="Bravo", order=2)
|
||||
mock_get_hooks.return_value = [menu_hook_1, menu_hook_2]
|
||||
create_menu_item_from_hook(menu_hook_1, text="name has changed", order=99)
|
||||
create_menu_item_from_hook(menu_hook_2)
|
||||
|
||||
# when
|
||||
MenuItem.objects.sync_all()
|
||||
|
||||
# then
|
||||
self.assertEqual(MenuItem.objects.count(), 2)
|
||||
|
||||
obj = MenuItem.objects.get(text="Alpha")
|
||||
self.assertEqual(obj.item_type, MenuItemType.APP)
|
||||
self.assertEqual(obj.order, 99)
|
||||
|
||||
obj = MenuItem.objects.get(text="Bravo")
|
||||
self.assertEqual(obj.item_type, MenuItemType.APP)
|
||||
self.assertEqual(obj.order, 2)
|
||||
|
||||
def test_should_remove_obsolete_app_items_but_keep_user_items(self, mock_get_hooks):
|
||||
# given
|
||||
menu_hook = create_menu_item_hook_function(text="Alpha")
|
||||
mock_get_hooks.return_value = [menu_hook]
|
||||
create_app_menu_item(text="Bravo") # obsolete item
|
||||
link_item = create_link_menu_item()
|
||||
folder_item = create_folder_menu_item()
|
||||
|
||||
# when
|
||||
MenuItem.objects.sync_all()
|
||||
|
||||
# then
|
||||
self.assertEqual(MenuItem.objects.count(), 3)
|
||||
obj = MenuItem.objects.get(text="Alpha")
|
||||
self.assertTrue(obj.item_type, MenuItemType.APP)
|
||||
self.assertIn(link_item, MenuItem.objects.all())
|
||||
self.assertIn(folder_item, MenuItem.objects.all())
|
166
allianceauth/menu/tests/test_models.py
Normal file
166
allianceauth/menu/tests/test_models.py
Normal file
@ -0,0 +1,166 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from allianceauth.menu.constants import MenuItemType
|
||||
|
||||
from .factories import (
|
||||
create_app_menu_item,
|
||||
create_folder_menu_item,
|
||||
create_link_menu_item,
|
||||
)
|
||||
|
||||
|
||||
class TestMenuItem(TestCase):
|
||||
def test_str(self):
|
||||
# given
|
||||
obj = create_link_menu_item()
|
||||
# when
|
||||
result = str(obj)
|
||||
# then
|
||||
self.assertIsInstance(result, str)
|
||||
|
||||
def test_should_return_item_type(self):
|
||||
# given
|
||||
app_item = create_app_menu_item()
|
||||
link_item = create_link_menu_item()
|
||||
folder_item = create_folder_menu_item()
|
||||
|
||||
cases = [
|
||||
(app_item, MenuItemType.APP),
|
||||
(link_item, MenuItemType.LINK),
|
||||
(folder_item, MenuItemType.FOLDER),
|
||||
]
|
||||
# when
|
||||
for obj, expected in cases:
|
||||
with self.subTest(type=expected):
|
||||
self.assertEqual(obj.item_type, expected)
|
||||
|
||||
def test_should_identify_if_item_is_a_child(self):
|
||||
# given
|
||||
folder = create_folder_menu_item()
|
||||
child = create_link_menu_item(parent=folder)
|
||||
not_child = create_link_menu_item()
|
||||
|
||||
cases = [(child, True), (not_child, False)]
|
||||
# when
|
||||
for obj, expected in cases:
|
||||
with self.subTest(type=expected):
|
||||
self.assertIs(obj.is_child, expected)
|
||||
|
||||
def test_should_identify_if_item_is_a_folder(self):
|
||||
# given
|
||||
app_item = create_app_menu_item()
|
||||
link_item = create_link_menu_item()
|
||||
folder_item = create_folder_menu_item()
|
||||
|
||||
cases = [
|
||||
(app_item, False),
|
||||
(link_item, False),
|
||||
(folder_item, True),
|
||||
]
|
||||
# when
|
||||
for obj, expected in cases:
|
||||
with self.subTest(type=expected):
|
||||
self.assertIs(obj.is_folder, expected)
|
||||
|
||||
def test_should_identify_if_item_is_user_defined(self):
|
||||
# given
|
||||
app_item = create_app_menu_item()
|
||||
link_item = create_link_menu_item()
|
||||
folder_item = create_folder_menu_item()
|
||||
|
||||
cases = [
|
||||
(app_item, False),
|
||||
(link_item, True),
|
||||
(folder_item, True),
|
||||
]
|
||||
# when
|
||||
for obj, expected in cases:
|
||||
with self.subTest(type=expected):
|
||||
self.assertIs(obj.is_user_defined, expected)
|
||||
|
||||
def test_should_identify_if_item_is_an_app_item(self):
|
||||
# given
|
||||
app_item = create_app_menu_item()
|
||||
link_item = create_link_menu_item()
|
||||
folder_item = create_folder_menu_item()
|
||||
|
||||
cases = [
|
||||
(app_item, True),
|
||||
(link_item, False),
|
||||
(folder_item, False),
|
||||
]
|
||||
# when
|
||||
for obj, expected in cases:
|
||||
with self.subTest(type=expected):
|
||||
self.assertIs(obj.is_app_item, expected)
|
||||
|
||||
def test_should_identify_if_item_is_a_link_item(self):
|
||||
# given
|
||||
app_item = create_app_menu_item()
|
||||
link_item = create_link_menu_item()
|
||||
folder_item = create_folder_menu_item()
|
||||
|
||||
cases = [
|
||||
(app_item, False),
|
||||
(link_item, True),
|
||||
(folder_item, False),
|
||||
]
|
||||
# when
|
||||
for obj, expected in cases:
|
||||
with self.subTest(type=expected):
|
||||
self.assertIs(obj.is_link_item, expected)
|
||||
|
||||
def test_should_not_allow_creating_invalid_app_item(self):
|
||||
# when
|
||||
obj = create_app_menu_item(hook_hash="")
|
||||
|
||||
# then
|
||||
obj.refresh_from_db()
|
||||
self.assertIsNone(obj.hook_hash)
|
||||
|
||||
|
||||
class TestMenuItemToHookObj(TestCase):
|
||||
def test_should_create_from_link_item(self):
|
||||
# given
|
||||
obj = create_link_menu_item(text="Alpha")
|
||||
|
||||
# when
|
||||
hook_obj = obj.to_hook_obj()
|
||||
|
||||
# then
|
||||
self.assertEqual(hook_obj.text, "Alpha")
|
||||
self.assertEqual(hook_obj.url, obj.url)
|
||||
self.assertEqual(hook_obj.html_id, "")
|
||||
self.assertFalse(hook_obj.is_folder)
|
||||
|
||||
def test_should_create_from_folder(self):
|
||||
# given
|
||||
obj = create_folder_menu_item(text="Alpha", classes="dummy")
|
||||
|
||||
# when
|
||||
hook_obj = obj.to_hook_obj()
|
||||
|
||||
# then
|
||||
self.assertEqual(hook_obj.text, "Alpha")
|
||||
self.assertEqual(hook_obj.classes, "dummy")
|
||||
self.assertEqual(hook_obj.url, "")
|
||||
self.assertTrue(hook_obj.html_id)
|
||||
self.assertTrue(hook_obj.is_folder)
|
||||
|
||||
def test_should_create_from_folder_and_use_default_icon_classes(self):
|
||||
# given
|
||||
obj = create_folder_menu_item(classes="")
|
||||
|
||||
# when
|
||||
hook_obj = obj.to_hook_obj()
|
||||
|
||||
# then
|
||||
self.assertEqual(hook_obj.classes, "fa-solid fa-folder")
|
||||
|
||||
def test_should_create_from_app_item(self):
|
||||
# given
|
||||
obj = create_app_menu_item(text="Alpha")
|
||||
|
||||
# when
|
||||
with self.assertRaises(ValueError):
|
||||
obj.to_hook_obj()
|
47
allianceauth/menu/tests/utils.py
Normal file
47
allianceauth/menu/tests/utils.py
Normal file
@ -0,0 +1,47 @@
|
||||
import tempfile
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.template import Context, Template
|
||||
|
||||
PACKAGE_PATH = "allianceauth.menu"
|
||||
|
||||
|
||||
def extract_links(response: HttpResponse) -> dict:
|
||||
soup = extract_html(response)
|
||||
links = {
|
||||
link["href"]: "".join(link.stripped_strings)
|
||||
for link in soup.find_all("a", href=True)
|
||||
}
|
||||
return links
|
||||
|
||||
|
||||
def extract_html(response: HttpResponse) -> BeautifulSoup:
|
||||
soup = BeautifulSoup(response.content, "html.parser")
|
||||
return soup
|
||||
|
||||
|
||||
def open_page_in_browser(response: HttpResponse):
|
||||
"""Open the response in the system's default browser.
|
||||
|
||||
This will create a temporary file in the user's home.
|
||||
"""
|
||||
path = Path.home() / "temp"
|
||||
path.mkdir(exist_ok=True)
|
||||
|
||||
with tempfile.NamedTemporaryFile(dir=path, delete=False) as file:
|
||||
file.write(response.content)
|
||||
webbrowser.open(file.name)
|
||||
|
||||
|
||||
def render_template(string, context=None):
|
||||
context = context or {}
|
||||
context = Context(context)
|
||||
return Template(string).render(context)
|
||||
|
||||
|
||||
def remove_whitespaces(s) -> str:
|
||||
return s.replace("\n", "").strip()
|
@ -8,9 +8,10 @@ If you wish to make changes, overload the setting in your project's settings fil
|
||||
|
||||
import os
|
||||
|
||||
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',
|
||||
|
@ -1,6 +1,6 @@
|
||||
{% load i18n %}
|
||||
{% load navactive %}
|
||||
{% load menu_menu_items %}
|
||||
{% load menu_items %}
|
||||
|
||||
<div class="col-sm-2 auth-side-navbar" role="navigation">
|
||||
<div class="collapse navbar-collapse auth-menus-collapse auth-side-navbar-collapse">
|
||||
|
@ -2,20 +2,21 @@ import datetime
|
||||
import logging
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import (
|
||||
LoginRequiredMixin, PermissionRequiredMixin,
|
||||
)
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.shortcuts import render, redirect
|
||||
from django.views import View
|
||||
from django.urls import reverse_lazy
|
||||
from django.views.generic import CreateView, UpdateView, DeleteView
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views import View
|
||||
from django.views.generic import CreateView, DeleteView, UpdateView
|
||||
|
||||
from allianceauth.eveonline.models import EveCorporationInfo
|
||||
from allianceauth.timerboard.form import TimerForm
|
||||
from allianceauth.timerboard.models import Timer
|
||||
from allianceauth.eveonline.models import EveCorporationInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -94,7 +95,7 @@ class RemoveTimerView(TimerManagementView, DeleteView):
|
||||
def dashboard_timers(request):
|
||||
try:
|
||||
corp = request.user.profile.main_character.corporation
|
||||
except EveCorporationInfo.DoesNotExist:
|
||||
except (EveCorporationInfo.DoesNotExist, AttributeError):
|
||||
return ""
|
||||
|
||||
timers = Timer.objects.select_related('eve_character').filter((Q(eve_corp__isnull=True) | Q(eve_corp=corp)) ,eve_time__gte=timezone.now())[:5]
|
||||
|
@ -41,6 +41,13 @@ CACHES = {
|
||||
}
|
||||
}
|
||||
|
||||
##########################
|
||||
# Django ESI Configuration
|
||||
##########################
|
||||
ESI_SSO_CLIENT_ID = "dummy"
|
||||
ESI_SSO_CLIENT_SECRET = "dummy"
|
||||
ESI_SSO_CALLBACK_URL = f"{SITE_URL}/sso/callback"
|
||||
|
||||
########################
|
||||
# XenForo Configuration
|
||||
########################
|
||||
|
@ -29,3 +29,11 @@ PASSWORD_HASHERS = [
|
||||
LOGGING = None # Comment out to enable logging for debugging
|
||||
|
||||
ALLIANCEAUTH_DASHBOARD_TASK_STATISTICS_DISABLED = True # disable for tests
|
||||
|
||||
|
||||
##########################
|
||||
# Django ESI Configuration
|
||||
##########################
|
||||
ESI_SSO_CLIENT_ID = "dummy"
|
||||
ESI_SSO_CLIENT_SECRET = "dummy"
|
||||
ESI_SSO_CALLBACK_URL = f"{SITE_URL}/sso/callback"
|
||||
|
Loading…
x
Reference in New Issue
Block a user