mirror of
https://gitlab.com/allianceauth/allianceauth.git
synced 2026-02-04 22:26:19 +01:00
Compare commits
194 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
179c26975c | ||
|
|
e17f6e799b | ||
|
|
7cd8294104 | ||
|
|
ede5540335 | ||
|
|
747279b773 | ||
|
|
44f8b1c477 | ||
|
|
7c6ebd9bf6 | ||
|
|
430469b708 | ||
|
|
efbb3cee31 | ||
|
|
21094ed4dd | ||
|
|
5f326efc7e | ||
|
|
b6e34ace35 | ||
|
|
fe4a8965e3 | ||
|
|
23371c233d | ||
|
|
7a3bbf0d7f | ||
|
|
89a1bec9c1 | ||
|
|
1c1e70619a | ||
|
|
0ff4374efa | ||
|
|
18d0e58a48 | ||
|
|
84f44338dc | ||
|
|
2ba0412890 | ||
|
|
2326522b29 | ||
|
|
a7cb6ee434 | ||
|
|
2aeef63565 | ||
|
|
3c9e7335ef | ||
|
|
49067de325 | ||
|
|
471e7e29ae | ||
|
|
3ec5775406 | ||
|
|
e804d2b60d | ||
|
|
742438a95d | ||
|
|
5c60086baa | ||
|
|
e49041bb14 | ||
|
|
f3cbe91883 | ||
|
|
ea439a2176 | ||
|
|
56e1e76f11 | ||
|
|
634e7357be | ||
|
|
08dc88da1a | ||
|
|
3d206e445c | ||
|
|
64686cdad1 | ||
|
|
d7fe09bdf1 | ||
|
|
6da50da92f | ||
|
|
51e4dd986f | ||
|
|
bee6522182 | ||
|
|
1711a9dd33 | ||
|
|
3914626379 | ||
|
|
df276cb32d | ||
|
|
daad7d8b10 | ||
|
|
3bf5bc0fe3 | ||
|
|
96abae553a | ||
|
|
f9cbfb1562 | ||
|
|
8eaa94e179 | ||
|
|
4f876b648b | ||
|
|
cd738137c0 | ||
|
|
5605eb129d | ||
|
|
87ef0f21a3 | ||
|
|
a1c7ce827e | ||
|
|
97466bcdfb | ||
|
|
ff3096b106 | ||
|
|
98f0d77f3f | ||
|
|
92548ba402 | ||
|
|
c46741d311 | ||
|
|
7c7c1abf7c | ||
|
|
fc303b1b0a | ||
|
|
4e220a9679 | ||
|
|
b17b1f7504 | ||
|
|
7081fc0e76 | ||
|
|
68e4574f19 | ||
|
|
e6e0a70012 | ||
|
|
13e38da942 | ||
|
|
468c1de26b | ||
|
|
22ef5ac0e5 | ||
|
|
ef2dc08958 | ||
|
|
6b84ffa16c | ||
|
|
d7a1096413 | ||
|
|
93b94a8bc2 | ||
|
|
9a95716105 | ||
|
|
dbfcf5d87a | ||
|
|
105d7d53b3 | ||
|
|
01cefe1457 | ||
|
|
7fe3db8017 | ||
|
|
e0945fac80 | ||
|
|
40fa190820 | ||
|
|
670580f8f3 | ||
|
|
323a0bcf16 | ||
|
|
6e995edd80 | ||
|
|
8d86e45b7a | ||
|
|
2aa6df4461 | ||
|
|
cf6f989502 | ||
|
|
3e1d8ae334 | ||
|
|
bcfe9484b5 | ||
|
|
5e4d1b9cfd | ||
|
|
3b463e7305 | ||
|
|
eedf5082fa | ||
|
|
2ea5b15175 | ||
|
|
7a9808aad3 | ||
|
|
a1d712694c | ||
|
|
ca11c726a3 | ||
|
|
6e0f7a35bd | ||
|
|
7375b001ca | ||
|
|
0287086633 | ||
|
|
9eb2becbb5 | ||
|
|
12f1444fe7 | ||
|
|
d6372bd093 | ||
|
|
3935a9cdd2 | ||
|
|
49fb6c39d5 | ||
|
|
8821f18b21 | ||
|
|
f4d8ead54e | ||
|
|
7427e13505 | ||
|
|
445683c3d5 | ||
|
|
677c46c48a | ||
|
|
87b9e3f87a | ||
|
|
da2a5aff2f | ||
|
|
65d77743dc | ||
|
|
1c7f8256d0 | ||
|
|
61f0aae5d9 | ||
|
|
4d56030edf | ||
|
|
2dfe194a3b | ||
|
|
ebb40deb7f | ||
|
|
a3a2d3d35b | ||
|
|
d4dee519b8 | ||
|
|
dfcbad3476 | ||
|
|
e03c1307a3 | ||
|
|
054ef27fa4 | ||
|
|
97e224b8e6 | ||
|
|
3b8fa415bc | ||
|
|
b94fd7ed19 | ||
|
|
d1dac61135 | ||
|
|
d2a095217f | ||
|
|
3a95b89779 | ||
|
|
4f5b231bdf | ||
|
|
40c0b8d862 | ||
|
|
62c936f1c0 | ||
|
|
2a762df9b3 | ||
|
|
8fb5a488f7 | ||
|
|
dc239d5396 | ||
|
|
2815ebaa07 | ||
|
|
34dd4802a8 | ||
|
|
3ebf11308c | ||
|
|
5cd5180a91 | ||
|
|
ba213db493 | ||
|
|
8362d11714 | ||
|
|
7ba65968ed | ||
|
|
7fdbac20cb | ||
|
|
76efcb5266 | ||
|
|
6aacb8c2e3 | ||
|
|
465fba3a18 | ||
|
|
9ae6addc71 | ||
|
|
76ae9b8849 | ||
|
|
13a8b7678f | ||
|
|
c166fc0ef9 | ||
|
|
9da61588eb | ||
|
|
7425176b3f | ||
|
|
2bb518f7e0 | ||
|
|
84d5693583 | ||
|
|
867e2a1ded | ||
|
|
fd6c6991f5 | ||
|
|
333fe8497d | ||
|
|
8a3fb17147 | ||
|
|
f8baeb19a7 | ||
|
|
10096862e5 | ||
|
|
89193d2fcf | ||
|
|
fd08b987bd | ||
|
|
4e9e22cb4b | ||
|
|
f8fbbb5ba7 | ||
|
|
dd3ef41396 | ||
|
|
d5fda05dc9 | ||
|
|
d815fad0e6 | ||
|
|
e109198782 | ||
|
|
fbd4672454 | ||
|
|
a29bd567c2 | ||
|
|
960aef95ad | ||
|
|
4aae5497bb | ||
|
|
6081cbe900 | ||
|
|
5e9b47cf79 | ||
|
|
853826c140 | ||
|
|
ce0d8342e3 | ||
|
|
006785e592 | ||
|
|
df05070a55 | ||
|
|
e81450baf3 | ||
|
|
24b6c19aca | ||
|
|
9f4bf13cc9 | ||
|
|
2a156302f0 | ||
|
|
c4d3bde106 | ||
|
|
9c7de58989 | ||
|
|
0900806f68 | ||
|
|
a0d32d8c2d | ||
|
|
51b86f88b9 | ||
|
|
8184461b48 | ||
|
|
ca0cdd6e15 | ||
|
|
036a17ad3b | ||
|
|
2418023ddd | ||
|
|
6e3219fd1b | ||
|
|
8aeb061635 | ||
|
|
84e2107b62 |
@@ -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
|
||||
|
||||
@@ -4,8 +4,21 @@
|
||||
# pre-commit autoupdate
|
||||
|
||||
repos:
|
||||
# Code Upgrades
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.15.2
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py38-plus]
|
||||
- repo: https://github.com/adamchainz/django-upgrade
|
||||
rev: 1.17.0
|
||||
hooks:
|
||||
- id: django-upgrade
|
||||
args: [--target-version=4.2]
|
||||
|
||||
# Formatting
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.4.0
|
||||
rev: v4.6.0
|
||||
hooks:
|
||||
# Identify invalid files
|
||||
- id: check-ast
|
||||
@@ -13,27 +26,24 @@ repos:
|
||||
- id: check-json
|
||||
- id: check-toml
|
||||
- id: check-xml
|
||||
|
||||
# git checks
|
||||
- id: check-merge-conflict
|
||||
- id: check-added-large-files
|
||||
args: [ --maxkb=1000 ]
|
||||
args: [--maxkb=1000]
|
||||
- id: detect-private-key
|
||||
- id: check-case-conflict
|
||||
|
||||
# Python checks
|
||||
# - id: check-docstring-first
|
||||
# - id: check-docstring-first
|
||||
- id: debug-statements
|
||||
# - id: requirements-txt-fixer
|
||||
# - id: requirements-txt-fixer
|
||||
- id: fix-encoding-pragma
|
||||
args: [ --remove ]
|
||||
args: [--remove]
|
||||
- id: fix-byte-order-marker
|
||||
|
||||
# General quality checks
|
||||
- id: mixed-line-ending
|
||||
args: [ --fix=lf ]
|
||||
args: [--fix=lf]
|
||||
- id: trailing-whitespace
|
||||
args: [ --markdown-linebreak-ext=md ]
|
||||
args: [--markdown-linebreak-ext=md]
|
||||
exclude: |
|
||||
(?x)(
|
||||
\.min\.css|
|
||||
@@ -52,9 +62,8 @@ repos:
|
||||
\.mo|
|
||||
swagger\.json
|
||||
)
|
||||
|
||||
- repo: https://github.com/editorconfig-checker/editorconfig-checker.python
|
||||
rev: 2.7.2
|
||||
rev: 2.7.3
|
||||
hooks:
|
||||
- id: editorconfig-checker
|
||||
exclude: |
|
||||
@@ -65,21 +74,26 @@ repos:
|
||||
\.mo|
|
||||
swagger\.json
|
||||
)
|
||||
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.10.1
|
||||
- repo: https://github.com/igorshubovych/markdownlint-cli
|
||||
rev: v0.41.0
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [ --py38-plus ]
|
||||
|
||||
- repo: https://github.com/adamchainz/django-upgrade
|
||||
rev: 1.14.0
|
||||
- id: markdownlint
|
||||
args:
|
||||
- --disable=MD013
|
||||
# Infrastructure
|
||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
||||
rev: 2.1.3
|
||||
hooks:
|
||||
- id: django-upgrade
|
||||
args: [--target-version=4.2]
|
||||
|
||||
- repo: https://github.com/asottile/setup-cfg-fmt
|
||||
rev: v2.3.0
|
||||
- id: pyproject-fmt
|
||||
name: pyproject.toml formatter
|
||||
description: "Format the pyproject.toml file."
|
||||
args:
|
||||
- --indent=4
|
||||
additional_dependencies:
|
||||
- tox==4.15.0 # https://github.com/tox-dev/tox/releases/latest
|
||||
- repo: https://github.com/abravalheri/validate-pyproject
|
||||
rev: v0.18
|
||||
hooks:
|
||||
- id: setup-cfg-fmt
|
||||
args: [ --include-version-classifiers ]
|
||||
- id: validate-pyproject
|
||||
name: Validate pyproject.toml
|
||||
description: "Validate the pyproject.toml file."
|
||||
|
||||
10
.tx/config
10
.tx/config
@@ -1,10 +0,0 @@
|
||||
[main]
|
||||
host = https://app.transifex.com
|
||||
lang_map = zh-Hans: zh_Hans
|
||||
|
||||
[o:alliance-auth:p:alliance-auth:r:django-po]
|
||||
file_filter = allianceauth/locale/<lang>/LC_MESSAGES/django.po
|
||||
source_file = allianceauth/locale/en/LC_MESSAGES/django.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
minimum_perc = 0
|
||||
@@ -5,7 +5,7 @@ manage online service access.
|
||||
# This will make sure the app is always imported when
|
||||
# Django starts so that shared_task will use this app.
|
||||
|
||||
__version__ = '4.0.0a5'
|
||||
__version__ = '4.1.0'
|
||||
__title__ = 'Alliance Auth'
|
||||
__url__ = 'https://gitlab.com/allianceauth/allianceauth'
|
||||
NAME = f'{__title__} v{__version__}'
|
||||
|
||||
@@ -9,6 +9,8 @@ from .utils import (
|
||||
install_stat_tokens,
|
||||
install_stat_users)
|
||||
|
||||
from allianceauth import __version__
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BASE_URL = "https://www.google-analytics.com"
|
||||
@@ -139,7 +141,7 @@ def send_ga_tracking_celery_event(
|
||||
'client_id': AnalyticsIdentifier.objects.get(id=1).identifier.hex,
|
||||
"user_properties": {
|
||||
"allianceauth_version": {
|
||||
"value": "allianceauth_version"
|
||||
"value": __version__
|
||||
}
|
||||
},
|
||||
'non_personalized_ads': True,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from allianceauth.hooks import DashboardItemHook
|
||||
from allianceauth import hooks
|
||||
from .views import dashboard_characters, dashboard_groups, dashboard_admin
|
||||
from .views import dashboard_characters, dashboard_esi_check, dashboard_groups, dashboard_admin
|
||||
|
||||
|
||||
class UserCharactersHook(DashboardItemHook):
|
||||
@@ -26,6 +26,15 @@ class AdminHook(DashboardItemHook):
|
||||
DashboardItemHook.__init__(
|
||||
self,
|
||||
dashboard_admin,
|
||||
1
|
||||
)
|
||||
|
||||
|
||||
class ESICheckHook(DashboardItemHook):
|
||||
def __init__(self):
|
||||
DashboardItemHook.__init__(
|
||||
self,
|
||||
dashboard_esi_check,
|
||||
0
|
||||
)
|
||||
|
||||
@@ -43,3 +52,8 @@ def register_groups_hook():
|
||||
@hooks.register('dashboard_hook')
|
||||
def register_admin_hook():
|
||||
return AdminHook()
|
||||
|
||||
|
||||
@hooks.register('dashboard_hook')
|
||||
def register_esi_hook():
|
||||
return ESICheckHook()
|
||||
|
||||
12
allianceauth/authentication/constants.py
Normal file
12
allianceauth/authentication/constants.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
# Overide ESI messages in the dashboard widget
|
||||
# when the returned messages are not helpful or out of date
|
||||
ESI_ERROR_MESSAGE_OVERRIDES = {
|
||||
420: _("This software has exceeded the error limit for ESI. "
|
||||
"If you are a user, please contact the maintainer of this software."
|
||||
" If you are a developer/maintainer, please make a greater "
|
||||
"effort in the future to receive valid responses. For tips on how, "
|
||||
"come have a chat with us in ##3rd-party-dev-and-esi on the EVE "
|
||||
"Online Discord. https://www.eveonline.com/discord")
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.13 on 2024-05-12 09:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('authentication', '0022_userprofile_theme'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='userprofile',
|
||||
name='language',
|
||||
field=models.CharField(blank=True, choices=[('en', 'English'), ('de', 'German'), ('es', 'Spanish'), ('zh-hans', 'Chinese Simplified'), ('ru', 'Russian'), ('ko', 'Korean'), ('fr', 'French'), ('ja', 'Japanese'), ('it', 'Italian'), ('uk', 'Ukrainian'), ('pl', 'Polish')], default='', max_length=10, verbose_name='Language'),
|
||||
),
|
||||
]
|
||||
@@ -78,6 +78,7 @@ class UserProfile(models.Model):
|
||||
JAPANESE = 'ja', _('Japanese')
|
||||
ITALIAN = 'it', _('Italian')
|
||||
UKRAINIAN = 'uk', _('Ukrainian')
|
||||
POLISH = 'pl', _("Polish")
|
||||
|
||||
user = models.OneToOneField(
|
||||
User,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{% extends 'allianceauth/base.html' %}
|
||||
|
||||
|
||||
{% block page_title %}Dashboard{% endblock page_title %}
|
||||
|
||||
{% block content %}
|
||||
<div>
|
||||
<h1>Dashboard Dummy</h1>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,13 +1,11 @@
|
||||
{% load i18n %}
|
||||
<div class="col-12 col-xl-8 align-self-stretch p-2 ps-0 pe-0 ps-xl-0 pe-xl-2">
|
||||
<div class="card">
|
||||
<div id="aa-dashboard-panel-characters" class="col-12 col-xl-8 align-self-stretch p-2 ps-0 pe-0 ps-xl-0 pe-xl-2">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<h4 class="ms-auto me-auto">
|
||||
{% translate "Characters" %}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% translate "Characters" as widget_title %}
|
||||
{% include "framework/dashboard/widget-title.html" with title=widget_title %}
|
||||
|
||||
<div>
|
||||
<div style="height: 300px; overflow-y:auto;">
|
||||
<div class="d-flex">
|
||||
<a href="{% url 'authentication:add_character' %}" class="btn btn-primary flex-fill m-1" title="{% translate 'Add Character' %}">
|
||||
@@ -1,9 +1,11 @@
|
||||
{% load i18n %}
|
||||
<div class="col-12 col-xl-4 align-self-stretch py-2 ps-xl-2">
|
||||
<div id="aa-dashboard-panel-membership" class="col-12 col-xl-4 align-self-stretch py-2 ps-xl-2">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title text-center">{% translate "Membership" %}</h4>
|
||||
<div class="card-body">
|
||||
{% translate "Membership" as widget_title %}
|
||||
{% include "framework/dashboard/widget-title.html" with title=widget_title %}
|
||||
|
||||
<div>
|
||||
<div style="height: 300px; overflow-y:auto;">
|
||||
<h5 class="text-center">{% translate "State:" %} {{ request.user.profile.state }}</h5>
|
||||
<table class="table">
|
||||
@@ -16,7 +16,7 @@
|
||||
{% translate "This page is a best attempt, but backups or database logs can still contain your tokens. Always revoke tokens on https://community.eveonline.com/support/third-party-applications/ where possible."|urlize %}
|
||||
</p>
|
||||
|
||||
<table class="table" id="table_tokens" style="width: 100%;">
|
||||
<table class="table w-100" id="table_tokens">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% translate "Scopes" %}</th>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{% load theme_tags %}
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -16,7 +17,7 @@
|
||||
|
||||
<title>{% block title %}{% block page_title %}{% endblock page_title %} - {{ SITE_NAME }}{% endblock title %}</title>
|
||||
|
||||
{% include 'bundles/bootstrap-css-bs5.html' %}
|
||||
{% theme_css %}
|
||||
{% include 'bundles/fontawesome.html' %}
|
||||
|
||||
{% block extra_include %}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<form action="{% url 'set_language' %}" method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<select onchange="this.form.submit()" class="form-control" id="lang-select" name="language">
|
||||
<select class="form-select" onchange="this.form.submit()" class="form-control" id="lang-select" name="language">
|
||||
{% get_language_info_list for LANGUAGES as languages %}
|
||||
|
||||
{% for language in languages %}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-6">
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.level_tag}}">{{ message }}</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends 'public/base.html' %}
|
||||
|
||||
{% load bootstrap %}
|
||||
{% load django_bootstrap5 %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block page_title %}{% translate "Registration" %}{% endblock %}
|
||||
@@ -12,16 +12,20 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="col-md-4 col-md-offset-4">
|
||||
<div class="panel panel-default panel-transparent">
|
||||
<div class="panel-body">
|
||||
<form method="POST">
|
||||
{% csrf_token %}
|
||||
{{ form|bootstrap }}
|
||||
<button class="btn btn-lg btn-primary btn-block" type="submit">{% translate "Register" %}</button>
|
||||
</form>
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card card-login border-secondary p-3">
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form form %}
|
||||
|
||||
<button class="btn btn-primary btn-block" type="submit">{% translate "Register" %}</button>
|
||||
</form>
|
||||
|
||||
{% include 'public/lang_select.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'public/lang_select.html' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import json
|
||||
import requests_mock
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import RequestFactory, TestCase
|
||||
|
||||
from allianceauth.authentication.views import task_counts
|
||||
from allianceauth.authentication.views import task_counts, esi_check
|
||||
from allianceauth.tests.auth_utils import AuthUtils
|
||||
from allianceauth.authentication.constants import ESI_ERROR_MESSAGE_OVERRIDES
|
||||
|
||||
MODULE_PATH = "allianceauth.authentication.views"
|
||||
|
||||
@@ -21,6 +23,8 @@ class TestRunningTasksCount(TestCase):
|
||||
super().setUpClass()
|
||||
cls.factory = RequestFactory()
|
||||
cls.user = AuthUtils.create_user("bruce_wayne")
|
||||
cls.user.is_superuser = True
|
||||
cls.user.save()
|
||||
|
||||
def test_should_return_data(
|
||||
self, mock_active_tasks_count, mock_queued_tasks_count
|
||||
@@ -35,5 +39,164 @@ class TestRunningTasksCount(TestCase):
|
||||
# then
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertDictEqual(
|
||||
jsonresponse_to_dict(response), {"tasks_running": 2, "tasks_queued": 3}
|
||||
jsonresponse_to_dict(response), {
|
||||
"tasks_running": 2, "tasks_queued": 3}
|
||||
)
|
||||
|
||||
def test_su_only(
|
||||
self, mock_active_tasks_count, mock_queued_tasks_count
|
||||
):
|
||||
self.user.is_superuser = False
|
||||
self.user.save()
|
||||
self.user.refresh_from_db()
|
||||
# given
|
||||
mock_active_tasks_count.return_value = 2
|
||||
mock_queued_tasks_count.return_value = 3
|
||||
request = self.factory.get("/")
|
||||
request.user = self.user
|
||||
# when
|
||||
response = task_counts(request)
|
||||
# then
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
|
||||
class TestEsiCheck(TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
super().setUpClass()
|
||||
cls.factory = RequestFactory()
|
||||
cls.user = AuthUtils.create_user("bruce_wayne")
|
||||
cls.user.is_superuser = True
|
||||
cls.user.save()
|
||||
|
||||
@requests_mock.Mocker()
|
||||
def test_401_data_returns_200(
|
||||
self, m
|
||||
):
|
||||
error_json = {
|
||||
"error": "You have been banned from using ESI. Please contact Technical Support. (support@eveonline.com)"
|
||||
}
|
||||
status_code = 401
|
||||
m.get(
|
||||
"https://esi.evetech.net/latest/status/?datasource=tranquility",
|
||||
text=json.dumps(error_json),
|
||||
status_code=status_code
|
||||
)
|
||||
# given
|
||||
request = self.factory.get("/")
|
||||
request.user = self.user
|
||||
# when
|
||||
response = esi_check(request)
|
||||
# then
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertDictEqual(
|
||||
jsonresponse_to_dict(response), {
|
||||
"status": status_code,
|
||||
"data": error_json
|
||||
}
|
||||
)
|
||||
|
||||
@requests_mock.Mocker()
|
||||
def test_504_data_returns_200(
|
||||
self, m
|
||||
):
|
||||
error_json = {
|
||||
"error": "Gateway timeout message",
|
||||
"timeout": 5000
|
||||
}
|
||||
status_code = 504
|
||||
m.get(
|
||||
"https://esi.evetech.net/latest/status/?datasource=tranquility",
|
||||
text=json.dumps(error_json),
|
||||
status_code=status_code
|
||||
)
|
||||
# given
|
||||
request = self.factory.get("/")
|
||||
request.user = self.user
|
||||
# when
|
||||
response = esi_check(request)
|
||||
# then
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertDictEqual(
|
||||
jsonresponse_to_dict(response), {
|
||||
"status": status_code,
|
||||
"data": error_json
|
||||
}
|
||||
)
|
||||
|
||||
@requests_mock.Mocker()
|
||||
def test_420_data_override(
|
||||
self, m
|
||||
):
|
||||
error_json = {
|
||||
"error": "message from CCP",
|
||||
}
|
||||
status_code = 420
|
||||
m.get(
|
||||
"https://esi.evetech.net/latest/status/?datasource=tranquility",
|
||||
text=json.dumps(error_json),
|
||||
status_code=status_code
|
||||
)
|
||||
# given
|
||||
request = self.factory.get("/")
|
||||
request.user = self.user
|
||||
# when
|
||||
response = esi_check(request)
|
||||
# then
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotEqual(
|
||||
jsonresponse_to_dict(response)["data"],
|
||||
error_json
|
||||
)
|
||||
self.assertDictEqual(
|
||||
jsonresponse_to_dict(response), {
|
||||
"status": status_code,
|
||||
"data": {
|
||||
"error": ESI_ERROR_MESSAGE_OVERRIDES.get(status_code)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@requests_mock.Mocker()
|
||||
def test_200_data_returns_200(
|
||||
self, m
|
||||
):
|
||||
good_json = {
|
||||
"players": 5,
|
||||
"server_version": "69420",
|
||||
"start_time": "2030-01-01T23:59:59Z"
|
||||
}
|
||||
status_code = 200
|
||||
|
||||
m.get(
|
||||
"https://esi.evetech.net/latest/status/?datasource=tranquility",
|
||||
text=json.dumps(good_json),
|
||||
status_code=status_code
|
||||
)
|
||||
# given
|
||||
request = self.factory.get("/")
|
||||
request.user = self.user
|
||||
# when
|
||||
response = esi_check(request)
|
||||
# then
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertDictEqual(
|
||||
jsonresponse_to_dict(response), {
|
||||
"status": status_code,
|
||||
"data": good_json
|
||||
}
|
||||
)
|
||||
|
||||
def test_su_only(
|
||||
self,
|
||||
):
|
||||
self.user.is_superuser = False
|
||||
self.user.save()
|
||||
self.user.refresh_from_db()
|
||||
# given
|
||||
request = self.factory.get("/")
|
||||
request.user = self.user
|
||||
# when
|
||||
response = esi_check(request)
|
||||
# then
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
@@ -38,5 +38,7 @@ 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'),
|
||||
path('esi-check/', views.esi_check, name='esi_check'),
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from allianceauth.hooks import get_hooks
|
||||
|
||||
import requests
|
||||
from django_registration.backends.activation.views import (
|
||||
REGISTRATION_SALT, ActivationView as BaseActivationView,
|
||||
RegistrationView as BaseRegistrationView,
|
||||
@@ -10,7 +10,7 @@ from django_registration.signals import user_registered
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import authenticate, login
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.decorators import login_required, user_passes_test
|
||||
from django.contrib.auth.models import User
|
||||
from django.core import signing
|
||||
from django.http import JsonResponse
|
||||
@@ -23,14 +23,16 @@ from esi.decorators import token_required
|
||||
from esi.models import Token
|
||||
|
||||
from allianceauth.eveonline.models import EveCharacter
|
||||
from allianceauth.hooks import get_hooks
|
||||
|
||||
from .constants import ESI_ERROR_MESSAGE_OVERRIDES
|
||||
from .core.celery_workers import active_tasks_count, queued_tasks_count
|
||||
from .forms import RegistrationForm
|
||||
from .models import CharacterOwnership
|
||||
|
||||
if 'allianceauth.eveonline.autogroups' in settings.INSTALLED_APPS:
|
||||
_has_auto_groups = True
|
||||
from allianceauth.eveonline.autogroups.models import *
|
||||
from allianceauth.eveonline.autogroups.models import * # noqa: F401, F403
|
||||
else:
|
||||
_has_auto_groups = False
|
||||
|
||||
@@ -54,7 +56,7 @@ def dashboard_groups(request):
|
||||
context = {
|
||||
'groups': groups,
|
||||
}
|
||||
return render_to_string('authentication/dashboard.groups.html', context=context, request=request)
|
||||
return render_to_string('authentication/dashboard_groups.html', context=context, request=request)
|
||||
|
||||
|
||||
def dashboard_characters(request):
|
||||
@@ -66,7 +68,7 @@ def dashboard_characters(request):
|
||||
context = {
|
||||
'characters': characters
|
||||
}
|
||||
return render_to_string('authentication/dashboard.characters.html', context=context, request=request)
|
||||
return render_to_string('authentication/dashboard_characters.html', context=context, request=request)
|
||||
|
||||
|
||||
def dashboard_admin(request):
|
||||
@@ -76,6 +78,13 @@ def dashboard_admin(request):
|
||||
return ""
|
||||
|
||||
|
||||
def dashboard_esi_check(request):
|
||||
if request.user.is_superuser:
|
||||
return render_to_string('allianceauth/admin-status/esi_check.html', request=request)
|
||||
else:
|
||||
return ""
|
||||
|
||||
|
||||
@login_required
|
||||
def dashboard(request):
|
||||
_dash_items = list()
|
||||
@@ -135,23 +144,30 @@ def token_refresh(request, token_id=None):
|
||||
@login_required
|
||||
@token_required(scopes=settings.LOGIN_TOKEN_SCOPES)
|
||||
def main_character_change(request, token):
|
||||
logger.debug(f"main_character_change called by user {request.user} for character {token.character_name}")
|
||||
logger.debug(
|
||||
f"main_character_change called by user {request.user} for character {token.character_name}")
|
||||
try:
|
||||
co = CharacterOwnership.objects.get(character__character_id=token.character_id, user=request.user)
|
||||
co = CharacterOwnership.objects.get(
|
||||
character__character_id=token.character_id, user=request.user)
|
||||
except CharacterOwnership.DoesNotExist:
|
||||
if not CharacterOwnership.objects.filter(character__character_id=token.character_id).exists():
|
||||
co = CharacterOwnership.objects.create_by_token(token)
|
||||
else:
|
||||
messages.error(
|
||||
request,
|
||||
_('Cannot change main character to %(char)s: character owned by a different account.') % ({'char': token.character_name})
|
||||
_('Cannot change main character to %(char)s: character owned by a different account.') % (
|
||||
{'char': token.character_name})
|
||||
)
|
||||
co = None
|
||||
if co:
|
||||
request.user.profile.main_character = co.character
|
||||
request.user.profile.save(update_fields=['main_character'])
|
||||
messages.success(request, _('Changed main character to %(char)s') % {"char": co.character})
|
||||
logger.info('Changed user %(user)s main character to %(char)s' % ({'user': request.user, 'char': co.character}))
|
||||
messages.success(request, _('Changed main character to %s') % co.character)
|
||||
logger.info(
|
||||
'Changed user {user} main character to {char}'.format(
|
||||
user=request.user, char=co.character
|
||||
)
|
||||
)
|
||||
return redirect("authentication:dashboard")
|
||||
|
||||
|
||||
@@ -159,9 +175,11 @@ def main_character_change(request, token):
|
||||
def add_character(request, token):
|
||||
if CharacterOwnership.objects.filter(character__character_id=token.character_id).filter(
|
||||
owner_hash=token.character_owner_hash).filter(user=request.user).exists():
|
||||
messages.success(request, _('Added %(name)s to your account.' % ({'name': token.character_name})))
|
||||
messages.success(request, _(
|
||||
'Added %(name)s to your account.' % ({'name': token.character_name})))
|
||||
else:
|
||||
messages.error(request, _('Failed to add %(name)s to your account: they already have an account.' % ({'name': token.character_name})))
|
||||
messages.error(request, _('Failed to add %(name)s to your account: they already have an account.' % (
|
||||
{'name': token.character_name})))
|
||||
return redirect('authentication:dashboard')
|
||||
|
||||
|
||||
@@ -204,8 +222,10 @@ def sso_login(request, token):
|
||||
token.delete()
|
||||
messages.error(
|
||||
request,
|
||||
_('Unable to authenticate as the selected character. '
|
||||
'Please log in with the main character associated with this account.')
|
||||
_(
|
||||
'Unable to authenticate as the selected character. '
|
||||
'Please log in with the main character associated with this account.'
|
||||
)
|
||||
)
|
||||
return redirect(settings.LOGIN_URL)
|
||||
|
||||
@@ -278,7 +298,8 @@ class RegistrationView(BaseRegistrationView):
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def register(self, form):
|
||||
user = User.objects.get(pk=self.request.session.get('registration_uid'))
|
||||
user = User.objects.get(
|
||||
pk=self.request.session.get('registration_uid'))
|
||||
user.email = form.cleaned_data['email']
|
||||
user_registered.send(self.__class__, user=user, request=self.request)
|
||||
if getattr(settings, 'REGISTRATION_VERIFY_EMAIL', True):
|
||||
@@ -295,7 +316,8 @@ class RegistrationView(BaseRegistrationView):
|
||||
|
||||
def get_email_context(self, activation_key):
|
||||
context = super().get_email_context(activation_key)
|
||||
context['url'] = context['site'].domain + reverse('registration_activate', args=[activation_key])
|
||||
context['url'] = context['site'].domain + \
|
||||
reverse('registration_activate', args=[activation_key])
|
||||
return context
|
||||
|
||||
|
||||
@@ -328,20 +350,24 @@ class ActivationView(BaseActivationView):
|
||||
|
||||
|
||||
def registration_complete(request):
|
||||
messages.success(request, _('Sent confirmation email. Please follow the link to confirm your email address.'))
|
||||
messages.success(request, _(
|
||||
'Sent confirmation email. Please follow the link to confirm your email address.'))
|
||||
return redirect('authentication:login')
|
||||
|
||||
|
||||
def activation_complete(request):
|
||||
messages.success(request, _('Confirmed your email address. Please login to continue.'))
|
||||
messages.success(request, _(
|
||||
'Confirmed your email address. Please login to continue.'))
|
||||
return redirect('authentication:dashboard')
|
||||
|
||||
|
||||
def registration_closed(request):
|
||||
messages.error(request, _('Registration of new accounts is not allowed at this time.'))
|
||||
messages.error(request, _(
|
||||
'Registration of new accounts is not allowed at this time.'))
|
||||
return redirect('authentication:login')
|
||||
|
||||
|
||||
@user_passes_test(lambda u: u.is_superuser)
|
||||
def task_counts(request) -> JsonResponse:
|
||||
"""Return task counts as JSON for an AJAX call."""
|
||||
data = {
|
||||
@@ -349,3 +375,31 @@ def task_counts(request) -> JsonResponse:
|
||||
"tasks_queued": queued_tasks_count()
|
||||
}
|
||||
return JsonResponse(data)
|
||||
|
||||
|
||||
def check_for_override_esi_error_message(response):
|
||||
if response.status_code in ESI_ERROR_MESSAGE_OVERRIDES:
|
||||
return {"error": ESI_ERROR_MESSAGE_OVERRIDES.get(response.status_code)}
|
||||
else:
|
||||
return response.json()
|
||||
|
||||
|
||||
@user_passes_test(lambda u: u.is_superuser)
|
||||
def esi_check(request) -> JsonResponse:
|
||||
"""Return if ESI ok With error messages and codes as JSON"""
|
||||
_r = requests.get("https://esi.evetech.net/latest/status/?datasource=tranquility")
|
||||
|
||||
data = {
|
||||
"status": _r.status_code,
|
||||
"data": check_for_override_esi_error_message(_r)
|
||||
}
|
||||
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')
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
<div class="card card-default mt-4">
|
||||
<div class="card-header clearfix" role="tablist">
|
||||
<ul class="nav nav-pills text-right float-start">
|
||||
<ul class="nav nav-pills float-start">
|
||||
<li class="nav-item" role="presentation">
|
||||
<a
|
||||
class="nav-link active"
|
||||
|
||||
57
allianceauth/framework/api/evecharacter.py
Normal file
57
allianceauth/framework/api/evecharacter.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Alliance Auth Evecharacter API
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from allianceauth.authentication.models import CharacterOwnership
|
||||
from allianceauth.eveonline.models import EveCharacter
|
||||
from allianceauth.framework.api.user import get_sentinel_user
|
||||
|
||||
|
||||
def get_main_character_from_evecharacter(
|
||||
character: EveCharacter,
|
||||
) -> Optional[EveCharacter]:
|
||||
"""
|
||||
Get the main character for a given EveCharacter or None when no main character is set
|
||||
|
||||
:param character:
|
||||
:type character:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
|
||||
try:
|
||||
userprofile = character.character_ownership.user.profile
|
||||
except (
|
||||
AttributeError,
|
||||
EveCharacter.character_ownership.RelatedObjectDoesNotExist,
|
||||
CharacterOwnership.user.RelatedObjectDoesNotExist,
|
||||
):
|
||||
return None
|
||||
|
||||
return userprofile.main_character
|
||||
|
||||
|
||||
def get_user_from_evecharacter(character: EveCharacter) -> User:
|
||||
"""
|
||||
Get the user for an EveCharacter or the sentinel user when no user is found
|
||||
|
||||
:param character:
|
||||
:type character:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
|
||||
try:
|
||||
userprofile = character.character_ownership.user.profile
|
||||
except (
|
||||
AttributeError,
|
||||
EveCharacter.character_ownership.RelatedObjectDoesNotExist,
|
||||
CharacterOwnership.user.RelatedObjectDoesNotExist,
|
||||
):
|
||||
return get_sentinel_user()
|
||||
|
||||
return userprofile.user
|
||||
@@ -6,17 +6,33 @@ from typing import Optional
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from allianceauth.authentication.models import CharacterOwnership
|
||||
from allianceauth.eveonline.models import EveCharacter
|
||||
|
||||
|
||||
def get_sentinel_user() -> User:
|
||||
def get_all_characters_from_user(user: User) -> list:
|
||||
"""
|
||||
Get the sentinel user or create one
|
||||
Get all characters from a user or an empty list
|
||||
when no characters are found for the user or the user is None
|
||||
|
||||
:param user:
|
||||
:type user:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
|
||||
return User.objects.get_or_create(username="deleted")[0]
|
||||
if user is None:
|
||||
return []
|
||||
|
||||
try:
|
||||
characters = [
|
||||
char.character for char in CharacterOwnership.objects.filter(user=user)
|
||||
]
|
||||
except AttributeError:
|
||||
return []
|
||||
|
||||
return characters
|
||||
|
||||
|
||||
def get_main_character_from_user(user: User) -> Optional[EveCharacter]:
|
||||
"""
|
||||
@@ -62,3 +78,13 @@ def get_main_character_name_from_user(user: User) -> str:
|
||||
return str(user)
|
||||
|
||||
return username
|
||||
|
||||
|
||||
def get_sentinel_user() -> User:
|
||||
"""
|
||||
Get the sentinel user or create one
|
||||
|
||||
:return:
|
||||
"""
|
||||
|
||||
return User.objects.get_or_create(username="deleted")[0]
|
||||
|
||||
@@ -13,6 +13,45 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Side Navigation
|
||||
------------------------------------------------------------------------------------- */
|
||||
@media all {
|
||||
#sidebar > div {
|
||||
width: 325px;
|
||||
}
|
||||
|
||||
/* Menu items in general */
|
||||
#sidebar-menu li > a,
|
||||
#sidebar-menu li > ul > li > a {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
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,
|
||||
#sidebar-menu [data-bs-toggle="collapse"].collapsed > i.fa-chevron-down {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Cursor classes
|
||||
------------------------------------------------------------------------------------- */
|
||||
@media all {
|
||||
@@ -72,9 +111,16 @@
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-left-width: 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
margin-top: 1.25rem;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.aa-callout.aa-callout-sm {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.aa-callout.aa-callout-lg {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* Last item bottom margin should be 0 */
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{#Usage:#}
|
||||
{# {% include "framework/dashboard/widget-title.html" with title="Foobar" %}#}
|
||||
|
||||
<div class="d-flex align-items-center">
|
||||
<h4 class="ms-auto me-auto mb-3">
|
||||
{{ title }}
|
||||
</h4>
|
||||
</div>
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
{% if subtitle %}
|
||||
<br>
|
||||
<small>{{ subtitle }}</small>
|
||||
<small class="text-muted">{{ subtitle }}</small>
|
||||
{% endif %}
|
||||
</h1>
|
||||
{% endif %}
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
{% block extra_javascript %}
|
||||
{% include 'bundles/datatables-js-bs5.html' %}
|
||||
{% include 'bundles/moment-js.html' with locale=True %}
|
||||
{# {% include 'bundles/filterdropdown-js.html' %}#}
|
||||
{% include 'bundles/filterdropdown-js.html' %}
|
||||
|
||||
<script>
|
||||
$.fn.dataTable.moment = (format, locale) => {
|
||||
@@ -117,7 +117,8 @@
|
||||
idx: 6
|
||||
}
|
||||
],
|
||||
bootstrap: true
|
||||
bootstrap: true,
|
||||
bootstrap_version: 5
|
||||
},
|
||||
"stateSave": true,
|
||||
"stateDuration": 0
|
||||
|
||||
@@ -30,7 +30,11 @@
|
||||
<tr>
|
||||
<th>{% translate "Name" %}</th>
|
||||
<th>{% translate "Description" %}</th>
|
||||
<th>{% translate "Leaders" %}<span class="m-1 fw-lighter badge bg-primary">{% translate "User" %}</span><span class="m-1 fw-lighter badge bg-secondary ">{% translate "Group" %}</span></th>
|
||||
<th>
|
||||
{% translate "Leaders" %}<br>
|
||||
<span class="my-1 me-1 fw-lighter badge bg-primary">{% translate "User" %}</span>
|
||||
<span class="my-1 me-1 fw-lighter badge bg-secondary">{% translate "Group" %}</span>
|
||||
</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -39,13 +43,23 @@
|
||||
{% for g in groups %}
|
||||
<tr>
|
||||
<td>{{ g.group.name }}</td>
|
||||
<td>{{ g.group.authgroup.description|linebreaks|urlize }}</td>
|
||||
<td>
|
||||
{% if g.group.authgroup.description %}
|
||||
{{ g.group.authgroup.description|linebreaks|urlize }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="max-width: 30%;">
|
||||
{% if g.group.authgroup.group_leaders.all.count %}
|
||||
{% for leader in g.group.authgroup.group_leaders.all %}{% if leader.profile.main_character %}<span class="m-1 badge bg-primary">{{leader.profile.main_character}}</span>{% endif %}{% endfor %}
|
||||
{% for leader in g.group.authgroup.group_leaders.all %}
|
||||
{% if leader.profile.main_character %}
|
||||
<span class="my-1 me-1 badge bg-primary">{{leader.profile.main_character}}</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if g.group.authgroup.group_leaders.all.count %}
|
||||
{% for group in g.group.authgroup.group_leader_groups.all %}<span class="badge bg-secondary">{{group.name}}</span>{% endfor %}
|
||||
{% for group in g.group.authgroup.group_leader_groups.all %}
|
||||
<span class="my-1 me-1 badge bg-secondary">{{group.name}}</span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
|
||||
@@ -92,6 +92,9 @@
|
||||
<a href="{% url 'groupmanagement:accept_request' acceptrequest.id %}" class="btn btn-success">
|
||||
{% translate "Accept" %}
|
||||
</a>
|
||||
<a href="{% url 'groupmanagement:reject_request' acceptrequest.id %}" class="btn btn-danger">
|
||||
{% translate "Reject" %}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
BIN
allianceauth/locale/cs/LC_MESSAGES/django.mo
Normal file
BIN
allianceauth/locale/cs/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
2776
allianceauth/locale/cs/LC_MESSAGES/django.po
Normal file
2776
allianceauth/locale/cs/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
BIN
allianceauth/locale/nl/LC_MESSAGES/django.mo
Normal file
BIN
allianceauth/locale/nl/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
2789
allianceauth/locale/nl/LC_MESSAGES/django.po
Normal file
2789
allianceauth/locale/nl/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
BIN
allianceauth/locale/pl_PL/LC_MESSAGES/django.mo
Normal file
BIN
allianceauth/locale/pl_PL/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
2848
allianceauth/locale/pl_PL/LC_MESSAGES/django.po
Normal file
2848
allianceauth/locale/pl_PL/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
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,37 +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-4{% endif %}">
|
||||
{{ item.count }}
|
||||
</span>
|
||||
{% elif item.url %}
|
||||
<span class="pill m-2 me-4 align-self-center fas fa-external-link-alt"></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.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 %}
|
||||
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-4 align-self-center fas fa-solid fa-chevron-down"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#id-{{ item.text|slugify }}"
|
||||
aria-expanded="false"
|
||||
aria-controls="">
|
||||
</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>
|
||||
|
||||
3
allianceauth/menu/templates/menu/menu-logo.html
Normal file
3
allianceauth/menu/templates/menu/menu-logo.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<div class="align-items-center text-center">
|
||||
{% include "bundles/image-auth-logo.html" %}
|
||||
</div>
|
||||
@@ -2,57 +2,91 @@
|
||||
{% load evelinks %}
|
||||
{% load theme_tags %}
|
||||
|
||||
<div style="z-index:5;" class="w100 d-flex flex-column justify-content-center align-items-center text-center pb-2">
|
||||
{% if user.is_authenticated %}
|
||||
{% if request.user.profile.main_character %}
|
||||
{% with request.user.profile.main_character as main %}
|
||||
<div class="p-2 position-relative m-2">
|
||||
<div id="aa-user-info" class="w-100 d-flex flex-column justify-content-center align-items-center text-center py-1 border-top border-secondary {% if not user.is_authenticated %}position-absolute bottom-0{% endif %}">
|
||||
<div class="d-flex mb-0 w-100">
|
||||
<div class="p-2 position-relative m-2">
|
||||
{% if user.is_authenticated %}
|
||||
{% with request.user.profile.main_character as main %}
|
||||
<img class="rounded-circle" src="{{ main.character_id|character_portrait_url:64 }}" alt="{{ main.character_name }}">
|
||||
<img class="rounded-circle position-absolute bottom-0 start-0" src="{{ main.corporation_logo_url_32 }}" alt="{{ main.corporation_name }}">
|
||||
|
||||
{% if main.alliance_id %}
|
||||
<img class="rounded-circle position-absolute bottom-0 end-0" src="{{ main.alliance_logo_url_32 }}" alt="{{ main.alliance_name }}">
|
||||
{% elif main.faction_id %}
|
||||
<img class="rounded-circle position-absolute bottom-0 end-0" src="{{ main.faction_logo_url_32 }}" alt="{{ main.faction_name }}">
|
||||
{% endif %}
|
||||
</div>
|
||||
<h5>{{ main.character_name }}</h5>
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<img class="rounded-circle m-2" src="{{ 1|character_portrait_url:32 }}" alt="{% translate 'No Main Character!' %}">
|
||||
<h5>{% translate "No Main Character!" %}</h5>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
{% include "bundles/image-auth-logo.html" with logo_width="64px" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="align-self-center text-start">
|
||||
{% if user.is_authenticated %}
|
||||
{% with request.user.profile.main_character as main %}
|
||||
<h5 class="m-0">{{ main.character_name }}</h5>
|
||||
<p class="m-0 small">{{ main.corporation_name }}</p>
|
||||
{% if main.alliance_id %}
|
||||
<p class="m-0 small">{{ main.alliance_name }}</p>
|
||||
{% elif main.faction_id %}
|
||||
<p class="m-0 small">{{ main.faction_name }}</p>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<h5 class="m-0">{{ SITE_NAME }}</h5>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="ms-auto dropup">
|
||||
<button type="button" class="h-100 btn" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="fa-solid fa-gear fa-fw text-light"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu" style="min-width: 200px;">
|
||||
<li><h6 class="dropdown-header">{% translate "Language" %}</h6></li>
|
||||
<li>
|
||||
<a class="dropdown-item">{% include "public/lang_select.html" %}</a>
|
||||
</li>
|
||||
<li><h6 class="dropdown-header">{% translate "Theme" %}</h6></li>
|
||||
|
||||
{% theme_select %}
|
||||
{% endif %}
|
||||
<li>
|
||||
<a class="dropdown-item">
|
||||
{% theme_select %}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<div class="btn-group m-2">
|
||||
<button type="button" class="btn btn-secondary p-1">
|
||||
{% include "public/lang_select.html" %}
|
||||
</button>
|
||||
|
||||
{% if user.is_superuser %}
|
||||
<a role="button" class="btn btn btn-secondary d-flex" href="{% url 'admin:index' %}">
|
||||
<span class="align-self-center">{% translate "Admin" %}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="btn-group m-2">
|
||||
{% if user.is_authenticated %}
|
||||
<a role="button" class="btn btn-info" href="{% url 'authentication:token_management' %}" title="Token Management"><i class="fa-solid fa-user-lock fa-fw"></i></a>
|
||||
{% endif %}
|
||||
|
||||
{% if user.is_superuser %}
|
||||
<a role="button" class="btn btn-info" href="https://allianceauth.readthedocs.io/" title="Alliance Auth Documentation"><i class="fa-solid fa-book fa-fw"></i></a>
|
||||
<a role="button" class="btn btn-info" href="https://discord.gg/fjnHAmk" title="Alliance Auth Discord"><i class="fa-brands fa-discord fa-fw"></i></a>
|
||||
<a role="button" class="btn btn-info" href="https://gitlab.com/allianceauth/allianceauth" title="Alliance Auth Git"><i class="fa-brands fa-gitlab fa-fw"></i></a>
|
||||
{% endif %}
|
||||
|
||||
{% if user.is_authenticated %}
|
||||
<a role="button" class="btn btn-danger" href="{% url 'logout' %}" title="{% translate 'Sign Out' %}"><i class="fa-solid fa-right-from-bracket fa-fw"></i></a>
|
||||
{% else %}
|
||||
<a role="button" class="btn btn-success" href="{% url 'authentication:login' %}" title="{% translate 'Sign In' %}"> <i class="fa-solid fa-right-to-bracket fa-fw"></i></a>
|
||||
{% endif %}
|
||||
{% if user.is_superuser %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><h6 class="dropdown-header">{% translate "Super User" %}</h6></li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="https://allianceauth.readthedocs.io/" title="Alliance Auth Documentation"><i class="fa-solid fa-book fa-fw"></i> Alliance Auth Documentation</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="https://discord.gg/fjnHAmk" title="Alliance Auth Discord"><i class="fa-brands fa-discord fa-fw"></i> Alliance Auth Discord</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="https://gitlab.com/allianceauth/allianceauth" title="Alliance Auth Git"><i class="fa-brands fa-gitlab fa-fw"></i> Alliance Auth Git</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if user.is_staff %}
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'admin:index' %}">
|
||||
<i class="fa-solid fa-gear fa-fw"></i> {% translate "Admin" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
{% if user.is_authenticated %}
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'authentication:token_management' %}">
|
||||
<i class="fa-solid fa-user-lock fa-fw"></i> Token Management
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item text-danger" href="{% url 'logout' %}" title="{% translate 'Sign Out' %}"><i class="fa-solid fa-right-from-bracket fa-fw "></i> {% translate 'Sign Out' %}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li>
|
||||
<a class="dropdown-item text-success" href="{% url 'authentication:login' %}" title="{% translate 'Sign In' %}"> <i class="fa-solid fa-right-to-bracket fa-fw "></i> {% translate 'Sign In' %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,19 +4,21 @@
|
||||
|
||||
<div class="col-auto px-0">
|
||||
<div class="collapse collapse-horizontal" tabindex="-1" id="sidebar">
|
||||
<div style="width: 350px;">
|
||||
<div class="nav-padding navbar-dark bg-dark text-light px-0 d-flex flex-column overflow-hidden vh-100 auth-logo">
|
||||
<div>
|
||||
<div class="nav-padding navbar-dark bg-dark text-light px-0 d-flex flex-column overflow-hidden vh-100 {% if not user.is_authenticated %}position-relative{% endif %}">
|
||||
{% if user.is_authenticated %}
|
||||
<ul style="z-index:5;" id="sidebar-menu" class="navbar-nav flex-column mb-auto overflow-auto pt-2">
|
||||
<ul id="sidebar-menu" class="navbar-nav flex-column mb-auto overflow-auto pt-2">
|
||||
<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 fas fa-tachometer-alt fa-fw align-self-center me-3 {% navactive request 'authentication:dashboard' %}"></i>
|
||||
<a class="nav-link flex-fill align-self-center" href="{% url 'authentication:dashboard' %}">
|
||||
<a class="nav-link flex-fill align-self-center {% navactive request 'authentication:dashboard' %}" href="{% url 'authentication:dashboard' %}">
|
||||
{% translate "Dashboard" %}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% sorted_menu_items %}
|
||||
{% menu_items %}
|
||||
</ul>
|
||||
|
||||
{% include 'menu/menu-logo.html' %}
|
||||
{% endif %}
|
||||
|
||||
{% include 'menu/menu-user.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,178 @@
|
||||
"""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)
|
||||
if rendered_item.html == "":
|
||||
# If there is no content dont render it.
|
||||
# This item has probably been hidden by permissions
|
||||
continue
|
||||
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)
|
||||
100
allianceauth/menu/tests/factories.py
Normal file
100
allianceauth/menu/tests/factories.py
Normal file
@@ -0,0 +1,100 @@
|
||||
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_class(**kwargs) -> MenuItemHook:
|
||||
num = next(counter_menu_item_hook)
|
||||
return type(f"GeneratedMenuItem{num}", (MenuItemHook,), {})
|
||||
|
||||
|
||||
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])
|
||||
364
allianceauth/menu/tests/templatetags/test_menu_menu_items.py
Normal file
364
allianceauth/menu/tests/templatetags/test_menu_menu_items.py
Normal file
@@ -0,0 +1,364 @@
|
||||
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_class,
|
||||
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_remove_empty_folders_with_items_hidden(self, mock_get_hooks):
|
||||
# given
|
||||
|
||||
class TestHook(create_menu_item_hook_class()):
|
||||
text = "Dummy App No Data"
|
||||
classes = "fa-solid fa-users-gear"
|
||||
url_name = "groupmanagement:management"
|
||||
|
||||
def render(Self, request):
|
||||
# simulate no perms
|
||||
return ""
|
||||
|
||||
params = {
|
||||
"text": "Alpha",
|
||||
"classes": "fa-solid fa-users-gear",
|
||||
"url_name": "groupmanagement:management",
|
||||
}
|
||||
|
||||
alpha = TestHook(**params)
|
||||
|
||||
hooks = [lambda: alpha]
|
||||
|
||||
mock_get_hooks.return_value = hooks
|
||||
|
||||
folder = create_folder_menu_item(text="Folder", order=2)
|
||||
create_menu_item_from_hook(hooks[0], parent=folder)
|
||||
create_link_menu_item(text="Bravo", order=3) # this is all that should show
|
||||
|
||||
request = self.factory.get("/")
|
||||
|
||||
# when
|
||||
result = render_menu(request)
|
||||
|
||||
# then
|
||||
menu = list(result)
|
||||
self.assertEqual(len(menu), 1)
|
||||
self.assertEqual(menu[0].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
|
||||
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()
|
||||
@@ -4,38 +4,37 @@
|
||||
<div class="col-12 align-self-stretch py-2">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title text-center">{% translate "Upcoming Fleets" %}</h4>
|
||||
{% translate "Upcoming Fleets" as widget_title %}
|
||||
{% include "framework/dashboard/widget-title.html" with title=widget_title %}
|
||||
|
||||
<div class="card-body">
|
||||
<div style="height: 300px; overflow-y:auto;">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-center">{% translate "Operation" %}</th>
|
||||
<th class="text-center">{% translate "Type" %}</th>
|
||||
<th class="text-center">{% translate "Form Up System" %}</th>
|
||||
<th class="text-center">{% translate "EVE Time" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{% for ops in timers %}
|
||||
<tr>
|
||||
<th class="text-center">{% translate "Operation" %}</th>
|
||||
<th class="text-center">{% translate "Type" %}</th>
|
||||
<th class="text-center">{% translate "Form Up System" %}</th>
|
||||
<th class="text-center">{% translate "Start Time" %}</th>
|
||||
<td class="text-center">
|
||||
{{ ops.operation_name }}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{{ ops.type }}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<a href="{{ ops.system|dotlan_solar_system_url }}">{{ ops.system }}</a>
|
||||
</td>
|
||||
<td class="text-center" nowrap>{{ ops.start | date:"Y-m-d H:i" }}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{% for ops in timers %}
|
||||
<tr>
|
||||
<td class="text-center">
|
||||
{{ ops.operation_name }}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{{ ops.type }}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<a href="{{ ops.system|dotlan_solar_system_url }}">{{ ops.system }}</a>
|
||||
</td>
|
||||
<td class="text-center" nowrap>{{ ops.start | date:"Y-m-d H:i" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,16 +11,20 @@
|
||||
{% translate "Fleet Operation Timers" %}
|
||||
{% endblock header_nav_brand %}
|
||||
|
||||
{% block header_nav_collapse_right %}
|
||||
{% if perms.auth.optimer_management %}
|
||||
<li class="nav-item">
|
||||
<a class="btn btn-success" href="{% url 'optimer:add' %}">
|
||||
{% translate "Create Operation" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endblock header_nav_collapse_right %}
|
||||
|
||||
{% block content %}
|
||||
<div>
|
||||
<div class="text-end">
|
||||
{% if perms.auth.optimer_management %}
|
||||
<a href="{% url 'optimer:add' %}" class="btn btn-success">{% translate "Create Operation" %}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="text-center mb-3">
|
||||
<div class="badge bg-info text-start">
|
||||
<div class="badge bg-primary text-start">
|
||||
<b>{% translate "Current Eve Time:" %}</b>
|
||||
<span id="current-time"></span>
|
||||
</div>
|
||||
|
||||
@@ -14,9 +14,11 @@ from .models import OpTimer, OpTimerType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OPS_VIEW_PERMISSION = 'auth.optimer_view'
|
||||
OPS_MANAGE_PERMISSION = 'auth.optimer_management'
|
||||
|
||||
@login_required
|
||||
@permission_required('auth.optimer_view')
|
||||
@permission_required(OPS_VIEW_PERMISSION)
|
||||
def optimer_view(request):
|
||||
"""
|
||||
View for the optimer management page
|
||||
@@ -39,7 +41,7 @@ def optimer_view(request):
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required('auth.optimer_management')
|
||||
@permission_required(OPS_MANAGE_PERMISSION)
|
||||
def add_optimer_view(request):
|
||||
"""
|
||||
View for the add optimer page
|
||||
@@ -98,7 +100,7 @@ def add_optimer_view(request):
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required('auth.optimer_management')
|
||||
@permission_required(OPS_MANAGE_PERMISSION)
|
||||
def remove_optimer(request, optimer_id):
|
||||
"""
|
||||
Remove optimer
|
||||
@@ -121,7 +123,7 @@ def remove_optimer(request, optimer_id):
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required('auth.optimer_management')
|
||||
@permission_required(OPS_MANAGE_PERMISSION)
|
||||
def edit_optimer(request, optimer_id):
|
||||
"""
|
||||
Edit optimer
|
||||
@@ -192,14 +194,22 @@ def dashboard_ops(request):
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
if request.user.has_perm(OPS_VIEW_PERMISSION):
|
||||
base_query = OpTimer.objects.select_related('eve_character', 'type')
|
||||
timers = base_query.filter(
|
||||
start__gte=timezone.now()
|
||||
)[:5]
|
||||
|
||||
base_query = OpTimer.objects.select_related('eve_character', 'type')
|
||||
timers = base_query.filter(start__gte=timezone.now())[:5]
|
||||
|
||||
if timers.count():
|
||||
context = {
|
||||
'timers': timers,
|
||||
}
|
||||
return render_to_string('optimer/dashboard.ops.html', context=context, request=request)
|
||||
if timers.count():
|
||||
context = {
|
||||
'timers': timers,
|
||||
}
|
||||
return render_to_string(
|
||||
'optimer/dashboard.ops.html',
|
||||
context=context,
|
||||
request=request
|
||||
)
|
||||
else:
|
||||
return ""
|
||||
else:
|
||||
return ""
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
</p>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped" id="tab_permissions_audit" style="width: 100%;">
|
||||
<table class="table table-striped w-100" id="tab_permissions_audit">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{% translate "Group" %}</th>
|
||||
@@ -55,7 +55,7 @@
|
||||
{% endblock content %}
|
||||
{% block extra_javascript %}
|
||||
{% include "bundles/datatables-js-bs5.html" %}
|
||||
{# {% include "bundles/filterdropdown-js.html" %}#}
|
||||
{% include "bundles/filterdropdown-js.html" %}
|
||||
|
||||
<script>
|
||||
$(document).ready(() => {
|
||||
@@ -75,7 +75,8 @@
|
||||
idx: 0,
|
||||
title: 'Source'
|
||||
}],
|
||||
bootstrap: true
|
||||
bootstrap: true,
|
||||
bootstrap_version: 5
|
||||
},
|
||||
"stateSave": true,
|
||||
"stateDuration": 0,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user