Compare commits

...

130 Commits

Author SHA1 Message Date
Ariel Rin
b17b1f7504 Version Bump 4.0.1 2024-03-21 19:34:02 +10:00
Ariel Rin
7081fc0e76 Merge branch 'docs' into 'master'
Documentation

See merge request allianceauth/allianceauth!1610
2024-03-21 09:17:43 +00:00
Ariel Rin
68e4574f19 Merge branch 'analytics' into 'master'
Pass Version to analytics user properties

See merge request allianceauth/allianceauth!1612
2024-03-21 09:12:53 +00:00
Ariel Rin
e6e0a70012 Pass Version to analytics user properties 2024-03-21 09:12:53 +00:00
Ariel Rin
13e38da942 Merge branch 'timers-row-background' into 'master'
[CHANGE] Restore the "old" color coding in table rows

See merge request allianceauth/allianceauth!1611
2024-03-21 09:12:25 +00:00
Peter Pfeufer
468c1de26b [CHANGE] restore the "old" color coding in table rows 2024-03-20 19:22:03 +01:00
Ariel Rin
22ef5ac0e5 raw file contents incase someone wgets 2024-03-16 15:44:39 +10:00
Ariel Rin
ef2dc08958 Version Bump 4.0.0 2024-03-16 14:17:29 +10:00
Ariel Rin
6b84ffa16c update pre-commit 2024-03-16 14:16:50 +10:00
Ariel Rin
d7a1096413 Merge branch 'translations_7f31a07ccd4e4a66b1dd7b6bc2dbddb5' into 'master'
Updates for project Alliance Auth

See merge request allianceauth/allianceauth!1595
2024-03-16 04:08:52 +00:00
Ariel Rin
93b94a8bc2 Updates for project Alliance Auth 2024-03-16 04:08:52 +00:00
Ariel Rin
9a95716105 Merge branch 'nav-active-passthrough' into 'master'
[Enhancement] Keep menu folders open when a sub item is active

See merge request allianceauth/allianceauth!1608
2024-03-16 04:00:06 +00:00
Aaron Kable
dbfcf5d87a [Enhancement] Keep menu folders open when a sub item is active 2024-03-16 04:00:06 +00:00
Ariel Rin
105d7d53b3 Merge branch 'no-perm-folders' into 'master'
[FIX] Hide empty folders in menu

See merge request allianceauth/allianceauth!1607
2024-03-16 03:59:54 +00:00
Aaron Kable
01cefe1457 [FIX] Hide empty folders in menu 2024-03-16 03:59:54 +00:00
Ariel Rin
7fe3db8017 drop pypy testing 2024-03-13 21:44:48 +10:00
Ariel Rin
e0945fac80 Version Bump 4.0.0rc1 2024-03-13 19:12:11 +10:00
Ariel Rin
40fa190820 Merge branch 'v4docs' into 'v4.x'
Expand Docker Docs for v4

See merge request allianceauth/allianceauth!1577
2024-03-13 09:06:10 +00:00
Ariel Rin
670580f8f3 Merge branch 'sw-version-button-same-height' into 'v4.x'
[FIX] Make SW Version buttons the same height

See merge request allianceauth/allianceauth!1606
2024-03-12 13:23:32 +00:00
Peter Pfeufer
323a0bcf16 [FIX] Make SW Version buttons the same height 2024-03-12 12:42:34 +01:00
Ariel Rin
6e995edd80 Merge branch 'sha-fixes' into 'v4.x'
[FIX] Bootstrap SHA hashes

See merge request allianceauth/allianceauth!1605
2024-03-12 11:30:16 +00:00
Peter Pfeufer
8d86e45b7a [FIX] Bootstrap SHA hashes 2024-03-12 12:29:21 +01:00
Ariel Rin
2aa6df4461 Merge branch 'bootswatch' into 'v4.x'
Bump and unify on bootstrap 5.3.3

See merge request allianceauth/allianceauth!1603
2024-03-12 03:36:19 +00:00
Ariel Rin
cf6f989502 Bump and unify on bootstrap 5.3.3 2024-03-12 03:36:19 +00:00
Ariel Rin
3e1d8ae334 Merge branch 'widgets' into 'v4.x'
When providing a single time, clarify EVE Time

See merge request allianceauth/allianceauth!1604
2024-03-12 03:34:33 +00:00
Ariel Rin
bcfe9484b5 When providing a single time, clarify EVE Time 2024-03-12 03:34:33 +00:00
Ariel Rin
5e4d1b9cfd Merge branch 'timerboard-perms' into 'v4.x'
Add Dashboard Widget View Perms

See merge request allianceauth/allianceauth!1602
2024-03-10 09:36:51 +00:00
Aaron Kable
3b463e7305 Fix timer query, Corp is not nullable 2024-03-08 17:37:11 +08:00
Aaron Kable
eedf5082fa remove the locked hieght from the ops/tiemrs dashboard widgets 2024-03-08 17:15:03 +08:00
Aaron Kable
2ea5b15175 Add perms to ops dashboard view 2024-03-08 17:14:36 +08:00
Aaron Kable
7a9808aad3 Add permision check to timerboard dashboard view 2024-03-08 15:54:39 +08:00
Ariel Rin
a1d712694c Merge branch 'theme-fixes' into 'v4.x'
[V4] Add reject button back to group management.

See merge request allianceauth/allianceauth!1601
2024-03-07 10:35:39 +00:00
Ariel Rin
ca11c726a3 Merge branch 'login-screen-theming' into 'v4.x'
[ADD] Theme CSS to login base template

See merge request allianceauth/allianceauth!1600
2024-03-07 10:34:53 +00:00
Ariel Rin
6e0f7a35bd Merge branch 'login-box-fix' into 'v4.x'
[CHANGE] Give the login box a bit more space

See merge request allianceauth/allianceauth!1599
2024-03-07 10:34:16 +00:00
Aaron Kable
7375b001ca Add reject button back in 2024-03-04 17:18:19 +08:00
Peter Pfeufer
0287086633 [ADD] Theme CSS to login base template 2024-02-25 20:53:52 +01:00
Peter Pfeufer
9eb2becbb5 [CHANGE] Give the login box a bit more space 2024-02-25 20:37:32 +01:00
Ariel Rin
12f1444fe7 add update command 2024-02-24 20:41:10 +10:00
Ariel Rin
d6372bd093 add notes to building custom image 2024-02-24 16:05:50 +10:00
Ariel Rin
3935a9cdd2 Version Bump 4.0.0b2 2024-02-24 14:44:49 +10:00
Ariel Rin
49fb6c39d5 Merge branch 'navactive' into 'v4.x'
Errors thrown here cause 404 error handling to fail. 404s cant url resolve.

