Compare commits

...

17 Commits

Author SHA1 Message Date
Aaron Kable
cfc57b83ab Merge branch 'datatables-framework' into 'master'
Draft: Basic framework for datatables server rendering

See merge request allianceauth/allianceauth!1785
2026-01-08 07:48:52 +00:00
Aaron Kable
aec055b542 Add more some tests 2026-01-08 15:48:34 +08:00
Aaron Kable
9ed2e97068 Add some tests 2026-01-08 15:44:41 +08:00
Aaron Kable
4d30a59c9c pre-comit save issue 2026-01-08 08:51:54 +08:00
Aaron Kable
1c7c775029 refactor 2026-01-08 08:50:54 +08:00
Aaron Kable
d4e3addd6c Update Docs 2026-01-08 07:59:12 +08:00
Aaron Kable
940f6b14b7 first pass framework for datatables erver rendering 2026-01-07 20:00:52 +08:00
Ariel Rin
99c65d2a5d Merge branch 'helpful-comments' into 'master'
[ADD] Some helpful comments

See merge request allianceauth/allianceauth!1780
2026-01-02 02:43:08 +00:00
Ariel Rin
55125a8ff3 Merge branch 'missing-logo' into 'master'
[ADD] Missing logo to list of available SVG logos

See merge request allianceauth/allianceauth!1781
2026-01-02 02:35:23 +00:00
Ariel Rin
2fd0fcdbcb Merge branch 'notifications_fix' into 'master'
[Fix] wrong delete read url

See merge request allianceauth/allianceauth!1783
2026-01-02 02:35:11 +00:00
Swashman
2fe7bcf20e [Fix] wrong delete read url 2026-01-02 02:35:11 +00:00
Ariel Rin
70f314e578 Merge branch 'development-team' into 'master'
[CHANGE] Update development team

See merge request allianceauth/allianceauth!1782
2025-12-10 08:57:02 +00:00
Peter Pfeufer
bc1b1c3a8f [CHANGE] Update development team 2025-12-10 09:40:47 +01:00
Peter Pfeufer
453512db64 [ADD] Missing logo to list of available SVG logos 2025-11-23 14:02:30 +01:00
Peter Pfeufer
4047159fd1 [ADD] Some helpful comments
Since this is one of the most occurring issues when editing `local.py`
2025-11-22 22:29:24 +01:00
Joel Falknau
f5ddbb8004 version Bump 4.11.2 2025-11-13 11:48:01 +10:00
Joel Falknau
c45d5d7325 Allow the older ua generator to still pass on old python 2025-11-13 11:35:17 +10:00
14 changed files with 503 additions and 23 deletions

View File

