Improve menu app

This commit is contained in:
Erik Kalkoken 2024-02-17 07:56:38 +00:00 committed by Ariel Rin
parent 2a762df9b3
commit 62c936f1c0
50 changed files with 2185 additions and 424 deletions

View File

@ -19,5 +19,6 @@ exclude_lines =
if __name__ == .__main__.:
def __repr__
raise AssertionError
if TYPE_CHECKING:
ignore_errors = True

View File

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

View File

@ -0,0 +1,10 @@
{% extends 'allianceauth/base.html' %}
{% block page_title %}Dashboard{% endblock page_title %}
{% block content %}
<div>
<h1>Dashboard Dummy</h1>
</div>
{% endblock %}

View File

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

View File

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

View File

@ -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,

View File

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

View File

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

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

View File

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

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

View 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

View 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"}),
}

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,7 +15,7 @@
</a>
</li>
{% sorted_menu_items %}
{% menu_items %}
</ul>
{% include 'menu/menu-logo.html' %}

View 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

View File

@ -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]

View File

View File

View 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, [])

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

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

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

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

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

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

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

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

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

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

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

View File

@ -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',

View File

@ -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">

View File

@ -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]

View File

@ -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
########################

View File

@ -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"