See merge request allianceauth/allianceauth!1598
2024-02-24 04:42:48 +00:00
Ariel Rin
8821f18b21 Errors thrown here cause 404 error handling to fail. 404s cant url resolve. 2024-02-24 04:42:48 +00:00
Ariel Rin
f4d8ead54e remove justified text 2024-02-23 23:25:20 +10:00
Ariel Rin
7427e13505 move into docker and add to toctree 2024-02-23 23:21:09 +10:00
Ariel Rin
445683c3d5 Merge branch 'v4.x' of gitlab.com:allianceauth/allianceauth into v4docs 2024-02-23 22:26:38 +10:00
Ariel Rin
677c46c48a very basic migration guide to our v4 docker stack 2024-02-23 22:23:12 +10:00
Ariel Rin
87b9e3f87a use latest python minor 2024-02-23 20:37:41 +10:00
Ariel Rin
da2a5aff2f missing ` 2024-02-23 20:37:18 +10:00
Ariel Rin
65d77743dc fix toctree after myst conversion 2024-02-23 20:37:06 +10:00
Ariel Rin
1c7f8256d0 Merge branch 'fix-filenames' into 'v4.x'
[AA4] Fix dots in file names

See merge request allianceauth/allianceauth!1597
2024-02-22 03:00:47 +00:00
Erik Kalkoken
61f0aae5d9 [AA4] Fix dots in file names 2024-02-22 03:00:47 +00:00
Ariel Rin
4d56030edf Version Bump 4.0.0b1 2024-02-17 20:56:51 +10:00
Ariel Rin
2dfe194a3b Merge branch 'v4.x-banned' into 'v4.x'
[v4.x] ESI Alerts Dashboard Widget

See merge request allianceauth/allianceauth!1591
2024-02-17 10:48:37 +00:00
Aaron Kable
ebb40deb7f [v4.x] ESI Alerts Dashboard Widget 2024-02-17 10:48:37 +00:00
Ariel Rin
a3a2d3d35b Merge branch 'darklyrollback' into 'v4.x'
rollback darkly, fixes #1396

See merge request allianceauth/allianceauth!1594
2024-02-17 10:37:18 +00:00
Ariel Rin
d4dee519b8 rollback darkly, fixes #1396 2024-02-17 20:29:20 +10:00
Ariel Rin
dfcbad3476 Update source EN 2024-02-17 20:09:53 +10:00
Ariel Rin
e03c1307a3 Merge branch 'master' of gitlab.com:allianceauth/allianceauth into v4.x 2024-02-17 20:01:50 +10:00
Ariel Rin
054ef27fa4 Version Bump 3.8.1 2024-02-17 19:39:14 +10:00
Ariel Rin
97e224b8e6 Merge branch 'translations_7f31a07ccd4e4a66b1dd7b6bc2dbddb5' into 'master'
Updates for project Alliance Auth

See merge request allianceauth/allianceauth!1593
2024-02-17 09:30:43 +00:00
Ariel Rin
3b8fa415bc Updates for project Alliance Auth 2024-02-17 09:30:43 +00:00
Ariel Rin
b94fd7ed19 I18N Maintenance 2024-02-17 18:52:42 +10:00
Ariel Rin
d1dac61135 Merge branch 'translations_7f31a07ccd4e4a66b1dd7b6bc2dbddb5' into 'master'
Updates for project Alliance Auth

See merge request allianceauth/allianceauth!1563
2024-02-17 08:48:55 +00:00
Ariel Rin
d2a095217f Updates for project Alliance Auth 2024-02-17 08:48:55 +00:00
Ariel Rin
3a95b89779 Merge branch 'cherry-pick-2418023d' into 'v4.x'
remove venv from python command

See merge request allianceauth/allianceauth!1592
2024-02-17 08:39:47 +00:00
Ariel Rin
4f5b231bdf remove venv
(cherry picked from commit 2418023ddd)
2024-02-17 08:02:32 +00:00
Ariel Rin
40c0b8d862 Merge branch 'improve-menu-app' into 'v4.x'
Improve menu app

See merge request allianceauth/allianceauth!1589
2024-02-17 07:56:38 +00:00
Erik Kalkoken
62c936f1c0 Improve menu app 2024-02-17 07:56:38 +00:00
Ariel Rin
2a762df9b3 Merge branch 'bs5-datatable-filterdropdown' into 'v4.x'
Re-enable filter dropdown for Datatables

See merge request allianceauth/allianceauth!1590
2024-02-15 01:56:21 +00:00
Ariel Rin
8fb5a488f7 Merge branch 'bs-merge' into 'v4.x'
[v4.x] Merge Bootstrap/Bootstrap Dark Themes

See merge request allianceauth/allianceauth!1587
2024-02-15 01:55:15 +00:00
Ariel Rin
dc239d5396 Merge branch 'more-framework-functions' into 'v4.x'
[ADD] Evecharacter API functions

See merge request allianceauth/allianceauth!1588
2024-02-15 01:54:12 +00:00
Ariel Rin
2815ebaa07 Merge branch 'template-stuff' into 'v4.x'
Auth Menu Changes and Other Template Fixes

See merge request allianceauth/allianceauth!1586
2024-02-15 01:53:38 +00:00
Peter Pfeufer
34dd4802a8 [CHANGE] Some margins modified to be in line with the dashboard 2024-02-11 15:03:49 +01:00
Peter Pfeufer
3ebf11308c [CHANGE] Re-enable filterDropdown JS 2024-02-04 13:40:48 +01:00
Peter Pfeufer
5cd5180a91 [CHANGE] Use the now official release version 2024-02-04 13:35:05 +01:00
Peter Pfeufer
ba213db493 [CHANGE] Update filterDropDown JS for Bootstrap 5 2024-02-04 13:35:05 +01:00
Peter Pfeufer
8362d11714 [FIX] Mark selected theme in the theme dropdown in the user menu 2024-01-28 10:27:47 +01:00
Peter Pfeufer
7ba65968ed [CHANGE] Make it IDs instead of classes 2024-01-27 09:22:03 +01:00
Peter Pfeufer
7fdbac20cb [REMOVE] Unnecessary bg and text classes from user menu element 2024-01-26 15:01:44 +01:00
Peter Pfeufer
76efcb5266 [ADD] Classes to the dashboard panels to identify them
This is nice to have for theming :-)
2024-01-26 14:58:35 +01:00
Peter Pfeufer
6aacb8c2e3 [REMOVE] Unnecessary active class 2024-01-25 16:29:00 +01:00
Peter Pfeufer
465fba3a18 [FIX] It's user not User 2024-01-24 21:57:03 +01:00
Peter Pfeufer
9ae6addc71 [FIX] Stop Python 3.8 tests from failing (hopefully) 2024-01-24 21:11:04 +01:00
Peter Pfeufer
76ae9b8849 [ADD] get_all_characters_from_user function to User API 2024-01-24 21:03:09 +01:00
Peter Pfeufer
13a8b7678f [ADD] Evecharacter API functions 2024-01-24 20:41:36 +01:00
Aaron Kable
c166fc0ef9 Merge the 2 Bootstrap Themes they dont need to be separate 2024-01-24 17:41:19 +08:00
Peter Pfeufer
9da61588eb [CHANGE] Change the chevron for submenu depending on its toggle status 2024-01-24 10:24:14 +01:00
Peter Pfeufer
7425176b3f [CHANGE] Side menu improvements
- Increase width to 325px
- Add some CSS tricks to ensure long link names don't break the menu
2024-01-23 15:23:48 +01:00
Peter Pfeufer
2bb518f7e0 [CHANGE] Don't generate an empty <p> when no group description
Also, better margins on the badges.
2024-01-23 08:17:18 +01:00
Peter Pfeufer
84d5693583 [CHANGE] Use the right BS class 2024-01-17 22:29:22 +01:00
Peter Pfeufer
867e2a1ded [CHANGE] Replaced style="width: 100%;" with the appropriate BS class 2024-01-17 22:28:17 +01:00
Peter Pfeufer
fd6c6991f5 [REMOVED] Unnecessary text class 2024-01-17 22:25:54 +01:00
Peter Pfeufer
333fe8497d [FIX] Register template to Bootstrap 5 2024-01-17 22:21:16 +01:00
Peter Pfeufer
8a3fb17147 [CHANGE] Reduce the width of the side menu to 300px
This gives the apps a bit of wiggle room for all the tables we have there.
2024-01-17 20:04:07 +01:00
Peter Pfeufer
f8baeb19a7 [FIX] Static no longer needed here 2024-01-16 21:29:46 +01:00
Peter Pfeufer
10096862e5 [CHANGE] Better padding 2024-01-16 20:58:24 +01:00
Peter Pfeufer
89193d2fcf [CHANGE] Set min width, so it's properly readable 2024-01-16 20:32:45 +01:00
Peter Pfeufer
fd08b987bd [CHANGE] Ok, the overflow is needed for keep the menu in its bounds … 2024-01-16 20:24:32 +01:00
Peter Pfeufer
4e9e22cb4b [REMOVED] Unnecessary z-index and overflow
Width fixed as well
2024-01-16 20:18:46 +01:00
Peter Pfeufer
f8fbbb5ba7 [CHANGE] Auth menu for public pages 2024-01-16 20:00:53 +01:00
Peter Pfeufer
dd3ef41396 [FIX] Attribuite name and mandatory alt attribute added 2024-01-16 19:36:54 +01:00
Ariel Rin
d5fda05dc9 Merge branch 'add-missing-bootstrap-class' into 'v4.x'
[ADD] Missing Bootstrap class to select field

See merge request allianceauth/allianceauth!1585
2024-01-16 01:56:13 +00:00
Peter Pfeufer
d815fad0e6 [ADD] Missing Bootstrap class to select field 2024-01-15 07:31:52 +01:00
Ariel Rin
e109198782 Merge branch 'v4.x-theme-work' into 'v4.x'
Move User Actions into new Top Nav areas across the internal apps

See merge request allianceauth/allianceauth!1581
2024-01-15 05:35:19 +00:00
Aaron Kable
fbd4672454 Move User Actions into new Top Nav areas across the internal apps 2024-01-15 05:35:19 +00:00
Ariel Rin
a29bd567c2 Merge branch 'v4.x-user-menu' into 'v4.x'
New User Menu

See merge request allianceauth/allianceauth!1582
2024-01-15 05:34:19 +00:00
Aaron Kable
960aef95ad New User Menu 2024-01-15 05:34:19 +00:00
Ariel Rin
4aae5497bb Merge branch 'callout-box-fixes' into 'v4.x'
Different callout box sizes (through padding)

See merge request allianceauth/allianceauth!1584
2024-01-15 05:29:33 +00:00
Ariel Rin
6081cbe900 Merge branch 'grafanabootstrap' into 'v4.x'
Generate a grafana password during bootstrap script

See merge request allianceauth/allianceauth!1583
2024-01-15 05:27:13 +00:00
Ariel Rin
5e9b47cf79 Merge branch 'docs-improvements' into 'master'
Docs improvements

See merge request allianceauth/allianceauth!1574
2024-01-15 05:25:51 +00:00
Ariel Rin
853826c140 Merge branch 'add-email-timeout' into 'master'
Add email timeout

See merge request allianceauth/allianceauth!1580
2024-01-15 05:19:57 +00:00
Peter Pfeufer
ce0d8342e3 [REMOVE] Wrong modifier class in docs 2024-01-07 15:27:42 +01:00
Peter Pfeufer
006785e592 [CHANGE] Use default alert box margin as base 2024-01-07 15:05:41 +01:00
Peter Pfeufer
df05070a55 [ADD] Bootstrap class for bottom margin to callout box in SRP module 2024-01-07 14:02:59 +01:00
Peter Pfeufer
e81450baf3 [CHANGE] Docs updated 2024-01-07 13:57:47 +01:00
Peter Pfeufer
24b6c19aca [ADD] Different sizes (through padding) 2024-01-07 13:34:50 +01:00
Ariel Rin
9f4bf13cc9 Expand documentation on building a custom image, provide example 2024-01-05 15:42:39 +10:00
Ariel Rin
2a156302f0 Merge branch 'top-nav-fix' into 'v4.x'
[ADD] Missing Bootstrap class to top navigation blocks

See merge request allianceauth/allianceauth!1578
2024-01-05 05:42:09 +00:00
Ariel Rin
c4d3bde106 Generate a grafana password during bootstrap script 2024-01-05 15:34:27 +10:00
ErikKalkoken
9c7de58989 Add email timeout 2024-01-02 11:48:27 +01:00
Peter Pfeufer
0900806f68 [REMOVE] Unnecessary empty div 2023-12-29 20:10:06 +01:00
Peter Pfeufer
a0d32d8c2d [ADD] Missing Bootstrap class 2023-12-29 19:57:15 +01:00
Ariel Rin
51b86f88b9 generate some basic docker install guides for services 2023-12-26 20:41:53 +10:00
Ariel Rin
8184461b48 use docker compose instead of dash, add hint 2023-12-26 19:35:11 +10:00
Ariel Rin
ca0cdd6e15 remove old docs images 2023-12-26 19:34:25 +10:00
Ariel Rin
036a17ad3b note the container name changes after refactor 2023-12-26 19:34:09 +10:00
Ariel Rin
2418023ddd remove venv 2023-12-26 19:28:34 +10:00
Peter Pfeufer
6e3219fd1b [FIX] Grammar and spelling 2023-12-17 20:21:11 +01:00
Peter Pfeufer
8aeb061635 [ADD] Remark on how to stop Gunicorn when testing 2023-12-17 17:45:29 +01:00
Peter Pfeufer
84e2107b62 [CHANGE] Switch to adduser for Ubuntu
This will create the users' hoe directory and make it a no-login user in one command.
2023-12-17 17:42:08 +01:00
196 changed files with 16295 additions and 8831 deletions

View File

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

View File

@@ -26,11 +26,11 @@ pre-commit-check:
<<: *only-default
stage: pre-commit
image: python:3.11-bullseye
variables:
PRE_COMMIT_HOME: ${CI_PROJECT_DIR}/.cache/pre-commit
cache:
paths:
- ${PRE_COMMIT_HOME}
# variables:
# PRE_COMMIT_HOME: ${CI_PROJECT_DIR}/.cache/pre-commit
# cache:
# paths:
# - ${PRE_COMMIT_HOME}
script:
- pip install pre-commit
- pre-commit run --all-files

View File

@@ -5,7 +5,7 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
rev: v4.5.0
hooks:
# Identify invalid files
- id: check-ast
@@ -54,7 +54,7 @@ repos:
)
- repo: https://github.com/editorconfig-checker/editorconfig-checker.python
rev: 2.7.2
rev: 2.7.3
hooks:
- id: editorconfig-checker
exclude: |
@@ -67,19 +67,19 @@ repos:
)
- repo: https://github.com/asottile/pyupgrade
rev: v3.10.1
rev: v3.15.1
hooks:
- id: pyupgrade
args: [ --py38-plus ]
- repo: https://github.com/adamchainz/django-upgrade
rev: 1.14.0
rev: 1.16.0
hooks:
- id: django-upgrade
args: [--target-version=4.2]
- repo: https://github.com/asottile/setup-cfg-fmt
rev: v2.3.0
rev: v2.5.0
hooks:
- id: setup-cfg-fmt
args: [ --include-version-classifiers ]

View File

@@ -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.0.1'
__title__ = 'Alliance Auth'
__url__ = 'https://gitlab.com/allianceauth/allianceauth'
NAME = f'{__title__} v{__version__}'

View File

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

View File

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

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

View File

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

View File

@@ -1,5 +1,5 @@
{% 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 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">
<div class="card-body">
<div class="d-flex align-items-center">

View File

@@ -1,5 +1,5 @@
{% 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,111 @@
"""Admin site for menu app."""
from typing import Optional
from django.contrib import admin
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404
from django.utils.translation import gettext_noop as _
from . import models
from .constants import MenuItemType
from .core.smart_sync import sync_menu
from .filters import MenuItemTypeListFilter
from .forms import (
AppMenuItemAdminForm,
FolderMenuItemAdminForm,
LinkMenuItemAdminForm,
)
from .models import MenuItem
@admin.register(models.MenuItem)
@admin.register(MenuItem)
class MenuItemAdmin(admin.ModelAdmin):
list_display = ['text', 'hide', 'parent', 'url', 'icon_classes', 'rank']
ordering = ('rank',)
list_display = (
"_text",
"parent",
"order",
"_user_defined",
"_visible",
"_children",
)
list_filter = [
MenuItemTypeListFilter,
"is_hidden",
("parent", admin.RelatedOnlyFieldListFilter),
]
ordering = ["parent", "order", "text"]
def get_form(self, request: HttpRequest, obj: Optional[MenuItem] = None, **kwargs):
kwargs["form"] = self._choose_form(request, obj)
return super().get_form(request, obj, **kwargs)
@classmethod
def _choose_form(cls, request: HttpRequest, obj: Optional[MenuItem]):
"""Return the form for the current menu item type."""
if obj: # change
if obj.hook_hash:
return AppMenuItemAdminForm
if obj.is_folder:
return FolderMenuItemAdminForm
return LinkMenuItemAdminForm
# add
if cls._type_from_request(request) is MenuItemType.FOLDER:
return FolderMenuItemAdminForm
return LinkMenuItemAdminForm
def add_view(self, request, form_url="", extra_context=None) -> HttpResponse:
context = extra_context or {}
item_type = self._type_from_request(request, default=MenuItemType.LINK)
context["title"] = _("Add %s menu item") % item_type.label
return super().add_view(request, form_url, context)
def change_view(
self, request, object_id, form_url="", extra_context=None
) -> HttpResponse:
extra_context = extra_context or {}
obj = get_object_or_404(MenuItem, id=object_id)
extra_context["title"] = _("Change %s menu item") % obj.item_type.label
return super().change_view(request, object_id, form_url, extra_context)
def changelist_view(self, request: HttpRequest, extra_context=None):
# needed to ensure items are updated after an app change
# and when the admin page is opened directly
sync_menu()
extra_context = extra_context or {}
extra_context["folder_type"] = MenuItemType.FOLDER.value
return super().changelist_view(request, extra_context)
@admin.display(description=_("children"))
def _children(self, obj: MenuItem):
if not obj.is_folder:
return []
names = [obj.text for obj in obj.children.order_by("order", "text")]
return names if names else "?"
@admin.display(description=_("text"), ordering="text")
def _text(self, obj: MenuItem) -> str:
if obj.is_folder:
return f"[{obj.text}]"
return obj.text
@admin.display(description=_("user defined"), boolean=True)
def _user_defined(self, obj: MenuItem) -> bool:
return obj.is_user_defined
@admin.display(description=_("visible"), ordering="is_hidden", boolean=True)
def _visible(self, obj: MenuItem) -> bool:
return not bool(obj.is_hidden)
@staticmethod
def _type_from_request(
request: HttpRequest, default=None
) -> Optional[MenuItemType]:
try:
return MenuItemType(request.GET.get("type"))
except ValueError:
return default

View File

@@ -1,19 +1,19 @@
import logging
from django.apps import AppConfig
from django.db.utils import ProgrammingError, OperationalError
logger = logging.getLogger(__name__)
# TODO discuss permissions for user defined links
# TODO define aa way for hooks to predefine a "parent" to create a sub menu from modules
# TODO Add user documentation
class MenuConfig(AppConfig):
name = "allianceauth.menu"
label = "menu"
def ready(self):
try:
from allianceauth.menu.providers import menu_provider
menu_provider.clear_synced_flag()
except (ProgrammingError, OperationalError):
logger.warning("Migrations not completed for MenuItems")
from allianceauth.menu.core import smart_sync
smart_sync.reset_menu_items_sync()

View File

@@ -0,0 +1,18 @@
"""Global constants for the menu app."""
from django.db import models
from django.utils.translation import gettext_lazy as _
DEFAULT_FOLDER_ICON_CLASSES = "fa-solid fa-folder" # TODO: Make this a setting?
"""Default icon class for folders."""
DEFAULT_MENU_ITEM_ORDER = 9999
"""Default order for any menu item."""
class MenuItemType(models.TextChoices):
"""The type of a menu item."""
APP = "app", _("app")
FOLDER = "folder", _("folder")
LINK = "link", _("link")

View File

@@ -0,0 +1,48 @@
"""Logic for handling MenuItemHook objects."""
import hashlib
from typing import List, NamedTuple, Optional
from allianceauth.menu.hooks import MenuItemHook
class MenuItemHookCustom(MenuItemHook):
"""A user defined menu item that can be rendered with the standard template."""
def __init__(
self,
text: str,
classes: str,
url_name: str,
order: Optional[int] = None,
navactive: Optional[List[str]] = None,
):
super().__init__(text, classes, url_name, order, navactive)
self.url = ""
self.is_folder = None
self.html_id = ""
self.children = []
class MenuItemHookParams(NamedTuple):
"""Immutable container for params about a menu item hook."""
text: str
order: int
hash: str
def generate_hash(obj: MenuItemHook) -> str:
"""Return the hash for a menu item hook."""
my_class = obj.__class__
name = f"{my_class.__module__}.{my_class.__name__}"
hash_value = hashlib.sha256(name.encode("utf-8")).hexdigest()
return hash_value
def gather_params(obj: MenuItemHook) -> MenuItemHookParams:
"""Return params from a menu item hook."""
text = getattr(obj, "text", obj.__class__.__name__)
order = getattr(obj, "order", None)
hash = generate_hash(obj)
return MenuItemHookParams(text=text, hash=hash, order=order)

View File

@@ -0,0 +1,30 @@
"""Provide capability to sync menu items when needed only."""
from django.core.cache import cache
_MENU_SYNC_CACHE_KEY = "ALLIANCEAUTH-MENU-SYNCED"
def sync_menu() -> None:
"""Sync menu items if needed only."""
from allianceauth.menu.models import MenuItem
is_sync_needed = not _is_menu_synced() or not MenuItem.objects.exists()
# need to also check for existence of MenuItems in database
# to ensure the menu is synced during tests
if is_sync_needed:
MenuItem.objects.sync_all()
_record_menu_was_synced()
def _is_menu_synced() -> bool:
return cache.get(_MENU_SYNC_CACHE_KEY, False)
def _record_menu_was_synced() -> None:
cache.set(_MENU_SYNC_CACHE_KEY, True, timeout=None) # no timeout
def reset_menu_items_sync() -> None:
"""Ensure menu items are synced, e.g. after a Django restart."""
cache.delete(_MENU_SYNC_CACHE_KEY)

View File

@@ -0,0 +1,24 @@
"""Filters for the menu app."""
from django.contrib import admin
from django.utils.translation import gettext_noop as _
from allianceauth.menu.constants import MenuItemType
class MenuItemTypeListFilter(admin.SimpleListFilter):
"""Allow filtering admin changelist by menu item type."""
title = _("type")
parameter_name = "type"
def lookups(self, request, model_admin):
return [(obj.value, obj.label.title()) for obj in MenuItemType]
def queryset(self, request, queryset):
if value := self.value():
return queryset.annotate_item_type_2().filter(
item_type_2=MenuItemType(value).value
)
return None

View File

@@ -0,0 +1,49 @@
from django import forms
from .constants import DEFAULT_FOLDER_ICON_CLASSES
from .models import MenuItem
class FolderMenuItemAdminForm(forms.ModelForm):
"""A form for changing folder items."""
class Meta:
model = MenuItem
fields = ["text", "classes", "order", "is_hidden"]
def clean(self):
data = super().clean()
if not data["classes"]:
data["classes"] = DEFAULT_FOLDER_ICON_CLASSES
return data
class _BasedMenuItemAdminForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["parent"].queryset = MenuItem.objects.filter_folders().order_by(
"text"
)
self.fields["parent"].required = False
self.fields["parent"].widget = forms.Select(
choices=self.fields["parent"].widget.choices
) # disable modify buttons
class AppMenuItemAdminForm(_BasedMenuItemAdminForm):
"""A form for changing app items."""
class Meta:
model = MenuItem
fields = ["order", "parent", "is_hidden"]
class LinkMenuItemAdminForm(_BasedMenuItemAdminForm):
"""A form for changing link items."""
class Meta:
model = MenuItem
fields = ["text", "url", "classes", "order", "parent", "is_hidden"]
widgets = {
"url": forms.TextInput(attrs={"size": "100"}),
}

View File

@@ -1,42 +1,58 @@
from django.template.loader import render_to_string
"""Menu item hooks."""
from typing import List, Optional
from django.template.loader import render_to_string
from allianceauth.menu.constants import DEFAULT_MENU_ITEM_ORDER
class MenuItemHook:
"""
Auth Hook for generating Side Menu Items
"""
def __init__(self, text: str, classes: str, url_name: str, order: Optional[int] = None, navactive: List = []):
"""
:param text: The text shown as menu item, e.g. usually the name of the app.
:type text: str
:param classes: The classes that should be applied to the menu item icon
:type classes: List[str]
:param url_name: The name of the Django URL to use
:type url_name: str
:param order: An integer which specifies the order of the menu item, lowest to highest. Community apps are free to use any order above `1000`. Numbers below are served for Auth.
:type order: Optional[int], optional
:param navactive: A list of views or namespaces the link should be highlighted on. See [django-navhelper](https://github.com/geelweb/django-navhelper#navactive) for usage. Defaults to the supplied `url_name`.
:type navactive: List, optional
"""
"""Auth Hook for generating side menu items.
Args:
- text: The text shown as menu item, e.g. usually the name of the app.
- classes: The classes that should be applied to the menu item icon
- url_name: The name of the Django URL to use
- order: An integer which specifies the order of the menu item,
lowest to highest. Community apps are free to use any order above `1000`.
Numbers below are served for Auth.
- A list of views or namespaces the link should be highlighted on.
See 3rd party package django-navhelper for usage.
Defaults to the supplied `url_name`.
Optional:
- count is an integer shown next to the menu item as badge when is is not `None`.
Apps need to set the count in their child class, e.g. in `render()` method
"""
def __init__(
self,
text: str,
classes: str,
url_name: str,
order: Optional[int] = None,
navactive: Optional[List[str]] = None,
):
self.text = text
self.classes = classes
self.url_name = url_name
self.template = 'public/menuitem.html'
self.order = order if order is not None else 9999
# count is an integer shown next to the menu item as badge when count != None
# apps need to set the count in their child class, e.g. in render() method
self.template = "public/menuitem.html"
self.order = order if order is not None else DEFAULT_MENU_ITEM_ORDER
self.count = None
navactive = navactive or []
navactive.append(url_name)
self.navactive = navactive
def render(self, request):
return render_to_string(self.template,
{'item': self},
request=request)
def __str__(self) -> str:
return self.text
def __repr__(self) -> str:
return f'{self.__class__.__name__}(text="{self.text}")'
def render(self, request) -> str:
"""Render this menu item and return resulting HTML."""
return render_to_string(self.template, {"item": self}, request=request)

View File

@@ -0,0 +1,67 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from django.db import models
from django.db.models import Case, Q, Value, When
from allianceauth.hooks import get_hooks
from .constants import MenuItemType
from .core.menu_item_hooks import MenuItemHookParams, gather_params
if TYPE_CHECKING:
from .models import MenuItem
logger = logging.getLogger(__name__)
class MenuItemQuerySet(models.QuerySet):
def filter_folders(self):
"""Add filter to include folders only."""
return self.filter(hook_hash__isnull=True, url="")
def annotate_item_type_2(self):
"""Add calculated field with item type."""
return self.annotate(
item_type_2=Case(
When(~Q(hook_hash__isnull=True), then=Value(MenuItemType.APP.value)),
When(url="", then=Value(MenuItemType.FOLDER.value)),
default=Value(MenuItemType.LINK.value),
)
)
class MenuItemManagerBase(models.Manager):
def sync_all(self):
"""Sync all menu items from hooks."""
hook_params = self._gather_menu_item_hook_params()
self._delete_obsolete_app_items(hook_params)
self._update_or_create_app_items(hook_params)
def _gather_menu_item_hook_params(self) -> list[MenuItemHookParams]:
params = [gather_params(hook()) for hook in get_hooks("menu_item_hook")]
return params
def _delete_obsolete_app_items(self, params: list[MenuItemHookParams]):
hashes = [obj.hash for obj in params]
self.exclude(hook_hash__isnull=True).exclude(hook_hash__in=hashes).delete()
def _update_or_create_app_items(self, params: list[MenuItemHookParams]):
for param in params:
try:
obj: MenuItem = self.get(hook_hash=param.hash)
except self.model.DoesNotExist:
self.create(hook_hash=param.hash, order=param.order, text=param.text)
else:
# if it exists update the text only
if obj.text != param.text:
obj.text = param.text
obj.save()
logger.debug("Updated menu items from %d menu item hooks", len(params))
MenuItemManager = MenuItemManagerBase.from_queryset(MenuItemQuerySet)

View File

@@ -1,15 +0,0 @@
from django.utils.deprecation import MiddlewareMixin
import logging
from allianceauth.menu.providers import menu_provider
logger = logging.getLogger(__name__)
class MenuSyncMiddleware(MiddlewareMixin):
def __call__(self, request):
"""Alliance Auth Menu Sync Middleware"""
menu_provider.check_and_sync_menu()
return super().__call__(request)

View File

@@ -1,4 +1,4 @@
# Generated by Django 4.0.2 on 2022-08-28 14:00
# Generated by Django 4.2.9 on 2024-02-15 00:01
from django.db import migrations, models
import django.db.models.deletion
@@ -8,21 +8,88 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
]
dependencies = []
operations = [
migrations.CreateModel(
name='MenuItem',
name="MenuItem",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('hook_function', models.CharField(max_length=500)),
('icon_classes', models.CharField(max_length=150)),
('text', models.CharField(max_length=150)),
('url', models.CharField(blank=True, default=None, max_length=2048, null=True)),
('rank', models.IntegerField(default=1000)),
('hide', models.BooleanField(default=False)),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='menu.menuitem')),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"text",
models.CharField(
db_index=True,
help_text="Text to show on menu",
max_length=150,
verbose_name="text",
),
),
(
"order",
models.IntegerField(
db_index=True,
default=9999,
help_text="Order of the menu. Lowest First",
verbose_name="order",
),
),
(
"is_hidden",
models.BooleanField(
default=False,
help_text="Hide this menu item.If this item is a folder all items under it will be hidden too",
verbose_name="is hidden",
),
),
(
"hook_hash",
models.CharField(
default=None,
editable=False,
max_length=64,
null=True,
unique=True,
),
),
(
"classes",
models.CharField(
blank=True,
default="",
help_text="Font Awesome classes to show as icon on menu, e.g. <code>fa-solid fa-house</code>",
max_length=150,
verbose_name="icon classes",
),
),
(
"url",
models.TextField(
default="",
help_text="External URL this menu items will link to",
verbose_name="url",
),
),
(
"parent",
models.ForeignKey(
blank=True,
default=None,
help_text="Folder this item is in (optional)",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="children",
to="menu.menuitem",
verbose_name="folder",
),
),
],
),
]

View File

@@ -1,28 +0,0 @@
# Generated by Django 4.0.2 on 2022-08-28 14:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('menu', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='menuitem',
name='hook_function',
field=models.CharField(blank=True, default=None, max_length=500, null=True),
),
migrations.AlterField(
model_name='menuitem',
name='icon_classes',
field=models.CharField(blank=True, default=None, max_length=150, null=True),
),
migrations.AlterField(
model_name='menuitem',
name='text',
field=models.CharField(blank=True, default=None, max_length=150, null=True),
),
]

View File

@@ -1,17 +0,0 @@
# Generated by Django 4.0.8 on 2023-02-05 07:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('menu', '0002_alter_menuitem_hook_function_and_more'),
]
operations = [
migrations.AddIndex(
model_name='menuitem',
index=models.Index(fields=['rank'], name='menu_menuit_rank_e880ab_idx'),
),
]

View File

@@ -1,39 +0,0 @@
# Generated by Django 4.0.10 on 2023-07-16 11:41
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('menu', '0003_menuitem_menu_menuit_rank_e880ab_idx'),
]
operations = [
migrations.AlterField(
model_name='menuitem',
name='hide',
field=models.BooleanField(default=False, help_text='Hide this menu item. If this item is a header all items under it will be hidden too.'),
),
migrations.AlterField(
model_name='menuitem',
name='icon_classes',
field=models.CharField(blank=True, default=None, help_text='Font Awesome classes to show as icon on menu', max_length=150, null=True),
),
migrations.AlterField(
model_name='menuitem',
name='parent',
field=models.ForeignKey(blank=True, help_text='Parent Header. (Optional)', null=True, on_delete=django.db.models.deletion.SET_NULL, to='menu.menuitem'),
),
migrations.AlterField(
model_name='menuitem',
name='rank',
field=models.IntegerField(default=1000, help_text='Order of the menu. Lowest First.'),
),
migrations.AlterField(
model_name='menuitem',
name='text',
field=models.CharField(blank=True, default=None, help_text='Text to show on menu', max_length=150, null=True),
),
]

View File

@@ -1,174 +1,132 @@
import logging
from allianceauth.hooks import get_hooks
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.template.loader import render_to_string
logger = logging.getLogger(__name__)
from allianceauth.menu.constants import DEFAULT_FOLDER_ICON_CLASSES
from .constants import DEFAULT_MENU_ITEM_ORDER, MenuItemType
from .core.menu_item_hooks import MenuItemHookCustom
from .managers import MenuItemManager
class MenuItem(models.Model):
# Auto Generated model from an auth_hook
hook_function = models.CharField(
max_length=500, default=None, null=True, blank=True)
"""An item in the sidebar menu.
Some of these objects are generated from `MenuItemHook` objects.
To avoid confusion we are using the same same field names.user defined
"""
# User Made Model
icon_classes = models.CharField(
max_length=150, default=None, null=True, blank=True, help_text="Font Awesome classes to show as icon on menu")
text = models.CharField(
max_length=150, default=None, null=True, blank=True, help_text="Text to show on menu")
url = models.CharField(max_length=2048, default=None,
null=True, blank=True)
# Put it under a header?
max_length=150,
db_index=True,
verbose_name=_("text"),
help_text=_("Text to show on menu"),
)
order = models.IntegerField(
default=DEFAULT_MENU_ITEM_ORDER,
db_index=True,
verbose_name=_("order"),
help_text=_("Order of the menu. Lowest First"),
)
parent = models.ForeignKey(
'self', on_delete=models.SET_NULL, null=True, blank=True, help_text="Parent Header. (Optional)")
"self",
on_delete=models.SET_NULL,
default=None,
null=True,
blank=True,
related_name="children",
verbose_name=_("folder"),
help_text=_("Folder this item is in (optional)"),
)
is_hidden = models.BooleanField(
default=False,
verbose_name=_("is hidden"),
help_text=_(
"Hide this menu item."
"If this item is a folder all items under it will be hidden too"
),
)
# Put it where? lowest first
rank = models.IntegerField(default=1000, help_text="Order of the menu. Lowest First.")
# app related properties
hook_hash = models.CharField(
max_length=64, default=None, null=True, unique=True, editable=False
) # hash of a menu item hook. Must be nullable for unique comparison.
# Hide it fully? Hiding a parent will hide all it's children
hide = models.BooleanField(default=False, help_text="Hide this menu item. If this item is a header all items under it will be hidden too.")
# user defined properties
classes = models.CharField(
max_length=150,
default="",
blank=True,
verbose_name=_("icon classes"),
help_text=_(
"Font Awesome classes to show as icon on menu, "
"e.g. <code>fa-solid fa-house</code>"
),
)
url = models.TextField(
default="",
verbose_name=_("url"),
help_text=_("External URL this menu items will link to"),
)
class Meta:
indexes = [
models.Index(fields=['rank', ]),
]
objects = MenuItemManager()
def __str__(self) -> str:
return self.text
def save(self, *args, **kwargs):
if not self.hook_hash:
self.hook_hash = None # empty strings can create problems
return super().save(*args, **kwargs)
@property
def classes(self): # Helper function to make this model closer to the hook functions
return self.icon_classes
def item_type(self) -> MenuItemType:
"""Return the type of this menu item."""
if self.hook_hash:
return MenuItemType.APP
@staticmethod
def hook_to_name(mh):
return f"{mh.__class__.__module__}.{mh.__class__.__name__}"
if not self.url:
return MenuItemType.FOLDER
@staticmethod
def sync_hook_models():
# TODO define aa way for hooks to predefine a "parent" to create a sub menu from modules
menu_hooks = get_hooks('menu_item_hook')
hook_functions = []
for hook in menu_hooks:
mh = hook()
cls = MenuItem.hook_to_name(mh)
try:
# if it exists update the text only
# Users can adjust ranks so lets not change it if they have.
mi = MenuItem.objects.get(hook_function=cls)
mi.text = getattr(mh, "text", mh.__class__.__name__)
mi.save()
except MenuItem.DoesNotExist:
# This is a new hook, Make the database model.
MenuItem.objects.create(
hook_function=cls,
rank=getattr(mh, "order", 500),
text=getattr(mh, "text", mh.__class__.__name__)
)
hook_functions.append(cls)
return MenuItemType.LINK
# Get rid of any legacy hooks from modules removed
MenuItem.objects.filter(hook_function__isnull=False).exclude(
hook_function__in=hook_functions).delete()
@property
def is_app_item(self) -> bool:
"""Return True if this is an app item, else False."""
return self.item_type is MenuItemType.APP
@classmethod
def filter_items(cls, menu_item: dict):
"""
filter any items with no valid children from a menu
"""
count_items = len(menu_item['items'])
if count_items: # if we have children confirm we can see them
for i in menu_item['items']:
if len(i['render']) == 0:
count_items -= 1
if count_items == 0: # no children left dont render header
return False
return True
else:
return True
@property
def is_child(self) -> bool:
"""Return True if this item is a child, else False."""
return bool(self.parent_id)
@classmethod
def render_menu(cls, request):
"""
Return the sorted side menu items with any items the user can't see removed.
"""
# Override all the items to the bs5 theme
template = "menu/menu-item-bs5.html"
# TODO discuss permissions for user defined links
@property
def is_folder(self) -> bool:
"""Return True if this item is a folder, else False."""
return self.item_type is MenuItemType.FOLDER
# Turn all the hooks into functions
menu_hooks = get_hooks('menu_item_hook')
items = {}
for fn in menu_hooks:
f = fn()
items[cls.hook_to_name(f)] = f
@property
def is_link_item(self) -> bool:
"""Return True if this item is a link item, else False."""
return self.item_type is MenuItemType.LINK
menu_items = MenuItem.objects.all().order_by("rank")
@property
def is_user_defined(self) -> bool:
"""Return True if this item is user defined."""
return self.item_type is not MenuItemType.APP
menu = {}
for mi in menu_items:
if mi.hide:
# hidden item, skip it completely
continue
try:
_cnt = 0
_render = None
if mi.hook_function:
# This is a module hook, so we need to render it as the developer intended
# TODO add a new attribute for apps that want to override it in the new theme
items[mi.hook_function].template = template
_render = items[mi.hook_function].render(request)
_cnt = items[mi.hook_function].count
else:
# This is a user defined menu item so we render it with defaults.
_render = render_to_string(template,
{'item': mi},
request=request)
def to_hook_obj(self) -> MenuItemHookCustom:
"""Convert to hook object for rendering."""
if self.is_app_item:
raise ValueError("The related hook objects should be used for app items.")
parent = mi.id
if mi.parent_id: # Set it if present
parent = mi.parent_id
hook_obj = MenuItemHookCustom(
text=self.text, classes=self.classes, url_name="", order=self.order
)
hook_obj.navactive = []
if self.is_folder and not self.classes:
hook_obj.classes = DEFAULT_FOLDER_ICON_CLASSES
if parent not in menu: # this will cause the menu headers to be out of order
menu[parent] = {"items": [],
"count": 0,
"render": None,
"text": "None",
"rank": 9999,
}
_mi = {
"count": _cnt,
"render": _render,
"text": mi.text,
"rank": mi.rank,
"classes": (mi.icon_classes if mi.icon_classes != "" else "fa-solid fa-folder"),
"hide": mi.hide
}
if parent != mi.id:
# this is a sub item
menu[parent]["items"].append(_mi)
if _cnt:
#add its count to the header count
menu[parent]["count"] += _cnt
else:
if len(menu[parent]["items"]):
# this is a top folder dont update the count.
del(_mi["count"])
menu[parent].update(_mi)
except Exception as e:
logger.exception(e)
# reset to list
menu = list(menu.values())
# sort the menu list as the parents may be out of order.
menu.sort(key=lambda i: i['rank'])
# ensure no empty groups
menu = filter(cls.filter_items, menu)
return menu
hook_obj.url = self.url
hook_obj.is_folder = self.is_folder
hook_obj.html_id = f"id-folder-{self.id}" if self.is_folder else ""
return hook_obj

View File

@@ -1,49 +0,0 @@
import logging
from django.core.cache import cache
from allianceauth.menu.models import MenuItem
from allianceauth.utils.django import StartupCommand
logger = logging.getLogger(__name__)
MENU_SYNC_CACHE_KEY = "ALLIANCEAUTH-MENU-SYNCED"
MENU_CACHE_KEY = "ALLIANCEAUTH-MENU-CACHE"
class MenuProvider():
def clear_synced_flag(self) -> bool:
return cache.delete(MENU_SYNC_CACHE_KEY)
def set_synced_flag(self) -> bool:
return cache.set(MENU_SYNC_CACHE_KEY, True)
def get_synced_flag(self) -> bool:
return cache.get(MENU_SYNC_CACHE_KEY, False)
def sync_menu_models(self):
MenuItem.sync_hook_models()
self.set_synced_flag()
def check_and_sync_menu(self) -> None:
if self.get_synced_flag():
# performance hit to each page view to ensure tests work.
# tests clear DB but not cache.
# TODO rethink all of this?
if MenuItem.objects.all().count() > 0:
logger.debug("Menu Hooks Synced")
else:
self.sync_menu_models()
else:
logger.debug("Syncing Menu Hooks")
self.sync_menu_models()
def get_and_cache_menu(self):
pass
def clear_menu_cache(self):
pass
menu_provider = MenuProvider()

View File

@@ -0,0 +1,13 @@
{% extends "admin/change_list.html" %}
{% load i18n %}
{% block object-tools-items %}
{{ block.super }}
<li>
<a href="{% url 'admin:menu_menuitem_add' %}?type={{ folder_type }}" class="addlink">
{% translate "Add folder" %}
</a>
</li>
{% endblock %}

View File

@@ -1,7 +1,3 @@
{% for data in menu_items %}
{% if data.items|length > 0 %}
{% include "menu/menu-item-bs5.html" with item=data %}
{% else %}
{{ data.render }}
{% endif %}
{% for item in menu_items %}
{{ item.html }}
{% endfor %}

View File

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

View File

@@ -0,0 +1,3 @@
<div class="align-items-center text-center">
{% include "bundles/image-auth-logo.html" %}
</div>

View File

@@ -2,57 +2,89 @@
{% 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>
<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>

View File

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

View File

@@ -0,0 +1,32 @@
"""Template tags for rendering the classic side menu."""
from django import template
from django.http import HttpRequest
from allianceauth.hooks import get_hooks
register = template.Library()
# TODO: Show user created menu items
# TODO: Apply is_hidden feature for BS3 type items
@register.inclusion_tag("public/menublock.html", takes_context=True)
def menu_items(context: dict) -> dict:
"""Render menu items for classic dashboard."""
items = render_menu(context["request"])
return {"menu_items": items}
def render_menu(request: HttpRequest):
"""Return the rendered side menu for including in a template.
This function is creating a BS3 style menu.
"""
hooks = get_hooks("menu_item_hook")
raw_items = [fn() for fn in hooks]
raw_items.sort(key=lambda i: i.order)
menu_items = [item.render(request) for item in raw_items]
return menu_items

View File

@@ -1,34 +1,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]

View File

View File

View File

@@ -0,0 +1,63 @@
from django.test import TestCase
from allianceauth.menu.core.menu_item_hooks import (
MenuItemHookCustom,
gather_params,
generate_hash,
)
from allianceauth.menu.tests.factories import create_menu_item_hook_function
class TestGenerateHash(TestCase):
def test_should_generate_same_hash(self):
# given
hook = create_menu_item_hook_function()
# when
result_1 = generate_hash(hook())
result_2 = generate_hash(hook())
# then
self.assertIsInstance(result_1, str)
self.assertEqual(result_1, result_2)
def test_should_generate_different_hashes(self):
# given
hook_1 = create_menu_item_hook_function()
hook_2 = create_menu_item_hook_function()
# when
result_1 = generate_hash(hook_1())
result_2 = generate_hash(hook_2())
# then
self.assertNotEqual(result_1, result_2)
class TestExtractParams(TestCase):
def test_should_return_params(self):
# given
hook = create_menu_item_hook_function(text="Alpha", order=42)
# when
result = gather_params(hook())
# then
self.assertEqual(result.text, "Alpha")
self.assertEqual(result.order, 42)
self.assertIsInstance(result.hash, str)
class TestMenuItemHookCustom(TestCase):
def test_should_create_minimal(self):
# when
obj = MenuItemHookCustom(text="text", classes="classes", url_name="url_name")
# then
self.assertEqual(obj.text, "text")
self.assertEqual(obj.classes, "classes")
self.assertEqual(obj.url_name, "url_name")
self.assertEqual(obj.url, "")
self.assertIsNone(obj.is_folder)
self.assertEqual(obj.html_id, "")
self.assertListEqual(obj.children, [])

View File

@@ -0,0 +1,42 @@
from unittest.mock import patch
from django.test import TestCase
from allianceauth.menu.core import smart_sync
from allianceauth.menu.tests.factories import create_link_menu_item
from allianceauth.menu.tests.utils import PACKAGE_PATH
@patch(PACKAGE_PATH + ".models.MenuItem.objects.sync_all", spec=True)
class TestSmartSync(TestCase):
def test_should_sync_after_reset(self, mock_sync_all):
# given
smart_sync.reset_menu_items_sync()
mock_sync_all.reset_mock()
# when
smart_sync.sync_menu()
# then
self.assertTrue(mock_sync_all.called)
def test_should_sync_when_sync_flag_is_set_but_no_items_in_db(self, mock_sync_all):
# given
smart_sync._record_menu_was_synced()
# when
smart_sync.sync_menu()
# then
self.assertTrue(mock_sync_all.called)
def test_should_not_sync_when_sync_flag_is_set_and_items_in_db(self, mock_sync_all):
# given
smart_sync._record_menu_was_synced()
create_link_menu_item()
# when
smart_sync.sync_menu()
# then
self.assertFalse(mock_sync_all.called)

View File

@@ -0,0 +1,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)

View File

@@ -0,0 +1,178 @@
from http import HTTPStatus
from django.test import TestCase
from django.urls import reverse
from allianceauth.menu.constants import MenuItemType
from allianceauth.menu.forms import (
AppMenuItemAdminForm,
FolderMenuItemAdminForm,
LinkMenuItemAdminForm,
)
from allianceauth.menu.models import MenuItem
from allianceauth.menu.tests.factories import (
create_app_menu_item,
create_folder_menu_item,
create_link_menu_item,
create_user,
)
from allianceauth.menu.tests.utils import extract_html
def extract_menu_item_texts(response):
"""Extract labels of menu items shown in change list."""
soup = extract_html(response)
items = soup.find_all("th", {"class": "field-_text"})
labels = {elem.text for elem in items}
return labels
class TestAdminSite(TestCase):
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
cls.user = create_user(is_superuser=True, is_staff=True)
cls.changelist_url = reverse("admin:menu_menuitem_changelist")
cls.add_url = reverse("admin:menu_menuitem_add")
def change_url(self, id_):
return reverse("admin:menu_menuitem_change", args=[id_])
def test_changelist_should_show_all_types(self):
# given
self.client.force_login(self.user)
create_app_menu_item(text="app")
create_folder_menu_item(text="folder")
create_link_menu_item(text="link")
# when
response = self.client.get(self.changelist_url)
# then
self.assertEqual(response.status_code, HTTPStatus.OK)
labels = extract_menu_item_texts(response)
self.assertSetEqual(labels, {"app", "[folder]", "link"})
def test_should_create_new_link_item(self):
# given
self.client.force_login(self.user)
# when
response = self.client.post(
self.add_url,
{"text": "alpha", "url": "http://www.example.com", "order": 99},
)
# then
self.assertEqual(response.status_code, HTTPStatus.FOUND)
self.assertEqual(response.url, self.changelist_url)
self.assertEqual(MenuItem.objects.count(), 1)
obj = MenuItem.objects.first()
self.assertEqual(obj.text, "alpha")
self.assertEqual(obj.item_type, MenuItemType.LINK)
def test_should_create_new_folder_item(self):
# given
self.client.force_login(self.user)
# when
response = self.client.post(
self.add_url + "?type=folder", {"text": "alpha", "order": 99}
)
# then
self.assertEqual(response.status_code, HTTPStatus.FOUND)
self.assertEqual(response.url, self.changelist_url)
self.assertEqual(MenuItem.objects.count(), 1)
obj = MenuItem.objects.first()
self.assertEqual(obj.text, "alpha")
self.assertEqual(obj.item_type, MenuItemType.FOLDER)
def test_should_change_app_item(self):
# given
self.client.force_login(self.user)
item = create_app_menu_item(text="alpha", order=1)
form_data = AppMenuItemAdminForm(instance=item).initial
form_data["order"] = 99
form_data["parent"] = ""
# when
response = self.client.post(self.change_url(item.id), form_data)
# then
self.assertEqual(response.status_code, HTTPStatus.FOUND)
self.assertEqual(response.url, self.changelist_url)
self.assertEqual(MenuItem.objects.count(), 1)
obj = MenuItem.objects.first()
self.assertEqual(obj.order, 99)
self.assertEqual(obj.item_type, MenuItemType.APP)
def test_should_change_link_item(self):
# given
self.client.force_login(self.user)
item = create_link_menu_item(text="alpha")
form_data = LinkMenuItemAdminForm(instance=item).initial
form_data["text"] = "bravo"
form_data["parent"] = ""
# when
response = self.client.post(self.change_url(item.id), form_data)
# then
self.assertEqual(response.status_code, HTTPStatus.FOUND)
self.assertEqual(response.url, self.changelist_url)
self.assertEqual(MenuItem.objects.count(), 1)
obj = MenuItem.objects.first()
self.assertEqual(obj.text, "bravo")
self.assertEqual(obj.item_type, MenuItemType.LINK)
def test_should_change_folder_item(self):
# given
self.client.force_login(self.user)
item = create_folder_menu_item(text="alpha")
form_data = FolderMenuItemAdminForm(instance=item).initial
form_data["text"] = "bravo"
# when
response = self.client.post(self.change_url(item.id), form_data)
# then
self.assertEqual(response.status_code, HTTPStatus.FOUND)
self.assertEqual(response.url, self.changelist_url)
self.assertEqual(MenuItem.objects.count(), 1)
obj = MenuItem.objects.first()
self.assertEqual(obj.text, "bravo")
self.assertEqual(obj.item_type, MenuItemType.FOLDER)
def test_should_move_item_into_folder(self):
# given
self.client.force_login(self.user)
link = create_link_menu_item(text="alpha")
folder = create_folder_menu_item(text="folder")
form_data = LinkMenuItemAdminForm(instance=link).initial
form_data["parent"] = folder.id
# when
response = self.client.post(self.change_url(link.id), form_data)
# then
self.assertEqual(response.status_code, HTTPStatus.FOUND)
self.assertEqual(response.url, self.changelist_url)
link.refresh_from_db()
self.assertEqual(link.parent, folder)
def test_should_filter_items_by_type(self):
# given
self.client.force_login(self.user)
create_app_menu_item(text="app")
create_folder_menu_item(text="folder")
create_link_menu_item(text="link")
# when
cases = [("link", "link"), ("app", "app"), ("folder", "[folder]")]
for filter_name, expected_label in cases:
with self.subTest(filter_name=filter_name):
response = self.client.get(self.changelist_url + f"?type={filter_name}")
# then
self.assertEqual(response.status_code, HTTPStatus.OK)
labels = extract_menu_item_texts(response)
self.assertSetEqual(labels, {expected_label})

View File

@@ -0,0 +1,102 @@
from http import HTTPStatus
from django.test import TestCase
from allianceauth.menu.core.smart_sync import reset_menu_items_sync
from allianceauth.menu.tests.factories import (
create_folder_menu_item,
create_link_menu_item,
create_user,
)
from allianceauth.menu.tests.utils import extract_links
class TestDefaultDashboardWithSideMenu(TestCase):
def test_should_show_all_types_of_menu_entries(self):
# given
user = create_user(permissions=["auth.group_management"])
self.client.force_login(user)
create_link_menu_item(text="Alpha", url="http://www.example.com/alpha")
folder = create_folder_menu_item(text="Folder")
create_link_menu_item(
text="Bravo", url="http://www.example.com/bravo", parent=folder
)
reset_menu_items_sync() # this simulates startup
# when
response = self.client.get("/dashboard/")
# then
self.assertEqual(response.status_code, HTTPStatus.OK)
links = extract_links(response)
# open_page_in_browser(response)
self.assertEqual(links["/dashboard/"], "Dashboard")
self.assertEqual(links["/groups/"], "Groups")
self.assertEqual(links["/groupmanagement/requests/"], "Group Management")
self.assertEqual(links["http://www.example.com/alpha"], "Alpha")
self.assertEqual(links["http://www.example.com/bravo"], "Bravo")
def test_should_not_show_menu_entry_when_user_has_no_permission(self):
# given
user = create_user()
self.client.force_login(user)
reset_menu_items_sync()
# when
response = self.client.get("/dashboard/")
# then
self.assertEqual(response.status_code, HTTPStatus.OK)
links = extract_links(response)
self.assertEqual(links["/dashboard/"], "Dashboard")
self.assertEqual(links["/groups/"], "Groups")
self.assertNotIn("/groupmanagement/requests/", links)
def test_should_not_show_menu_entry_when_hidden(self):
# given
user = create_user()
self.client.force_login(user)
create_link_menu_item(text="Alpha", url="http://www.example.com/")
reset_menu_items_sync()
# when
response = self.client.get("/dashboard/")
# then
self.assertEqual(response.status_code, HTTPStatus.OK)
links = extract_links(response)
self.assertEqual(links["/dashboard/"], "Dashboard")
self.assertEqual(links["/groups/"], "Groups")
self.assertNotIn("http://www.example.com/alpha", links)
class TestBS3DashboardWithSideMenu(TestCase):
def test_should_not_show_group_management_when_user_has_no_permission(self):
# given
user = create_user()
self.client.force_login(user)
# when
response = self.client.get("/dashboard_bs3/")
# then
self.assertEqual(response.status_code, HTTPStatus.OK)
links = extract_links(response)
self.assertEqual(links["/dashboard/"], "Dashboard")
self.assertEqual(links["/groups/"], "Groups")
self.assertNotIn("/groupmanagement/requests/", links)
def test_should_show_group_management_when_user_has_permission(self):
# given
user = create_user(permissions=["auth.group_management"])
self.client.force_login(user)
# when
response = self.client.get("/dashboard_bs3/")
# then
self.assertEqual(response.status_code, HTTPStatus.OK)
links = extract_links(response)
self.assertEqual(links["/dashboard/"], "Dashboard")
self.assertEqual(links["/groups/"], "Groups")
self.assertEqual(links["/groupmanagement/requests/"], "Group Management")

View File

@@ -0,0 +1,54 @@
from unittest.mock import patch
from django.test import RequestFactory, TestCase
from allianceauth.menu.templatetags.menu_items import render_menu
from allianceauth.menu.tests.factories import create_menu_item_hook_function
from allianceauth.menu.tests.utils import PACKAGE_PATH, render_template
MODULE_PATH = PACKAGE_PATH + ".templatetags.menu_items"
class TestTemplateTags(TestCase):
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
cls.factory = RequestFactory()
@patch(MODULE_PATH + ".render_menu", spec=True)
def test_menu_items(self, mock_render_menu):
# given
mock_render_menu.return_value = ["Alpha"]
request = self.factory.get("/")
# when
rendered = render_template(
"{% load menu_items %}{% menu_items %}",
context={"request": request},
)
self.assertIn("Alpha", rendered)
@patch(MODULE_PATH + ".get_hooks", spec=True)
class TestRenderMenu(TestCase):
def setUp(self) -> None:
self.factory = RequestFactory()
def test_should_render_menu_in_order(self, mock_get_hooks):
# given
mock_get_hooks.return_value = [
create_menu_item_hook_function(text="Charlie"),
create_menu_item_hook_function(text="Alpha", order=1),
create_menu_item_hook_function(text="Bravo", order=2),
]
request = self.factory.get("/")
# when
result = render_menu(request)
# then
menu = list(result)
self.assertEqual(len(menu), 3)
self.assertIn("Alpha", menu[0])
self.assertIn("Bravo", menu[1])
self.assertIn("Charlie", menu[2])

View File

@@ -0,0 +1,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)

View File

@@ -0,0 +1,28 @@
from django.test import TestCase
from allianceauth.menu.constants import DEFAULT_FOLDER_ICON_CLASSES
from allianceauth.menu.forms import FolderMenuItemAdminForm
class TestFolderMenuItemAdminForm(TestCase):
def test_should_set_default_icon_classes(self):
# given
form_data = {"text": "Alpha", "order": 1}
form = FolderMenuItemAdminForm(data=form_data)
# when
obj = form.save(commit=False)
# then
self.assertEqual(obj.classes, DEFAULT_FOLDER_ICON_CLASSES)
def test_should_use_icon_classes_from_input(self):
# given
form_data = {"text": "Alpha", "order": 1, "classes": "dummy"}
form = FolderMenuItemAdminForm(data=form_data)
# when
obj = form.save(commit=False)
# then
self.assertEqual(obj.classes, "dummy")

View File

@@ -0,0 +1,82 @@
from django.test import RequestFactory, TestCase
from allianceauth.menu.hooks import MenuItemHook
from .factories import create_menu_item_hook
class TestMenuItemHook(TestCase):
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
cls.factory = RequestFactory()
def test_should_create_obj_with_minimal_params(self):
# when
obj = MenuItemHook("text", "classes", "url-name")
# then
self.assertEqual(obj.text, "text")
self.assertEqual(obj.classes, "classes")
self.assertEqual(obj.url_name, "url-name")
self.assertEqual(obj.template, "public/menuitem.html")
self.assertEqual(obj.order, 9999)
self.assertListEqual(obj.navactive, ["url-name"])
self.assertIsNone(obj.count)
def test_should_create_obj_with_full_params_1(self):
# when
obj = MenuItemHook("text", "classes", "url-name", 5, ["navactive"])
# then
self.assertEqual(obj.text, "text")
self.assertEqual(obj.classes, "classes")
self.assertEqual(obj.url_name, "url-name")
self.assertEqual(obj.template, "public/menuitem.html")
self.assertEqual(obj.order, 5)
self.assertListEqual(obj.navactive, ["navactive", "url-name"])
self.assertIsNone(obj.count)
def test_should_create_obj_with_full_params_2(self):
# when
obj = MenuItemHook(
text="text",
classes="classes",
url_name="url-name",
order=5,
navactive=["navactive"],
)
# then
self.assertEqual(obj.text, "text")
self.assertEqual(obj.classes, "classes")
self.assertEqual(obj.url_name, "url-name")
self.assertEqual(obj.template, "public/menuitem.html")
self.assertEqual(obj.order, 5)
self.assertListEqual(obj.navactive, ["navactive", "url-name"])
self.assertIsNone(obj.count)
def test_should_render_menu_item(self):
# given
request = self.factory.get("/")
hook = create_menu_item_hook(text="Alpha")
# when
result = hook.render(request)
# then
self.assertIn("Alpha", result)
def test_str(self):
# given
hook = create_menu_item_hook(text="Alpha")
# when/then
self.assertEqual(str(hook), "Alpha")
def test_repr(self):
# given
hook = create_menu_item_hook(text="Alpha")
# when/then
self.assertIn("Alpha", repr(hook))

View File

@@ -0,0 +1,103 @@
from unittest.mock import patch
from django.db.models import QuerySet
from django.test import TestCase
from allianceauth.menu.constants import MenuItemType
from allianceauth.menu.models import MenuItem
from .factories import (
create_app_menu_item,
create_folder_menu_item,
create_link_menu_item,
create_menu_item_from_hook,
create_menu_item_hook_function,
)
from .utils import PACKAGE_PATH
class TestMenuItemQuerySet(TestCase):
def test_should_add_item_type_field(self):
# given
app_item = create_app_menu_item()
link_item = create_link_menu_item()
folder_item = create_folder_menu_item()
# when
result: QuerySet[MenuItem] = MenuItem.objects.annotate_item_type_2()
# then
for obj in [app_item, link_item, folder_item]:
obj = result.get(pk=app_item.pk)
self.assertEqual(obj.item_type_2, obj.item_type)
def test_should_filter_folders(self):
# given
create_app_menu_item()
create_link_menu_item()
folder_item = create_folder_menu_item()
# when
result: QuerySet[MenuItem] = MenuItem.objects.filter_folders()
# then
item_pks = set(result.values_list("pk", flat=True))
self.assertSetEqual(item_pks, {folder_item.pk})
@patch(PACKAGE_PATH + ".managers.get_hooks", spec=True)
class TestMenuItemManagerSyncAll(TestCase):
def test_should_create_new_items_from_hooks_when_they_do_not_exist(
self, mock_get_hooks
):
# given
mock_get_hooks.return_value = [create_menu_item_hook_function(text="Alpha")]
# when
MenuItem.objects.sync_all()
# then
self.assertEqual(MenuItem.objects.count(), 1)
obj = MenuItem.objects.first()
self.assertEqual(obj.item_type, MenuItemType.APP)
self.assertEqual(obj.text, "Alpha")
def test_should_update_existing_app_items_when_changed_only(self, mock_get_hooks):
# given
menu_hook_1 = create_menu_item_hook_function(text="Alpha", order=1)
menu_hook_2 = create_menu_item_hook_function(text="Bravo", order=2)
mock_get_hooks.return_value = [menu_hook_1, menu_hook_2]
create_menu_item_from_hook(menu_hook_1, text="name has changed", order=99)
create_menu_item_from_hook(menu_hook_2)
# when
MenuItem.objects.sync_all()
# then
self.assertEqual(MenuItem.objects.count(), 2)
obj = MenuItem.objects.get(text="Alpha")
self.assertEqual(obj.item_type, MenuItemType.APP)
self.assertEqual(obj.order, 99)
obj = MenuItem.objects.get(text="Bravo")
self.assertEqual(obj.item_type, MenuItemType.APP)
self.assertEqual(obj.order, 2)
def test_should_remove_obsolete_app_items_but_keep_user_items(self, mock_get_hooks):
# given
menu_hook = create_menu_item_hook_function(text="Alpha")
mock_get_hooks.return_value = [menu_hook]
create_app_menu_item(text="Bravo") # obsolete item
link_item = create_link_menu_item()
folder_item = create_folder_menu_item()
# when
MenuItem.objects.sync_all()
# then
self.assertEqual(MenuItem.objects.count(), 3)
obj = MenuItem.objects.get(text="Alpha")
self.assertTrue(obj.item_type, MenuItemType.APP)
self.assertIn(link_item, MenuItem.objects.all())
self.assertIn(folder_item, MenuItem.objects.all())

View File

@@ -0,0 +1,166 @@
from django.test import TestCase
from allianceauth.menu.constants import MenuItemType
from .factories import (
create_app_menu_item,
create_folder_menu_item,
create_link_menu_item,
)
class TestMenuItem(TestCase):
def test_str(self):
# given
obj = create_link_menu_item()
# when
result = str(obj)
# then
self.assertIsInstance(result, str)
def test_should_return_item_type(self):
# given
app_item = create_app_menu_item()
link_item = create_link_menu_item()
folder_item = create_folder_menu_item()
cases = [
(app_item, MenuItemType.APP),
(link_item, MenuItemType.LINK),
(folder_item, MenuItemType.FOLDER),
]
# when
for obj, expected in cases:
with self.subTest(type=expected):
self.assertEqual(obj.item_type, expected)
def test_should_identify_if_item_is_a_child(self):
# given
folder = create_folder_menu_item()
child = create_link_menu_item(parent=folder)
not_child = create_link_menu_item()
cases = [(child, True), (not_child, False)]
# when
for obj, expected in cases:
with self.subTest(type=expected):
self.assertIs(obj.is_child, expected)
def test_should_identify_if_item_is_a_folder(self):
# given
app_item = create_app_menu_item()
link_item = create_link_menu_item()
folder_item = create_folder_menu_item()
cases = [
(app_item, False),
(link_item, False),
(folder_item, True),
]
# when
for obj, expected in cases:
with self.subTest(type=expected):
self.assertIs(obj.is_folder, expected)
def test_should_identify_if_item_is_user_defined(self):
# given
app_item = create_app_menu_item()
link_item = create_link_menu_item()
folder_item = create_folder_menu_item()
cases = [
(app_item, False),
(link_item, True),
(folder_item, True),
]
# when
for obj, expected in cases:
with self.subTest(type=expected):
self.assertIs(obj.is_user_defined, expected)
def test_should_identify_if_item_is_an_app_item(self):
# given
app_item = create_app_menu_item()
link_item = create_link_menu_item()
folder_item = create_folder_menu_item()
cases = [
(app_item, True),
(link_item, False),
(folder_item, False),
]
# when
for obj, expected in cases:
with self.subTest(type=expected):
self.assertIs(obj.is_app_item, expected)
def test_should_identify_if_item_is_a_link_item(self):
# given
app_item = create_app_menu_item()
link_item = create_link_menu_item()
folder_item = create_folder_menu_item()
cases = [
(app_item, False),
(link_item, True),
(folder_item, False),
]
# when
for obj, expected in cases:
with self.subTest(type=expected):
self.assertIs(obj.is_link_item, expected)
def test_should_not_allow_creating_invalid_app_item(self):
# when
obj = create_app_menu_item(hook_hash="")
# then
obj.refresh_from_db()
self.assertIsNone(obj.hook_hash)
class TestMenuItemToHookObj(TestCase):
def test_should_create_from_link_item(self):
# given
obj = create_link_menu_item(text="Alpha")
# when
hook_obj = obj.to_hook_obj()
# then
self.assertEqual(hook_obj.text, "Alpha")
self.assertEqual(hook_obj.url, obj.url)
self.assertEqual(hook_obj.html_id, "")
self.assertFalse(hook_obj.is_folder)
def test_should_create_from_folder(self):
# given
obj = create_folder_menu_item(text="Alpha", classes="dummy")
# when
hook_obj = obj.to_hook_obj()
# then
self.assertEqual(hook_obj.text, "Alpha")
self.assertEqual(hook_obj.classes, "dummy")
self.assertEqual(hook_obj.url, "")
self.assertTrue(hook_obj.html_id)
self.assertTrue(hook_obj.is_folder)
def test_should_create_from_folder_and_use_default_icon_classes(self):
# given
obj = create_folder_menu_item(classes="")
# when
hook_obj = obj.to_hook_obj()
# then
self.assertEqual(hook_obj.classes, "fa-solid fa-folder")
def test_should_create_from_app_item(self):
# given
obj = create_app_menu_item(text="Alpha")
# when
with self.assertRaises(ValueError):
obj.to_hook_obj()

View File

@@ -0,0 +1,47 @@
import tempfile
import webbrowser
from pathlib import Path
from bs4 import BeautifulSoup
from django.http import HttpResponse
from django.template import Context, Template
PACKAGE_PATH = "allianceauth.menu"
def extract_links(response: HttpResponse) -> dict:
soup = extract_html(response)
links = {
link["href"]: "".join(link.stripped_strings)
for link in soup.find_all("a", href=True)
}
return links
def extract_html(response: HttpResponse) -> BeautifulSoup:
soup = BeautifulSoup(response.content, "html.parser")
return soup
def open_page_in_browser(response: HttpResponse):
"""Open the response in the system's default browser.
This will create a temporary file in the user's home.
"""
path = Path.home() / "temp"
path.mkdir(exist_ok=True)
with tempfile.NamedTemporaryFile(dir=path, delete=False) as file:
file.write(response.content)
webbrowser.open(file.name)
def render_template(string, context=None):
context = context or {}
context = Context(context)
return Template(string).render(context)
def remove_whitespaces(s) -> str:
return s.replace("\n", "").strip()

View File

@@ -7,14 +7,14 @@
<h4 class="card-title text-center">{% translate "Upcoming Fleets" %}</h4>
<div class="card-body">
<div style="height: 300px; overflow-y:auto;">
<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 "Start Time" %}</th>
<th class="text-center">{% translate "EVE Time" %}</th>
</tr>
</thead>

View File

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

View File

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

View File

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

View File

@@ -23,7 +23,7 @@
</p>
<div class="table-responsive">
<table class="table table-striped" id="tab_permissions_overview" style="width: 100%;">
<table class="table table-striped w-100" id="tab_permissions_overview">
<thead>
<tr>
<th scope="col">{% translate "App" %}</th>
@@ -60,7 +60,7 @@
{% block extra_javascript %}
{% include "bundles/datatables-js-bs5.html" %}
{# {% include "bundles/filterdropdown-js.html" %}#}
{% include "bundles/filterdropdown-js.html" %}
<script>
$(document).ready(() => {
@@ -86,6 +86,7 @@
}
],
bootstrap: true,
bootstrap_version: 5
},
"stateSave": true,
"stateDuration": 0,

View File

@@ -8,9 +8,10 @@ If you wish to make changes, overload the setting in your project's settings fil
import os
from django.contrib import messages
from celery.schedules import crontab
from django.contrib import messages
INSTALLED_APPS = [
'allianceauth', # needs to be on top of this list to support favicons in Django admin (see https://gitlab.com/allianceauth/allianceauth/-/issues/1301)
'django.contrib.admin',
@@ -73,7 +74,6 @@ PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BASE_DIR = os.path.dirname(PROJECT_DIR)
MIDDLEWARE = [
'allianceauth.menu.middleware.MenuSyncMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'allianceauth.authentication.middleware.UserSettingsMiddleware',
@@ -179,7 +179,7 @@ MESSAGE_TAGS = {
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": "redis://127.0.0.1:6379/1" # change the 1 here to change the database used
"LOCATION": "redis://127.0.0.1:6379/1" # change the 1 here for the DB used
}
}
@@ -212,6 +212,8 @@ LOGOUT_REDIRECT_URL = 'authentication:dashboard' # destination after logging ou
# scopes required on new tokens when logging in. Cannot be blank.
LOGIN_TOKEN_SCOPES = ['publicData']
EMAIL_TIMEOUT = 15
# number of days email verification links are valid for
ACCOUNT_ACTIVATION_DAYS = 1

View File

@@ -23,7 +23,7 @@
<div class="row justify-content-center">
<div class="col-md-6">
{% if generated != "" %}
<div class="text-right mb-3">
<div class="text-end mb-3">
<textarea class="form-control" rows="10" cols="60">{{ generated }}</textarea>
</div>
{% endif %}

View File

@@ -1,6 +1,6 @@
{% load i18n %}
<div class="card text-center m-3" style="min-width: 18rem; min-height: 18rem;">
<div class="card text-center m-2" style="min-width: 18rem; min-height: 18rem;">
<div class="card-body">
<h5 class="card-title">{% block title %}{% endblock title %}</h5>

View File

@@ -11,25 +11,33 @@
{% translate "Ship Replacement Program" %}
{% endblock header_nav_brand %}
{% block header_nav_collapse_left %}
<a class="nav-link" href="{% url 'srp:management' %}">
{% translate "View Fleets" %}
</a>
{% endblock header_nav_collapse_left %}
{% block header_nav_collapse_right %}
{% if perms.auth.srp_management %}
<li class="nav-item">
{% if fleet_status == "Completed" %}
<a class="btn btn-warning" href="{% url 'srp:mark_uncompleted' fleet_id %}">
{% translate "Mark Incomplete" %}
</a>
{% else %}
<a class="btn btn-success" href="{% url 'srp:mark_completed' fleet_id %}">
{% translate "Mark Completed" %}
</a>
{% endif %}
</li>
{% endif %}
{% endblock header_nav_collapse_right %}
{% block content %}
<div>
{% translate "SRP Fleet Data" as page_header %}
{% include "framework/header/page-header.html" with title=page_header %}
<div class="text-end mb-3">
{% if perms.auth.srp_management %}
{% if fleet_status == "Completed" %}
<a href="{% url 'srp:mark_uncompleted' fleet_id %}" class="btn btn-warning">
{% translate "Mark Incomplete" %}
</a>
{% else %}
<a href="{% url 'srp:mark_completed' fleet_id %}" class="btn btn-success">
{% translate "Mark Completed" %}
</a>
{% endif %}
{% endif %}
</div>
{% if srpfleetrequests %}
<form method="POST">
{% csrf_token %}

View File

@@ -11,22 +11,26 @@
{% translate "Ship Replacement Program" %}
{% endblock header_nav_brand %}
{% block header_nav_collapse_left %}
{% if perms.auth.srp_management %}
<li class="nav-item">
<a class="nav-link" href="{% url 'srp:all' %}">
{% translate "View All" %}
</a>
</li>
{% endif %}
{% endblock header_nav_collapse_left %}
{% block header_nav_collapse_right %}
{% if perms.srp.add_srpfleetmain or perms.auth.srp_management %}
<li class="nav-item">
<a class="btn btn-success" href="{% url 'srp:add' %}">
{% translate "Add SRP Fleet" %}
</a>
</li>
{% endif %}
{% endblock header_nav_collapse_right %}
{% block content %}
<div>
<div class="text-end mb-3">
{% if perms.auth.srp_management %}
<a href="{% url 'srp:all' %}" class="btn btn-primary">
{% translate "View All" %}
</a>
{% endif %}
{% if perms.srp.add_srpfleetmain or perms.auth.srp_management %}
<a href="{% url 'srp:add' %}" class="btn btn-success">
{% translate "Add SRP Fleet" %}
</a>
{% endif %}
</div>
<div class="alert alert-info" role="alert">
<div class="text-end">
<b>{% translate "Total ISK Cost:" %} {{ totalcost | intcomma }}</b>
@@ -52,9 +56,7 @@
{% for srpfleet in srpfleets %}
<tr>
<td>
<div class="badge bg-info">
{{ srpfleet.fleet_name }}
</div>
{{ srpfleet.fleet_name }}
</td>
<td>{{ srpfleet.fleet_time | date:"Y-m-d H:i" }}</td>
<td>{{ srpfleet.fleet_doctrine }}</td>
@@ -93,7 +95,7 @@
<td>
<div class="badge bg-warning">{{ srpfleet.pending_requests }}</div>
</td>
<td class="text-end">
<td class="text-end text-nowrap">
<a href="{% url 'srp:fleet' srpfleet.id %}" class="btn btn-primary btn-sm m-1" title="View">
<i class="fa-solid fa-eye"></i>
</a>

View File

@@ -1,239 +1,296 @@
/*
/**
* filterDropDown.js
*
* Copyright (C) 2017-18 Erik Kalkoken
* Copyright (C) 2017-24 Erik Kalkoken
*
* Extension for the jQuery plug-in DataTables (developed and tested with v1.10.15)
* Extension for the jQuery plug-in DataTables (developed and tested with v1.13.7)
*
* Version 0.4.0
*
**/
* Version 0.5.0
**/
(($) => {
"use strict";
(function ($) {
// parse initialization array and returns filterDef array to faster and easy use
// also sets defaults for properties that are not set
function parseInitArray(initArray) {
// initialization and setting defaults
let filterDef = {
"columns": [],
"columnsIdxList": [],
"bootstrap": false,
"autoSize": true,
"ajax": null,
"label": "Filter "
/**
* Parse initialization array and returns filterDef array to faster and easy use,
* also sets defaults for properties that are not set
*
* @param initArray
* @returns {{autoSize: boolean, bootstrap_version: number, columnsIdxList: *[], columns: *[], bootstrap: boolean, label: string, ajax: null}}
*/
const parseInitArray = (initArray) => {
/**
* Default filter definition
*
* @type {{autoSize: boolean, bootstrap_version: number, columnsIdxList: *[], columns: *[], bootstrap: boolean, label: string, ajax: null}}
*/
const filterDef = {
columns: [],
columnsIdxList: [],
bootstrap: false,
bootstrap_version: 3,
autoSize: true,
ajax: null,
label: "Filter ",
};
// set filter properties if they have been defined
// otherwise the defaults will be used
if (("bootstrap" in initArray) && (typeof initArray.bootstrap === 'boolean')) {
// Set filter properties if they have been defined otherwise the defaults will be used
if (
"bootstrap" in initArray &&
typeof initArray.bootstrap === "boolean"
) {
filterDef.bootstrap = initArray.bootstrap;
}
if (("autoSize" in initArray) && (typeof initArray.autoSize === 'boolean')) {
if (
"bootstrap_version" in initArray &&
typeof initArray.bootstrap_version === "number"
) {
filterDef.bootstrap_version = initArray.bootstrap_version;
}
if (
"autoSize" in initArray &&
typeof initArray.autoSize === "boolean"
) {
filterDef.autoSize = initArray.autoSize;
}
if (("ajax" in initArray) && (typeof initArray.ajax === 'string')) {
if ("ajax" in initArray && typeof initArray.ajax === "string") {
filterDef.ajax = initArray.ajax;
}
if (("label" in initArray) && (typeof initArray.label === 'string')) {
if ("label" in initArray && typeof initArray.label === "string") {
filterDef.label = initArray.label;
}
// add definition for each column
// Add definition for each column
if ("columns" in initArray) {
initArray.columns.forEach(function (initColumn) {
if (("idx" in initColumn) && (typeof initColumn.idx === 'number')) {
// initialize column
let idx = initColumn.idx;
filterDef['columns'][idx] = {
"title": null,
"maxWidth": null,
"autoSize": true
initArray.columns.forEach((initColumn) => {
if ("idx" in initColumn && typeof initColumn.idx === "number") {
// Initialize column
const idx = initColumn.idx;
filterDef.columns[idx] = {
title: null,
maxWidth: null,
autoSize: true,
};
// add to list of indices in same order they appear in the init array
filterDef['columnsIdxList'].push(idx);
// Add to a list of indices in the same order they appear in the init array
filterDef.columnsIdxList.push(idx);
// set column properties if they have been defined
// otherwise the defaults will be used
if (('title' in initColumn)
&& (typeof initColumn.title === 'string')
// Set column properties if they have been defined otherwise the defaults will be used
if (
"title" in initColumn &&
typeof initColumn.title === "string"
) {
filterDef['columns'][idx].title = initColumn.title;
filterDef.columns[idx].title = initColumn.title;
}
if (('maxWidth' in initColumn)
&& (typeof initColumn.maxWidth === 'string')
if (
"maxWidth" in initColumn &&
typeof initColumn.maxWidth === "string"
) {
filterDef['columns'][idx].maxWidth = initColumn.maxWidth;
filterDef.columns[idx].maxWidth = initColumn.maxWidth;
}
if (('autoSize' in initColumn)
&& (typeof initColumn.autoSize === 'boolean')
if (
"autoSize" in initColumn &&
typeof initColumn.autoSize === "boolean"
) {
filterDef['columns'][idx].autoSize = initColumn.autoSize;
filterDef.columns[idx].autoSize = initColumn.autoSize;
}
}
});
}
return filterDef;
}
};
// Add option d to given select object
function addOption(select, d) {
if (d != "") {
select.append('<option value="' + d + '">' + d + '</option>');
/**
* Add option d to the given select object
*
* @param select
* @param d
*/
const addOption = (select, d) => {
if (d !== "") {
select.append(`<option value="${d}">${d}</option>`);
}
}
};
// initalizing select for current column and applying event to react to changes
function initSelectForColumn(id, column) {
let select = $("#" + id + "_filterSelect" + column.index());
select.on('change', function () {
let val = $.fn.dataTable.util.escapeRegex($(this).val());
column
.search(val ? '^' + val + '$' : '', true, false)
.draw();
/**
* Initialize the select element for given column and apply event to react to changes
*
* @param id
* @param column
* @returns {*|jQuery|HTMLElement}
*/
const initSelectForColumn = (id, column) => {
const select = $(`#${id}_filterSelect${column.index()}`);
$(select).change(() => {
const val = $.fn.dataTable.util.escapeRegex($(select).val());
column.search(val ? `^${val}$` : "", true, false).draw();
});
return select
}
// Add filterDropDown container div, draw select elements with default options
// use preInit so that elements are created and correctly shown before data is loaded
$(document).on('preInit.dt', function (e, settings) {
if (e.namespace !== 'dt') return;
return select;
};
// get api object for current dt table
var api = new $.fn.dataTable.Api(settings);
// Add filterDropDown container div, draw select elements with default options.
// Use preInit so that elements are created and correctly shown before data is loaded
$(document).on("preInit.dt", (e, settings) => {
if (e.namespace !== "dt") {
return;
}
// get id of current table
var id = api.table().node().id;
// Get the api object for the current dt table
const api = new $.fn.dataTable.Api(settings);
// get initialization object for current table to retrieve custom settings
var initObj = api.init();
// Get the id of the current table
const id = api.table().node().id;
// only proceed if filter has been defined in current table, otherwise don't do anything.
if (!("filterDropDown" in initObj)) return;
// Get the initialization object for the current table to retrieve custom settings
const initObj = api.init();
// get current filter definition from init array
var filterDef = parseInitArray(initObj.filterDropDown);
// Only proceed if the filter has been defined in the current table,
// otherwise don't do anything.
if (!("filterDropDown" in initObj)) {
return;
}
// Get the current filter definition from the init array
const filterDef = parseInitArray(initObj.filterDropDown);
// only proceed if there are any columns defined
if (filterDef.columns.length == 0) return;
if (filterDef.columns.length === 0) {
return;
}
// get container div for current data table to add new elements to
var container = api.table().container();
// Get container div for the current data table to add new elements to
const container = api.table().container();
// Add filter elements to DOM
const filterWrapperId = `${id}_filterWrapper`;
// Set CSS classes for the filter wrapper div depending on bootstrap setting
let divCssClass = `${filterWrapperId} ${
filterDef.bootstrap ? "form-inline" : ""
}`;
if (filterDef.bootstrap && filterDef.bootstrap_version === 5) {
divCssClass = `${filterWrapperId} input-group my-3`;
}
// add filter elements to DOM
var filterWrapperId = id + "_filterWrapper";
var divCssClass = filterWrapperId + " " + (
(filterDef.bootstrap)
? "form-inline"
: ""
);
$(container).prepend(
'<div id="'
+ filterWrapperId
+ '" class="'
+ divCssClass + '">'
+ filterDef.label
+ '</div>'
`<div id="${filterWrapperId}" class="${divCssClass}"><span class="pt-2">${filterDef.label}</span></div>`
);
api.columns(filterDef.columnsIdxList).every(function () {
let idx = this.index();
const idx = this.index();
// set title of current column
let colName = (filterDef.columns[idx].title !== null)
? filterDef.columns[idx].title
: $(this.header()).html();
let colName =
filterDef.columns[idx].title !== null
? filterDef.columns[idx].title
: $(this.header()).html();
if (colName == "") colName = 'column ' + (idx + 1);
// adding select element for current column to container
let selectId = id + "_filterSelect" + idx;
$('#' + filterWrapperId).append(
'<select id="'
+ selectId
+ '" class="form-control '
+ id
+ '_filterSelect"></select>'
);
// initalizing select for current column and applying event to react to changes
let select = $("#" + selectId).empty()
.append('<option value="">(' + colName + ')</option>');
// set max width of select elements to current width (which is defined by the size of the title)
// turn off on for very small screens for responsive design or if autoSize has been set to false
if (filterDef.autoSize && filterDef.columns[idx].autoSize && (screen.width > 768)) {
select.css('max-width', select.outerWidth());
if (colName === "") {
colName = `column ${idx + 1}`;
}
// apply optional css style if defined in init array
// will override automatic max width setting
// Adding the select element for current column to container
const selectId = `${id}_filterSelect${idx}`;
// Set CSS classes for the select element depending on bootstrap setting
let selectMarkup = `<select id="${selectId}" class="form-control ${id}_filterSelect"></select>`;
if (filterDef.bootstrap && filterDef.bootstrap_version === 5) {
selectMarkup = `<select id="${selectId}" class="form-select w-auto ms-2 ${id}_filterSelect"></select>`;
}
$("#" + filterWrapperId).append(selectMarkup);
// Initializing select for current column and applying event to react to changes
const select = $("#" + selectId)
.empty()
.append(`<option value="">(${colName})</option>`);
// Set max width of select elements to current width (which is defined by the size of the title)
// Turn off on for very small screens for responsive design, or if autoSize has been set to false
if (
filterDef.autoSize &&
filterDef.columns[idx].autoSize &&
screen.width > 768
) {
select.css("max-width", select.outerWidth());
}
// Apply optional css style if defined in the init array will override automatic max width setting
if (filterDef.columns[idx].maxWidth !== null) {
select.css('max-width', filterDef.columns[idx].maxWidth);
select.css("max-width", filterDef.columns[idx].maxWidth);
}
});
});
// filter table and add available options to dropDowns
$(document).on('init.dt', function (e, settings) {
if (e.namespace !== 'dt') return;
// Filter table and add available options to dropDowns
$(document).on("init.dt", (e, settings) => {
if (e.namespace !== "dt") {
return;
}
// get api object for current dt table
var api = new $.fn.dataTable.Api(settings);
// Get api object for current dt table
const api = new $.fn.dataTable.Api(settings);
// get id of current table
var id = api.table().node().id;
// Get id of current table
const id = api.table().node().id;
// get initialization object for current table to retrieve custom settings
var initObj = api.init();
// Get the initialization object for current table to retrieve custom settings
const initObj = api.init();
// only proceed if filter has been defined in current table, otherwise don't do anything.
if (!("filterDropDown" in initObj)) return;
// Only proceed if a filter has been defined in the current table, otherwise don't do anything.
if (!("filterDropDown" in initObj)) {
return;
}
// get current filter definition
var filterDef = parseInitArray(initObj.filterDropDown);
// Get current filter definition
const filterDef = parseInitArray(initObj.filterDropDown);
if (filterDef.ajax == null) {
api.columns(filterDef.columnsIdxList).every(function () {
let column = this
let select = initSelectForColumn(id, column);
column.data().unique().sort().each(function (d) {
addOption(select, d)
});
const column = this;
const select = initSelectForColumn(id, column);
column
.data()
.unique()
.sort()
.each((d) => {
addOption(select, d);
});
});
} else {
// fetch column options from server for server side processing
let columnsQuery = (
"columns="
+ encodeURIComponent(
api.columns(filterDef.columnsIdxList).dataSrc().join()
)
)
$.getJSON(filterDef.ajax + "?" + columnsQuery, function (columnsOptions) {
// Fetch column options from server for server side processing
const columnsQuery = `columns=${encodeURIComponent(
api.columns(filterDef.columnsIdxList).dataSrc().join()
)}`;
$.getJSON(`${filterDef.ajax}?${columnsQuery}`, (columnsOptions) => {
api.columns(filterDef.columnsIdxList).every(function () {
let column = this;
let select = initSelectForColumn(id, column);
let columnName = column.dataSrc()
const column = this;
const select = initSelectForColumn(id, column);
const columnName = column.dataSrc();
if (columnName in columnsOptions) {
columnsOptions[columnName].forEach(function (d) {
addOption(select, d)
columnsOptions[columnName].forEach((d) => {
addOption(select, d);
});
} else {
console.warn(
"Missing column '" + columnName + "' in ajax response."
)
`Missing column '${columnName}' in ajax response.`
);
}
});
});
}
});
}(jQuery));
})(jQuery);

View File

@@ -1 +1 @@
!function(t){function n(t){let n={columns:[],columnsIdxList:[],bootstrap:!1,autoSize:!0,ajax:null,label:"Filter "};return"bootstrap"in t&&"boolean"==typeof t.bootstrap&&(n.bootstrap=t.bootstrap),"autoSize"in t&&"boolean"==typeof t.autoSize&&(n.autoSize=t.autoSize),"ajax"in t&&"string"==typeof t.ajax&&(n.ajax=t.ajax),"label"in t&&"string"==typeof t.label&&(n.label=t.label),"columns"in t&&t.columns.forEach(function(t){if("idx"in t&&"number"==typeof t.idx){let e=t.idx;n.columns[e]={title:null,maxWidth:null,autoSize:!0},n.columnsIdxList.push(e),"title"in t&&"string"==typeof t.title&&(n.columns[e].title=t.title),"maxWidth"in t&&"string"==typeof t.maxWidth&&(n.columns[e].maxWidth=t.maxWidth),"autoSize"in t&&"boolean"==typeof t.autoSize&&(n.columns[e].autoSize=t.autoSize)}}),n}function e(t,n){""!=n&&t.append('<option value="'+n+'">'+n+"</option>")}function i(n,e){let i=t("#"+n+"_filterSelect"+e.index());return i.on("change",function(){let n=t.fn.dataTable.util.escapeRegex(t(this).val());e.search(n?"^"+n+"$":"",!0,!1).draw()}),i}t(document).on("preInit.dt",function(e,i){if("dt"===e.namespace){var o=new t.fn.dataTable.Api(i),l=o.table().node().id,a=o.init();if("filterDropDown"in a){var u=n(a.filterDropDown);if(0!=u.columns.length){var s=o.table().container(),c=l+"_filterWrapper",r=c+" "+(u.bootstrap?"form-inline":"");t(s).prepend('<div id="'+c+'" class="'+r+'">'+u.label+"</div>"),o.columns(u.columnsIdxList).every(function(){let n=this.index(),e=null!==u.columns[n].title?u.columns[n].title:t(this.header()).html();""==e&&(e="column "+(n+1));let i=l+"_filterSelect"+n;t("#"+c).append('<select id="'+i+'" class="form-control '+l+'_filterSelect"></select>');let o=t("#"+i).empty().append('<option value="">('+e+")</option>");u.autoSize&&u.columns[n].autoSize&&screen.width>768&&o.css("max-width",o.outerWidth()),null!==u.columns[n].maxWidth&&o.css("max-width",u.columns[n].maxWidth)})}}}}),t(document).on("init.dt",function(o,l){if("dt"===o.namespace){var a=new t.fn.dataTable.Api(l),u=a.table().node().id,s=a.init();if("filterDropDown"in s){var c=n(s.filterDropDown);if(null==c.ajax)a.columns(c.columnsIdxList).every(function(){let t=i(u,this);this.data().unique().sort().each(function(n){e(t,n)})});else{let n="columns="+encodeURIComponent(a.columns(c.columnsIdxList).dataSrc().join());t.getJSON(c.ajax+"?"+n,function(t){a.columns(c.columnsIdxList).every(function(){let n=i(u,this),o=this.dataSrc();o in t?t[o].forEach(function(t){e(n,t)}):console.warn("Missing column '"+o+"' in ajax response.")})})}}}})}(jQuery);
($=>{"use strict";const parseInitArray=initArray=>{const filterDef={columns:[],columnsIdxList:[],bootstrap:false,bootstrap_version:3,autoSize:true,ajax:null,label:"Filter "};if("bootstrap"in initArray&&typeof initArray.bootstrap==="boolean"){filterDef.bootstrap=initArray.bootstrap}if("bootstrap_version"in initArray&&typeof initArray.bootstrap_version==="number"){filterDef.bootstrap_version=initArray.bootstrap_version}if("autoSize"in initArray&&typeof initArray.autoSize==="boolean"){filterDef.autoSize=initArray.autoSize}if("ajax"in initArray&&typeof initArray.ajax==="string"){filterDef.ajax=initArray.ajax}if("label"in initArray&&typeof initArray.label==="string"){filterDef.label=initArray.label}if("columns"in initArray){initArray.columns.forEach(initColumn=>{if("idx"in initColumn&&typeof initColumn.idx==="number"){const idx=initColumn.idx;filterDef.columns[idx]={title:null,maxWidth:null,autoSize:true};filterDef.columnsIdxList.push(idx);if("title"in initColumn&&typeof initColumn.title==="string"){filterDef.columns[idx].title=initColumn.title}if("maxWidth"in initColumn&&typeof initColumn.maxWidth==="string"){filterDef.columns[idx].maxWidth=initColumn.maxWidth}if("autoSize"in initColumn&&typeof initColumn.autoSize==="boolean"){filterDef.columns[idx].autoSize=initColumn.autoSize}}})}return filterDef};const addOption=(select,d)=>{if(d!==""){select.append(`<option value="${d}">${d}</option>`)}};const initSelectForColumn=(id,column)=>{const select=$(`#${id}_filterSelect${column.index()}`);$(select).change(()=>{const val=$.fn.dataTable.util.escapeRegex($(select).val());column.search(val?`^${val}$`:"",true,false).draw()});return select};$(document).on("preInit.dt",(e,settings)=>{if(e.namespace!=="dt"){return}const api=new $.fn.dataTable.Api(settings);const id=api.table().node().id;const initObj=api.init();if(!("filterDropDown"in initObj)){return}const filterDef=parseInitArray(initObj.filterDropDown);if(filterDef.columns.length===0){return}const container=api.table().container();const filterWrapperId=`${id}_filterWrapper`;let divCssClass=`${filterWrapperId} ${filterDef.bootstrap?"form-inline":""}`;if(filterDef.bootstrap&&filterDef.bootstrap_version===5){divCssClass=`${filterWrapperId} input-group my-3`}$(container).prepend(`<div id="${filterWrapperId}" class="${divCssClass}"><span class="pt-2">${filterDef.label}</span></div>`);api.columns(filterDef.columnsIdxList).every(function(){const idx=this.index();let colName=filterDef.columns[idx].title!==null?filterDef.columns[idx].title:$(this.header()).html();if(colName===""){colName=`column ${idx+1}`}const selectId=`${id}_filterSelect${idx}`;let selectMarkup=`<select id="${selectId}" class="form-control ${id}_filterSelect"></select>`;if(filterDef.bootstrap&&filterDef.bootstrap_version===5){selectMarkup=`<select id="${selectId}" class="form-select w-auto ms-2 ${id}_filterSelect"></select>`}$("#"+filterWrapperId).append(selectMarkup);const select=$("#"+selectId).empty().append(`<option value="">(${colName})</option>`);if(filterDef.autoSize&&filterDef.columns[idx].autoSize&&screen.width>768){select.css("max-width",select.outerWidth())}if(filterDef.columns[idx].maxWidth!==null){select.css("max-width",filterDef.columns[idx].maxWidth)}})});$(document).on("init.dt",(e,settings)=>{if(e.namespace!=="dt"){return}const api=new $.fn.dataTable.Api(settings);const id=api.table().node().id;const initObj=api.init();if(!("filterDropDown"in initObj)){return}const filterDef=parseInitArray(initObj.filterDropDown);if(filterDef.ajax==null){api.columns(filterDef.columnsIdxList).every(function(){const column=this;const select=initSelectForColumn(id,column);column.data().unique().sort().each(d=>{addOption(select,d)})})}else{const columnsQuery=`columns=${encodeURIComponent(api.columns(filterDef.columnsIdxList).dataSrc().join())}`;$.getJSON(`${filterDef.ajax}?${columnsQuery}`,columnsOptions=>{api.columns(filterDef.columnsIdxList).every(function(){const column=this;const select=initSelectForColumn(id,column);const columnName=column.dataSrc();if(columnName in columnsOptions){columnsOptions[columnName].forEach(d=>{addOption(select,d)})}else{console.warn(`Missing column '${columnName}' in ajax response.`)}})})}})})(jQuery);

View File

@@ -0,0 +1,37 @@
{% load i18n %}
<div id="esi-alert" class="col-12 align-self-stretch py-2 collapse">
<div class="alert alert-warning">
<p class="text-center ">{% translate 'Your Server received an ESI error response code of ' %}<b id="esi-code">?</b></p>
<hr>
<pre id="esi-data" class="text-center text-wrap"></pre>
</div>
</div>
<script>
const elemCard = document.getElementById("esi-alert");
const elemMessage = document.getElementById("esi-data");
const elemCode = document.getElementById("esi-code");
fetch('{% url "authentication:esi_check" %}')
.then((response) => {
if (response.ok) {
return response.json();
}
throw new Error("Something went wrong");
})
.then((responseJson) => {
console.log("ESI Check: ", JSON.stringify(responseJson, null, 2));
const status = responseJson.status;
if (status != 200) {
elemCode.textContent = status
elemMessage.textContent = responseJson.data.error;
new bootstrap.Collapse(elemCard, {
toggle: true
})
}
})
.catch((error) => {
console.log(error);
});
</script>

View File

@@ -2,7 +2,7 @@
{% load humanize %}
{% if notifications %}
<div class="col-12 align-self-stretch pb-2">
<div id="aa-dashboard-panel-admin-notifications" class="col-12 align-self-stretch pb-2">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center">
@@ -51,10 +51,10 @@
</div>
{% endif %}
<div class="col-12 align-self-stretch py-2">
<div class="col-12 align-self-stretch pb-2">
<div class="card">
<div class="card-body d-flex flex-row flex-wrap">
<div class="col-xl-6 col-lg-12 col-md-12 col-sm-12">
<div id="aa-dashboard-panel-software-version" class="col-xl-6 col-lg-12 col-md-12 col-sm-12">
<h4 class="ms-auto me-auto text-center">
{% translate "Software Version" %}
</h4>
@@ -62,14 +62,14 @@
<div class="card-body">
<ul class="list-group list-group-horizontal w-100" role="group" aria-label="{% translate 'Software Version' %}">
<li class="list-group-item w-100">
<div class="btn w-100 cursor-default">
<div class="btn h-100 w-100 cursor-default">
<h5 class="list-group-item-heading">{% translate "Current" %}</h5>
<p class="list-group-item-text">{{ current_version }}</p>
</div>
</li>
<li class="list-group-item bg-{% if latest_patch %}success{% elif latest_minor %}warning{% else %}danger{% endif %} w-100">
<a class="btn w-100" href="https://gitlab.com/allianceauth/allianceauth/-/releases/v{{ latest_patch_version }}">
<a class="btn h-100 w-100" href="https://gitlab.com/allianceauth/allianceauth/-/releases/v{{ latest_patch_version }}">
<h5 class="list-group-item-heading">{% translate "Latest Stable" %}</h5>
<p class="list-group-item-text">
@@ -82,7 +82,7 @@
{% if latest_beta %}
<li class="list-group-item bg-info w-100">
<a class="btn w-100" href="https://gitlab.com/allianceauth/allianceauth/-/releases/v{{ latest_beta_version }}">
<a class="btn h-100 w-100" href="https://gitlab.com/allianceauth/allianceauth/-/releases/v{{ latest_beta_version }}">
<h5 class="list-group-item-heading">{% translate "Latest Pre-Release" %}</h5>
<p class="list-group-item-text">
@@ -97,7 +97,7 @@
</div>
</div>
<div class="col-xl-6 col-lg-12 col-md-12 col-sm-12">
<div id="aa-dashboard-panel-task-queue" class="col-xl-6 col-lg-12 col-md-12 col-sm-12">
<h4 class="ms-auto me-auto text-center">
{% translate "Task Queue" %}
</h4>

View File

@@ -34,12 +34,6 @@
padding-top: {% header_padding_size %} !important;
}
{% endif %}
.auth-logo {
background-position: bottom;
background-repeat: no-repeat;
background-image: url("{% static 'allianceauth/images/auth-logo.png' %}") ;
}
</style>
{% block extra_css %}{% endblock extra_css %}
</head>
@@ -59,20 +53,18 @@
{% block header_nav_brand %}{{ SITE_NAME }}{% endblock %}
</div>
<div class="collapse navbar-collapse" id="navbarexpand">
<div class="m-2"></div>
<ul id="nav-left" class="navbar-nav nav me-auto">
<div class="collapse navbar-collapse ms-2 px-2" id="navbarexpand">
<ul id="nav-left" class="nav navbar-nav me-auto">
{% block header_nav_collapse_left %}
{% endblock %}
</ul>
<ul id="nav-right" class="navbar-nav">
<ul id="nav-right" class="nav navbar-nav">
{% block header_nav_collapse_right %}
{% endblock %}
</ul>
<ul id="nav-right-character-control" class="navbar-nav">
<ul id="nav-right-character-control" class="nav navbar-nav">
{% block header_nav_user_character_control %} <!-- Default to add char and swap main -->
{% include 'allianceauth/top-menu-rh-default.html' %}
{% endblock %}
@@ -106,23 +98,27 @@
<script>
(() => {
// TODO Extend this to the groups in the sidebar too.
// TODO Move to own JS file
const sidebar = document.getElementById('sidebar');
sidebar.addEventListener("shown.bs.collapse", () => {
localStorage.removeItem("sidebar_" + sidebar.id);
sidebar.addEventListener('shown.bs.collapse', () => {
localStorage.removeItem('sidebar_' + sidebar.id);
});
sidebar.addEventListener("hidden.bs.collapse", () => {
localStorage.setItem("sidebar_" + sidebar.id, true);
sidebar.addEventListener('hidden.bs.collapse', () => {
localStorage.setItem('sidebar_' + sidebar.id, 'closed');
});
if (localStorage.getItem("sidebar_" + sidebar.id) === "true") {
sidebar.classList.remove("show")
if (localStorage.getItem('sidebar_' + sidebar.id) === 'closed') {
sidebar.classList.remove('show')
} else {
sidebar.classList.add("show")
}
const activeChildMenuItem = document.querySelector('#sidebar-menu li ul li a.active');
if (activeChildMenuItem) {
activeChildMenuItem.parentElement.parentElement.classList.add('show');
}
})();
</script>

Some files were not shown because too many files have changed in this diff Show More