@@ -63,7 +63,6 @@ Here is an example of the Alliance Auth web site with a mixture of Services, App
- [Aaron Kable](https://gitlab.com/aaronkable/)
- [Ariel Rin](https://gitlab.com/soratidus999/)
- [Col Crunch](https://gitlab.com/colcrunch/)
- [Erik Kalkoken](https://gitlab.com/ErikKalkoken/)
- [Rounon Dax](https://gitlab.com/ppfeufer)
- [snipereagle1](https://gitlab.com/mckernanin)
@@ -71,6 +70,7 @@ Here is an example of the Alliance Auth web site with a mixture of Services, App
- [Adarnof](https://gitlab.com/adarnof/)
- [Basraah](https://gitlab.com/basraah/)
- [Erik Kalkoken](https://gitlab.com/ErikKalkoken/)
### Beta Testers / Bug Fixers

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.11.1'
__version__ = '4.11.2'
__title__ = 'Alliance Auth'
__title_useragent__ = 'AllianceAuth'
__url__ = 'https://gitlab.com/allianceauth/allianceauth'

View File

@@ -727,7 +727,8 @@ class TestEveSwaggerProvider(TestCase):
my_provider = EveSwaggerProvider()
my_client = my_provider.client
operation = my_client.Universe.get_universe_factions()
self.assertEqual(
operation.future.request.headers['User-Agent'],
f'AllianceAuth/{aa_version} (dummy@example.net; +{aa_url}) Django-ESI/{esi_version} (+{esi_url})'
)
expected_variants = {
f'AllianceAuth/{aa_version} (dummy@example.net; +{aa_url}) DjangoEsi/{esi_version} (+{esi_url})', # Django-ESI 8.0.0
f'AllianceAuth/{aa_version} (dummy@example.net; +{aa_url}) Django-ESI/{esi_version} (+{esi_url})' # Django-ESI 7.x, Py38 Py39
}
self.assertIn(operation.future.request.headers['User-Agent'], expected_variants)

View File

@@ -0,0 +1,143 @@
from collections import defaultdict
import re
from django.db.models import Model, Q
from django.http import HttpRequest, JsonResponse
from django.template import Context, Template
from django.template.loader import render_to_string
from django.views import View
from allianceauth.services.hooks import get_extension_logger
logger = get_extension_logger(__name__)
class DataTablesView(View):
model: Model = None
columns: list[tuple] = []
COL_SEARCH_REGEX = r"columns\[(?P<id>[0-9]{1,})\]\[search\]\[value\]"
COL_SEARCHABLE_REGEX = r"columns\[(?P<id>[0-9]{1,})\]\[searchable\]"
COL_SEARCHABLE_AS_REGEX_REGEX = r"columns\[(?P<id>[0-9]{1,})\]\[search\]\[regex\]"
COL_ORDERABLE_REGEX = r"columns\[(?P<id>[0-9]{1,})\]\[orderable\]"
search_regex: re.Pattern = None
searchable_regex: re.Pattern = None
searchable_as_regex_regex: re.Pattern = None
order_regex: re.Pattern = None
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.search_regex = re.compile(self.COL_SEARCH_REGEX)
self.searchable_regex = re.compile(self.COL_SEARCHABLE_REGEX)
self.searchable_as_regex_regex = re.compile(self.COL_SEARCHABLE_AS_REGEX_REGEX)
self.order_regex = re.compile(self.COL_ORDERABLE_REGEX)
def get_model_qs(self, request: HttpRequest):
return self.model.objects
def get_param(self, request: HttpRequest, key: str, cast=str, default=""):
return cast(request.GET.get(key, default))
def filter_qs(self, search_string: str, column_conf: dict):
# Search
filter_q = Q()
for id, c in column_conf.items():
_c = self.columns[id][0]
if c["searchable"] and len(_c) > 0:
if len(c["search"]) and len(_c):
if c["regex"]:
filter_q |= Q(**{f'{_c}__iregex': c["search"]})
else:
filter_q |= Q(**{f'{_c}__icontains': c["search"]})
if len(search_string) > 0:
filter_q |= Q(**{f'{_c}__icontains': search_string})
return filter_q
def get_columns_config(self, get: dict):
columns = defaultdict(
lambda: {
"searchable": False,
"orderable": False,
"regex": False,
"search": ""
}
)
for c in get:
_r = self.search_regex.findall(c)
if _r:
columns[int(_r[0])]["search"] = get[c]
_r_s = self.searchable_regex.findall(c)
if _r_s:
columns[int(_r_s[0])]["searchable"] = get[c] == "true"
_r_s_r = self.searchable_as_regex_regex.findall(c)
if _r_s_r:
columns[int(_r_s_r[0])]["regex"] = get[c] == "true"
_r_o = self.order_regex.findall(c)
if _r_o:
columns[int(_r_o[0])]["orderable"] = get[c] == "true"
return columns
def order_str(self, order_col, order_dir, column_conf: dict):
order = ""
_o = column_conf[order_col]
if _o["orderable"]:
if order_dir == 'desc':
order = '-' + self.columns[order_col][0]
else:
order = self.columns[order_col][0]
return order
def render_template(self, request: HttpRequest, template: str, ctx: dict):
if "{{" in template:
_template = Template(template)
return _template.render(Context(ctx))
else:
return render_to_string(
template,
ctx,
request
)
def get(self, request: HttpRequest, *args, **kwargs):
# Get all our Params out of GET
length = self.get_param(request, "length", int)
start = self.get_param(request, "start", int)
search_string = self.get_param(request, "search[value]")
order_col = self.get_param(request, "order[0][column]", int, 0)
order_dir = self.get_param(request, "order[0][dir]")
draw = self.get_param(request, "draw", int)
limit = start + length
column_conf = self.get_columns_config(request.GET)
# Searches
filter_q = Q() | self.filter_qs(search_string, column_conf)
# Build response rows
items = []
qs = self.get_model_qs(request).filter(filter_q).order_by()
# Apply ordering
order = self.order_str(order_col, order_dir, column_conf)
if order != "":
qs = qs.order_by(order)
# build output
for row in qs[start:limit]:
ctx = {"row": row}
row = []
for t in self.columns:
row.append(self.render_template(request, t[1], ctx))
items.append(row)
# Build our output dict
datatables_data = {}
datatables_data['draw'] = draw
datatables_data['recordsTotal'] = self.get_model_qs(request).all().count()
datatables_data['recordsFiltered'] = qs.count()
datatables_data['data'] = items
return JsonResponse(datatables_data)

View File

@@ -0,0 +1,160 @@
"""
Test sentinel user
"""
import json
import re
# Django
from allianceauth.tests.auth_utils import AuthUtils
from django.test import RequestFactory, TestCase
from django.http import HttpRequest
# Alliance Auth
from allianceauth.framework.datatables import DataTablesView
from allianceauth.eveonline.models import EveCharacter
class TestView(DataTablesView):
model=EveCharacter
columns = [
("", "{{ row.character_id }}"),
("character_name", "{{ row.character_name }}"),
("corporation_name", "{{ row.corporation_name }}"),
("alliance_name", "{{ row.alliance_name }}"),
]
class TestDataTables(TestCase):
"""
Tests for get_main_character_from_user
"""
def setUp(self):
self.get_params = {
'draw': '1',
'columns[0][data]': '0',
'columns[0][name]': '',
'columns[0][searchable]': 'false',
'columns[0][orderable]': 'false',
'columns[0][search][value]': '',
'columns[0][search][regex]': 'false',
'columns[1][data]': '1',
'columns[1][name]': '',
'columns[1][searchable]': 'true',
'columns[1][orderable]': 'true',
'columns[1][search][value]': '',
'columns[1][search][regex]': 'false',
'columns[2][data]': '2',
'columns[2][name]': '',
'columns[2][searchable]': 'true',
'columns[2][orderable]': 'false',
'columns[2][search][value]': '',
'columns[2][search][regex]': 'false',
'columns[3][data]': '3',
'columns[3][name]': '',
'columns[3][searchable]': 'true',
'columns[3][orderable]': 'true',
'columns[3][search][value]': '',
'columns[3][search][regex]': 'false',
'order[0][column]': '1',
'order[0][dir]': 'asc',
'start': '0',
'length': '10',
'search[value]': '',
'search[regex]': 'false',
'_': '123456789'
}
@classmethod
def setUpClass(cls) -> None:
"""
Set up eve models
"""
super().setUpClass()
cls.factory = RequestFactory()
cls.user = AuthUtils.create_user("bruce_wayne")
cls.user.is_superuser = True
cls.user.save()
EveCharacter.objects.all().delete()
for i in range(1,21):
EveCharacter.objects.create(
character_id=1000+i,
character_name=f"{1000+i} - Test Character",
corporation_id=2000+i,
corporation_name=f"{2000+i} - Test Corporation",
alliance_id=3000+i,
alliance_name=f"{3000+i} - Test Alliance",
)
def test_view_default(self):
self.client.force_login(self.user)
request = self.factory.get('/fake-url/', data=self.get_params)
response = TestView()
response.setup(request)
data = json.loads(response.get(request).content)["data"]
self.assertEqual(data[0][0], "1001")
self.assertEqual(data[9][0], "1010")
def test_view_reverse_sort(self):
self.get_params["order[0][dir]"] = "desc"
self.client.force_login(self.user)
request = self.factory.get('/fake-url/', data=self.get_params)
response = TestView()
response.setup(request)
data = json.loads(response.get(request).content)["data"]
self.assertEqual(data[0][0], "1020")
self.assertEqual(data[9][0], "1011")
def test_view_non_sortable_sort(self):
self.get_params["order[0][dir]"] = "desc"
self.get_params["order[0][column]"] = "0"
self.client.force_login(self.user)
request = self.factory.get('/fake-url/', data=self.get_params)
response = TestView()
response.setup(request)
data = json.loads(response.get(request).content)["data"]
self.assertEqual(data[0][0], "1001")
self.assertEqual(data[9][0], "1010")
def test_view_20_rows(self):
self.get_params["length"] = "20"
self.client.force_login(self.user)
request = self.factory.get('/fake-url/', data=self.get_params)
response = TestView()
response.setup(request)
data = json.loads(response.get(request).content)["data"]
self.assertEqual(data[0][0], "1001")
self.assertEqual(data[19][0], "1020")
def test_view_global_search(self):
self.get_params["search[value]"] = "1020"
self.client.force_login(self.user)
request = self.factory.get('/fake-url/', data=self.get_params)
response = TestView()
response.setup(request)
data = json.loads(response.get(request).content)["data"]
self.assertEqual(len(data), 1)
self.assertEqual(data[0][0], "1020")
def test_view_col_1_search(self):
self.get_params["columns[1][search][value]"] = "1020"
self.client.force_login(self.user)
request = self.factory.get('/fake-url/', data=self.get_params)
response = TestView()
response.setup(request)
data = json.loads(response.get(request).content)["data"]
self.assertEqual(len(data), 1)
self.assertEqual(data[0][0], "1020")
def test_view_col_1_search_empty(self):
self.get_params["columns[1][search][value]"] = "zzz"
self.client.force_login(self.user)
request = self.factory.get('/fake-url/', data=self.get_params)
response = TestView()
response.setup(request)
data = json.loads(response.get(request).content)["data"]
self.assertEqual(len(data), 0)

View File

@@ -33,7 +33,7 @@
{% include "framework/header/nav-collapse-icon.html" with fa_icon="fa-solid fa-check-double" url=nav_item_link title=nav_item_title icon_on_mobile=True %}
{% translate "Delete all read notifications" as nav_item_title %}
{% url "notifications:mark_all_read" as nav_item_link %}
{% url "notifications:delete_all_read" as nav_item_link %}
{% include "framework/header/nav-collapse-icon.html" with fa_icon="fa-solid fa-trash-can" url=nav_item_link title=nav_item_title icon_on_mobile=True %}
{% endblock %}

View File

@@ -57,10 +57,10 @@ DATABASES['default'] = {
# CCP's developer portal
# Logging in to auth requires the publicData scope (can be overridden through the
# LOGIN_TOKEN_SCOPES setting). Other apps may require more (see their docs).
ESI_SSO_CALLBACK_URL = f"{SITE_URL}/sso/callback" # Do NOT change this line!
ESI_SSO_CLIENT_ID = ''
ESI_SSO_CLIENT_SECRET = ''
ESI_SSO_CALLBACK_URL = f"{SITE_URL}/sso/callback"
ESI_USER_CONTACT_EMAIL = '' # A server maintainer that CCP can contact in case of issues.
ESI_USER_CONTACT_EMAIL = '' # A server maintainer that CCP can contact in case of issues.
# By default, emails are validated before new users can log in.
# It's recommended to use a free service like SparkPost or Elastic Email to send email.

View File

@@ -1,7 +1,7 @@
PROTOCOL=https://
AUTH_SUBDOMAIN=%AUTH_SUBDOMAIN%
DOMAIN=%DOMAIN%
AA_DOCKER_TAG=registry.gitlab.com/allianceauth/allianceauth/auth:v4.11.1
AA_DOCKER_TAG=registry.gitlab.com/allianceauth/allianceauth/auth:v4.11.2
# Nginx Proxy Manager
PROXY_HTTP_PORT=80

View File

@@ -1,5 +1,5 @@
FROM python:3.11-slim
ARG AUTH_VERSION=v4.11.1
ARG AUTH_VERSION=v4.11.2
ARG AUTH_PACKAGE=allianceauth==${AUTH_VERSION}
ENV AUTH_USER=allianceauth
ENV AUTH_GROUP=allianceauth

View File

@@ -17,9 +17,7 @@ DATABASES["default"] = {
"PASSWORD": os.environ.get("AA_DB_PASSWORD"),
"HOST": os.environ.get("AA_DB_HOST"),
"PORT": os.environ.get("AA_DB_PORT", "3306"),
"OPTIONS": {
"charset": os.environ.get("AA_DB_CHARSET", "utf8mb4")
}
"OPTIONS": {"charset": os.environ.get("AA_DB_CHARSET", "utf8mb4")},
}
# Register an application at https://developers.eveonline.com for Authentication
@@ -27,10 +25,9 @@ DATABASES["default"] = {
# to https://example.com/sso/callback substituting your domain for example.com
# Logging in to auth requires the publicData scope (can be overridden through the
# LOGIN_TOKEN_SCOPES setting). Other apps may require more (see their docs).
ESI_SSO_CALLBACK_URL = f"{SITE_URL}/sso/callback" # Do NOT change this line!
ESI_SSO_CLIENT_ID = os.environ.get("ESI_SSO_CLIENT_ID")
ESI_SSO_CLIENT_SECRET = os.environ.get("ESI_SSO_CLIENT_SECRET")
ESI_SSO_CALLBACK_URL = f"{SITE_URL}/sso/callback"
ESI_USER_CONTACT_EMAIL = os.environ.get(
"ESI_USER_CONTACT_EMAIL"
) # A server maintainer that CCP can contact in case of issues.
@@ -70,7 +67,6 @@ INSTALLED_APPS += [
# 'allianceauth.permissions_tool',
# 'allianceauth.srp',
# 'allianceauth.timerboard',
# https://allianceauth.readthedocs.io/en/latest/features/services/index.html
# 'allianceauth.services.modules.discord',
# 'allianceauth.services.modules.discourse',

View File

@@ -0,0 +1,179 @@
# DataTables Server Side Rendering
The `allianceauth.framework.datatables.DataTablesView` module provides a simple class based view to
implement simple server side filtering ordering and searching of DataTables.
This is intended to make the life of our community apps developer a little
easier, so they don't have to reinvent the wheel.
## Usage
To use this view is as easy as defining your stub templates, and fields and adding the view to the `urls.py`
Given the `EveCharacter` Model as our model of choice we would define our stubs like so
## Add our Templates
### template/appname/stubs/icon.html
```django
{% load evelinks %}
{% character_portrait_url row 32 %}
```
### template/appname/stubs/name.html
```django
{{ row.character_name }} <span class="text-small">({{ row.character_ownership.user.username }})</span>
```
### template/appname/stubs/corp.html
```django
{{ row.corporation_name }}
```
### template/appname/list.html
```django
{% extends "allianceauth/base-bs5.html" %}
{% load i18n %}
{% block page_title %}
{% translate "App Name" %}
{% endblock page_title %}
{% block content %}
<table class="table table-striped w-100" id="table">
<!-- Normal Header Rows -->
<thead>
<tr>
<th></th>
<th>{% translate "Name" %}</th>
<th>{% translate "Corporation" %}</th>
<th>{% translate "Alliance" %}</th>
</tr>
</thead>
<!-- Put the footer in the header so it doesn't trigger the sorting on searching -->
<!-- only footers with text will get given an input -->
<!-- This is just an option, you do it however you like -->
<tfoot style="display: table-header-group;">
<tr>
<th></th>
<th>{% translate "Name" %}</th>
<th>{% translate "Corporation" %}</th>
<th>{% translate "Alliance" %}</th>
</tr>
</tfoot>
<!-- Empty tbody -->
<tbody>
</tbody>
</table>
{% endblock content %}
{% block extra_css %}
{% include 'bundles/datatables-css-bs5.html' %}
{% endblock %}
{% block extra_javascript %}
{% include 'bundles/datatables-js-bs5.html' %}
<script>
$(document).ready(function() {
$('#table').DataTable({
serverSide: true,
ajax: '{% url "appname:table" %}',
// This is for the column searching
initComplete: function () {
this.api()
.columns()
.every(function () {
let column = this;
let title = column.footer().textContent;
if (title != ""){
// Create input element
let input = document.createElement('input');
input.classList.add("w-100")
input.placeholder = title;
column.footer().replaceChildren(input);
// Event listener for user input
input.addEventListener('keyup', () => {
if (column.search() !== this.value) {
column.search(input.value).draw();
}
});
}
});
},
columnDefs: [
{ "searchable": false, "targets": [0] },
{ "sortable": false, "targets": [0] },
],
order: [
[1, "asc"]
],
pageLength: 10,
responsive : true
});
});
</script>
{% endblock extra_javascript %}
```
## Add our Views
Then we can setup out view in our `appname/views.py` file.
```python
from django.shortcuts import render
# Alliance Auth
from allianceauth.framework.datatables import DataTablesView
from allianceauth.eveonline.models import EveCharacter
## Datatables server side view
class EveCharacterTable(DataTablesView):
model = EveCharacter
# Define the columns as a tuple.
# String for field name for filtering and ordering
# String for the render template
# Templates can be a html file or template language directly in the list below
columns = [
# ("field_for_queries_or_sort", template: str)
("", "appname/stubs/icon.html"),
("character_name", "appname/stubs/name.html"),
("corporation_name", "appname/stubs/corp.html"),
("alliance_name", "{{ row.alliance_name }} {{ row.alliance_id }}"),
]
# if you need to do some prefetch or pre-filtering you can overide this function
def get_model_qs(self, request: HttpRequest):
qs = self.model.objects
if not request.user.is_superuser:
# eg only show unlinked characters to non-superusers
# just an example
# filtering here will prevent people searching things that may not be visible to them
qs = qs.filter(character_ownership__isnull=True)
# maybe some character ownership select related for performance?
return qs.select_related("character_ownership", "character_ownership__user")
## Main Page View
def show_table(request):
return render("appname/list.html")
```
## Add our Urls
### appname/urls.py
```python
from django.urls import path
from . import views
app_name = 'appname'
urlpatterns = [
path("list/", views.EveCharacterTable.as_view(), name='eve_character_table'),
path("tables/data_table", views.show_table, name='table')
]
```
and you are done.

View File

@@ -28,6 +28,7 @@ The following icons are available in the Alliance Auth SVG sprite:
- `aa-logo`: The Alliance Auth logo
- `aa-loading-spinner`: A loading spinner icon
- `aa-mumble-logo`: The Mumble logo
### Alliance Auth Logo

View File

@@ -20,8 +20,8 @@ Make the following changes in your auth project's settings file (`local.py`):
# Be sure to set the callback URLto https://example.com/discord/callback/
# substituting your domain for example.com in Discord's developer portal
# (Be sure to add the trailing slash)
DISCORD_CALLBACK_URL = f"{SITE_URL}/discord/callback/" # Do NOT change this line!
DISCORD_GUILD_ID = ''
DISCORD_CALLBACK_URL = f"{SITE_URL}/discord/callback/"
DISCORD_APP_ID = ''
DISCORD_APP_SECRET = ''
DISCORD_BOT_TOKEN = ''

10
tox.ini
View File

@@ -7,7 +7,7 @@ envlist = py{38,39,310,311,312}-{all,core}, docs
[testenv]
setenv =
all: DJANGO_SETTINGS_MODULE = tests.settings_all
core: DJANGO_SETTINGS_MODULE = tests.settings_core
; core: DJANGO_SETTINGS_MODULE = tests.settings_core
basepython =
py38: python3.8
py39: python3.9
@@ -18,10 +18,10 @@ deps=
coverage
install_command = pip install -e ".[test]" -U {opts} {packages}
commands =
all: coverage run runtests.py -v 2 --debug-mode
core: coverage run runtests.py allianceauth.authentication.tests.test_app_settings -v 2 --debug-mode
all: coverage report -m
all: coverage xml
all: coverage run runtests.py allianceauth.framework.tests.test_datatables -v 2 --debug-mode
; core: coverage run runtests.py allianceauth.authentication.tests.test_app_settings -v 2 --debug-mode
; all: coverage report -m
; all: coverage xml
[testenv:docs]
description = invoke sphinx-build to build the HTML docs