From 940f6b14b7ef17e3091438324ffad932e318cded Mon Sep 17 00:00:00 2001 From: Aaron Kable Date: Wed, 7 Jan 2026 20:00:52 +0800 Subject: [PATCH 01/25] first pass framework for datatables erver rendering --- allianceauth/framework/datatables.py | 115 +++++++++++ .../custom/framework/datatables.md | 183 ++++++++++++++++++ 2 files changed, 298 insertions(+) create mode 100644 allianceauth/framework/datatables.py create mode 100644 docs/development/custom/framework/datatables.md diff --git a/allianceauth/framework/datatables.py b/allianceauth/framework/datatables.py new file mode 100644 index 00000000..f80e88f8 --- /dev/null +++ b/allianceauth/framework/datatables.py @@ -0,0 +1,115 @@ +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): + """ + Basic DataTables server side table rendering + """ + model: Model = None + templates: list[str] = [] + columns: list[tuple] = [] + + 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): + # Global Search + filter_q = Q() + if len(search_string) > 0: + val = search_string + for x in self.columns: + if x[0]: + # Apply search to all Columns + filter_q |= Q(**{f'{x[1]}__icontains': val}) + return filter_q + + def filter_col_qs(self, get: dict): + # Row Search + # TODO check if we have a regex search or not + # TODO check if searchable or not + col_id_regex = r"columns\[(?P[0-9]{1,})\]\[search\]\[value\]" + regex = re.compile(col_id_regex) + filter_q = Q() + for c in get: + _r = regex.findall(c) + if _r: + _c = self.columns[int(_r[0])] + if _c[0]: + if len(get[c]): + filter_q |= Q(**{f'{_c[1]}__iregex': get[c]}) + return filter_q + + def order_str(self, order_col, order_dir): + order = "" + _o = self.columns[order_col] + if _o[0]: + if order_dir == 'desc': + order = '-' + _o[1] + else: + order = _o[1] + 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 + + # Searches + filter_q = Q() | self.filter_qs(search_string) | self.filter_col_qs(request.GET) + + # 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) + if order != "": + qs = qs.order_by(order) + + # build output + for row in qs[start:limit]: + ctx = {"row": row} + row = [] + for t in self.templates: + row.append(self.render_template(request, t, 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) diff --git a/docs/development/custom/framework/datatables.md b/docs/development/custom/framework/datatables.md new file mode 100644 index 00000000..e1214b51 --- /dev/null +++ b/docs/development/custom/framework/datatables.md @@ -0,0 +1,183 @@ +# 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 + +```html +{% load evelinks %} +{% character_portrait_url row 32 %} +``` + +### template/appname/stubs/name.html + +```html +{{ row.character_name }} ({{row.character_ownership.user.username}}) +``` + +### template/appname/stubs/corp.html + +```html +{{ row.corporation_name }} +``` + +### template/appname/list.html + +```html +{% extends "allianceauth/base-bs5.html" %} +{% load i18n %} +{% block page_title %} + {% translate "App Name" %} +{% endblock page_title %} +{% block content %} + + + + + + + + + + + + + + + + + + + + + + + + +
{% translate "Name" %}{% translate "Corporation" %}{% translate "Alliance" %}
{% translate "Name" %}{% translate "Corporation" %}{% translate "Alliance" %}
+{% endblock content %} +{% block extra_css %} + {% include 'bundles/datatables-css-bs5.html' %} +{% endblock %} +{% block extra_javascript %} + {% include 'bundles/datatables-js-bs5.html' %} + +{% 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 + # Templates can be a html file or template language directly in the list below + templates = [ + "appname/stubs/icon.html", + "appname/stubs/name.html", + "appname/stubs/corp.html", + "{{ row.alliance_name }} {{ row.alliance_id }}" + ] + + # Define the columns for filtering and ordering + columns = [ + # (can_sort: bool, "field_for_queries_or_sort") + (False, ""), + (True, "character_name"), + (True, "corporation_name"), + (True, "alliance_name"), + ] + + # 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. From d4e3addd6cf060fb3ee1a456eb50dc00739bb8a2 Mon Sep 17 00:00:00 2001 From: Aaron Kable Date: Thu, 8 Jan 2026 07:59:12 +0800 Subject: [PATCH 02/25] Update Docs --- docs/development/custom/framework/datatables.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/development/custom/framework/datatables.md b/docs/development/custom/framework/datatables.md index e1214b51..b32792f8 100644 --- a/docs/development/custom/framework/datatables.md +++ b/docs/development/custom/framework/datatables.md @@ -16,26 +16,26 @@ Given the `EveCharacter` Model as our model of choice we would define our stubs ### template/appname/stubs/icon.html -```html +```django {% load evelinks %} {% character_portrait_url row 32 %} ``` ### template/appname/stubs/name.html -```html -{{ row.character_name }} ({{row.character_ownership.user.username}}) +```django +{{ row.character_name }} ({{ row.character_ownership.user.username }}) ``` ### template/appname/stubs/corp.html -```html +```django {{ row.corporation_name }} ``` ### template/appname/list.html -```html +```django {% extends "allianceauth/base-bs5.html" %} {% load i18n %} {% block page_title %} @@ -77,7 +77,7 @@ Given the `EveCharacter` Model as our model of choice we would define our stubs $(document).ready(function() { $('#table').DataTable({ serverSide: true, - ajax: '/appname/tables/data_table', + ajax: '{% url "appname:table" %}', // This is for the column searching initComplete: function () { this.api() @@ -130,6 +130,7 @@ from allianceauth.eveonline.models import EveCharacter ## Datatables server side view class EveCharacterTable(DataTablesView): model = EveCharacter + # Columns are rendered from these templates # Templates can be a html file or template language directly in the list below templates = [ "appname/stubs/icon.html", From 1c7c775029f8f2bc2679324e1a7288e847e124c5 Mon Sep 17 00:00:00 2001 From: Aaron Kable Date: Thu, 8 Jan 2026 08:50:54 +0800 Subject: [PATCH 03/25] refactor --- allianceauth/framework/datatables.py | 98 ++++++++++++------- .../custom/framework/datatables.md | 23 ++--- 2 files changed, 72 insertions(+), 49 deletions(-) diff --git a/allianceauth/framework/datatables.py b/allianceauth/framework/datatables.py index f80e88f8..731f8cbe 100644 --- a/allianceauth/framework/datatables.py +++ b/allianceauth/framework/datatables.py @@ -12,54 +12,80 @@ from allianceauth.services.hooks import get_extension_logger logger = get_extension_logger(__name__) class DataTablesView(View): - """ - Basic DataTables server side table rendering - """ + model: Model = None - templates: list[str] = [] columns: list[tuple] = [] + COL_SEARCH_REGEX = r"columns\[(?P[0-9]{1,})\]\[search\]\[value\]" + COL_SEARCHABLE_REGEX = r"columns\[(?P[0-9]{1,})\]\[searchable\]" + COL_SEARCHABLE_AS_REGEX_REGEX = r"columns\[(?P[0-9]{1,})\]\[search\]\[regex\]" + COL_ORDERABLE_REGEX = r"columns\[(?P[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): - # Global Search + def filter_qs(self, search_string: str, column_conf: dict): + # Search filter_q = Q() - if len(search_string) > 0: - val = search_string - for x in self.columns: - if x[0]: - # Apply search to all Columns - filter_q |= Q(**{f'{x[1]}__icontains': val}) + 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 filter_col_qs(self, get: dict): - # Row Search - # TODO check if we have a regex search or not - # TODO check if searchable or not - col_id_regex = r"columns\[(?P[0-9]{1,})\]\[search\]\[value\]" - regex = re.compile(col_id_regex) - filter_q = Q() + def get_columns_config(self, get: dict): + columns = defaultdict( + lambda: { + "searchable": False, + "orderable": False, + "regex": False, + "search": "" + } + ) for c in get: - _r = regex.findall(c) + _r = self.search_regex.findall(c) if _r: - _c = self.columns[int(_r[0])] - if _c[0]: - if len(get[c]): - filter_q |= Q(**{f'{_c[1]}__iregex': get[c]}) - return filter_q + 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): + def order_str(self, order_col, order_dir, column_conf: dict): order = "" - _o = self.columns[order_col] - if _o[0]: + _o = column_conf[order_col] + if _o["orderable"]: if order_dir == 'desc': - order = '-' + _o[1] + order = '-' + self.columns[order_col][0] else: - order = _o[1] + order = self.columns[order_col][0] return order def render_template(self, request: HttpRequest, template: str, ctx: dict): @@ -85,24 +111,26 @@ class DataTablesView(View): limit = start + length + column_conf = self.get_columns_config(request.GET) + # Searches - filter_q = Q() | self.filter_qs(search_string) | self.filter_col_qs(request.GET) + 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) + 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} + ctx = {"note": row} row = [] - for t in self.templates: - row.append(self.render_template(request, t, ctx)) + for t in self.columns: + row.append(self.render_template(request, t[1], ctx)) items.append(row) # Build our output dict diff --git a/docs/development/custom/framework/datatables.md b/docs/development/custom/framework/datatables.md index b32792f8..3493f802 100644 --- a/docs/development/custom/framework/datatables.md +++ b/docs/development/custom/framework/datatables.md @@ -130,22 +130,17 @@ from allianceauth.eveonline.models import EveCharacter ## Datatables server side view class EveCharacterTable(DataTablesView): model = EveCharacter - # Columns are rendered from these templates - # Templates can be a html file or template language directly in the list below - templates = [ - "appname/stubs/icon.html", - "appname/stubs/name.html", - "appname/stubs/corp.html", - "{{ row.alliance_name }} {{ row.alliance_id }}" - ] - # Define the columns for filtering and ordering + # 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 = [ - # (can_sort: bool, "field_for_queries_or_sort") - (False, ""), - (True, "character_name"), - (True, "corporation_name"), - (True, "alliance_name"), + # ("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 From 4d30a59c9ceca3f32d85ec1e44739a77c737d970 Mon Sep 17 00:00:00 2001 From: Aaron Kable Date: Thu, 8 Jan 2026 08:51:54 +0800 Subject: [PATCH 04/25] pre-comit save issue --- allianceauth/framework/datatables.py | 1 + 1 file changed, 1 insertion(+) diff --git a/allianceauth/framework/datatables.py b/allianceauth/framework/datatables.py index 731f8cbe..e34aef7e 100644 --- a/allianceauth/framework/datatables.py +++ b/allianceauth/framework/datatables.py @@ -1,3 +1,4 @@ +from collections import defaultdict import re from django.db.models import Model, Q From 9ed2e97068a3e25b9a093de0a801b1cb8143b5ba Mon Sep 17 00:00:00 2001 From: Aaron Kable Date: Thu, 8 Jan 2026 15:44:41 +0800 Subject: [PATCH 05/25] Add some tests --- allianceauth/framework/datatables.py | 3 +- .../framework/tests/test_datatables.py | 149 ++++++++++++++++++ tox.ini | 10 +- 3 files changed, 155 insertions(+), 7 deletions(-) create mode 100644 allianceauth/framework/tests/test_datatables.py diff --git a/allianceauth/framework/datatables.py b/allianceauth/framework/datatables.py index e34aef7e..fc10d6d7 100644 --- a/allianceauth/framework/datatables.py +++ b/allianceauth/framework/datatables.py @@ -101,7 +101,6 @@ class DataTablesView(View): ) 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) @@ -128,7 +127,7 @@ class DataTablesView(View): # build output for row in qs[start:limit]: - ctx = {"note": row} + ctx = {"row": row} row = [] for t in self.columns: row.append(self.render_template(request, t[1], ctx)) diff --git a/allianceauth/framework/tests/test_datatables.py b/allianceauth/framework/tests/test_datatables.py new file mode 100644 index 00000000..c86e299d --- /dev/null +++ b/allianceauth/framework/tests/test_datatables.py @@ -0,0 +1,149 @@ +""" +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_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) diff --git a/tox.ini b/tox.ini index 6c90339e..bd953aec 100644 --- a/tox.ini +++ b/tox.ini @@ -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 From aec055b542a234050925fc6cfa12a43d649d84bc Mon Sep 17 00:00:00 2001 From: Aaron Kable Date: Thu, 8 Jan 2026 15:48:34 +0800 Subject: [PATCH 06/25] Add more some tests --- .../framework/tests/test_datatables.py | 75 +++++++++++-------- 1 file changed, 43 insertions(+), 32 deletions(-) diff --git a/allianceauth/framework/tests/test_datatables.py b/allianceauth/framework/tests/test_datatables.py index c86e299d..cf7dee4c 100644 --- a/allianceauth/framework/tests/test_datatables.py +++ b/allianceauth/framework/tests/test_datatables.py @@ -29,38 +29,38 @@ class TestDataTables(TestCase): 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'] + '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' } @@ -109,6 +109,17 @@ class TestDataTables(TestCase): 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) From de43114681c60d6e7cbdf65b59a0c9580282d0a8 Mon Sep 17 00:00:00 2001 From: Aaron Kable Date: Thu, 8 Jan 2026 17:45:26 +0800 Subject: [PATCH 07/25] pass url params into filters/qs --- allianceauth/framework/datatables.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/allianceauth/framework/datatables.py b/allianceauth/framework/datatables.py index fc10d6d7..55b15fe5 100644 --- a/allianceauth/framework/datatables.py +++ b/allianceauth/framework/datatables.py @@ -34,7 +34,7 @@ class DataTablesView(View): 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): + def get_model_qs(self, request: HttpRequest, *args, **kwargs): return self.model.objects def get_param(self, request: HttpRequest, key: str, cast=str, default=""): @@ -101,6 +101,7 @@ class DataTablesView(View): ) 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) @@ -118,7 +119,7 @@ class DataTablesView(View): # Build response rows items = [] - qs = self.get_model_qs(request).filter(filter_q).order_by() + qs = self.get_model_qs(request, *args, **kwargs).filter(filter_q).order_by() # Apply ordering order = self.order_str(order_col, order_dir, column_conf) @@ -127,7 +128,7 @@ class DataTablesView(View): # build output for row in qs[start:limit]: - ctx = {"row": row} + ctx = {"note": row} row = [] for t in self.columns: row.append(self.render_template(request, t[1], ctx)) @@ -136,7 +137,7 @@ class DataTablesView(View): # Build our output dict datatables_data = {} datatables_data['draw'] = draw - datatables_data['recordsTotal'] = self.get_model_qs(request).all().count() + datatables_data['recordsTotal'] = self.get_model_qs(request, *args, **kwargs).all().count() datatables_data['recordsFiltered'] = qs.count() datatables_data['data'] = items From fb7ae95a8a386aa9effdb0ec6e25a63a945608a4 Mon Sep 17 00:00:00 2001 From: Aaron Kable Date: Thu, 8 Jan 2026 17:58:10 +0800 Subject: [PATCH 08/25] fox template cxt --- allianceauth/framework/datatables.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/allianceauth/framework/datatables.py b/allianceauth/framework/datatables.py index 55b15fe5..b3494e65 100644 --- a/allianceauth/framework/datatables.py +++ b/allianceauth/framework/datatables.py @@ -128,7 +128,7 @@ class DataTablesView(View): # build output for row in qs[start:limit]: - ctx = {"note": row} + ctx = {"row": row} row = [] for t in self.columns: row.append(self.render_template(request, t[1], ctx)) From a5c09e0fc7fb3b95afa3fd6c683731553ccbc451 Mon Sep 17 00:00:00 2001 From: Aaron Kable Date: Thu, 8 Jan 2026 18:21:03 +0800 Subject: [PATCH 09/25] Add more detail to docs --- .../development/custom/framework/datatables.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/development/custom/framework/datatables.md b/docs/development/custom/framework/datatables.md index 3493f802..1f4b1765 100644 --- a/docs/development/custom/framework/datatables.md +++ b/docs/development/custom/framework/datatables.md @@ -121,6 +121,24 @@ Given the `EveCharacter` Model as our model of choice we would define our stubs Then we can setup out view in our `appname/views.py` file. +### Columns definition + +The `columns` must be defined as a 2 part tuple + +- Part 1 is the database field that will be used for filtering and ordering. If this is a foreign key you need to point to a field that is compatible with `__icontains` like `charField` or `textField`. It can be `None`/`False`/`""` if no ordering for filtering is required for this row. + - Examples for the EveCharacter Model: + - `character_name` + - `character_ownership__user__username` + - `character_ownership__user__profile__main_character__character_name` +- Part 2 is a string that is used to the render the column for each row. This can be a html stub or a string containing django style template language. + - Examples for the EveCharacter Model + - `{{ row.character_name }}` + - `{{ row.character_ownership.user.username }}` + - `{{ row.character_ownership.user.profile.main_character.character_name }}` + - `appname/stubs/character_img.html` + +### appname/views.py + ```python from django.shortcuts import render # Alliance Auth From d36e3a12566c937a3e8971c709f7f982fc808b28 Mon Sep 17 00:00:00 2001 From: Aaron Kable Date: Thu, 8 Jan 2026 18:37:43 +0800 Subject: [PATCH 10/25] py 3.8 ... --- allianceauth/framework/datatables.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/allianceauth/framework/datatables.py b/allianceauth/framework/datatables.py index b3494e65..56c1fa3d 100644 --- a/allianceauth/framework/datatables.py +++ b/allianceauth/framework/datatables.py @@ -1,5 +1,6 @@ from collections import defaultdict import re +from typing import List from django.db.models import Model, Q from django.http import HttpRequest, JsonResponse @@ -15,7 +16,7 @@ logger = get_extension_logger(__name__) class DataTablesView(View): model: Model = None - columns: list[tuple] = [] + columns: List[tuple] = [] COL_SEARCH_REGEX = r"columns\[(?P[0-9]{1,})\]\[search\]\[value\]" COL_SEARCHABLE_REGEX = r"columns\[(?P[0-9]{1,})\]\[searchable\]" From 7eb0a6c473cb87190ccc75cadd2d6ecfc0b391c8 Mon Sep 17 00:00:00 2001 From: Aaron Kable Date: Thu, 8 Jan 2026 18:43:47 +0800 Subject: [PATCH 11/25] tests --- allianceauth/framework/tests/test_datatables.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/allianceauth/framework/tests/test_datatables.py b/allianceauth/framework/tests/test_datatables.py index cf7dee4c..3e1a64be 100644 --- a/allianceauth/framework/tests/test_datatables.py +++ b/allianceauth/framework/tests/test_datatables.py @@ -23,10 +23,6 @@ class TestView(DataTablesView): ] class TestDataTables(TestCase): - """ - Tests for get_main_character_from_user - """ - def setUp(self): self.get_params = { 'draw': '1', @@ -90,7 +86,6 @@ class TestDataTables(TestCase): def test_view_default(self): - self.client.force_login(self.user) request = self.factory.get('/fake-url/', data=self.get_params) response = TestView() From ad1c17a255467f856cb8ad321bf4f6e0f130c7d9 Mon Sep 17 00:00:00 2001 From: Aaron Kable Date: Thu, 8 Jan 2026 20:11:40 +0800 Subject: [PATCH 12/25] parmas to dict --- allianceauth/framework/datatables.py | 129 +++++++++++---------------- 1 file changed, 50 insertions(+), 79 deletions(-) diff --git a/allianceauth/framework/datatables.py b/allianceauth/framework/datatables.py index 56c1fa3d..9b6b42ba 100644 --- a/allianceauth/framework/datatables.py +++ b/allianceauth/framework/datatables.py @@ -13,81 +13,56 @@ from allianceauth.services.hooks import get_extension_logger logger = get_extension_logger(__name__) + +class nested_param_dict(dict): + def __setitem__(self, item, value): + if "." in item: + head, path = item.split(".", 1) + obj = self.setdefault(head, nested_param_dict()) + obj[path] = value + else: + super().__setitem__(item, value) + + class DataTablesView(View): model: Model = None - columns: List[tuple] = [] - - COL_SEARCH_REGEX = r"columns\[(?P[0-9]{1,})\]\[search\]\[value\]" - COL_SEARCHABLE_REGEX = r"columns\[(?P[0-9]{1,})\]\[searchable\]" - COL_SEARCHABLE_AS_REGEX_REGEX = r"columns\[(?P[0-9]{1,})\]\[search\]\[regex\]" - COL_ORDERABLE_REGEX = r"columns\[(?P[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) + columns: list[tuple] = [] def get_model_qs(self, request: HttpRequest, *args, **kwargs): 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): + def filter_qs(self, table_conf: dict): # Search filter_q = Q() - for id, c in column_conf.items(): - _c = self.columns[id][0] + for id, c in table_conf["columns"].items(): + _c = self.columns[int(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"]}) + if len(c["search"]["value"]) and len(_c): + if c["search"]["regex"]: + filter_q |= Q(**{f'{_c}__iregex': c["search"]["value"]}) else: - filter_q |= Q(**{f'{_c}__icontains': c["search"]}) - if len(search_string) > 0: - filter_q |= Q(**{f'{_c}__icontains': search_string}) + filter_q |= Q(**{f'{_c}__icontains': c["search"]["value"]}) + if len(table_conf["search"]["value"]) > 0: + filter_q |= Q(**{f'{_c}__icontains': table_conf["search"]["value"]}) 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 get_table_config(self, get: dict): + _cols = nested_param_dict() + for c, v in get.items(): + _keys = [_k for _k in c.replace("]", "").split("[")] + _cols[".".join(_keys)] = v + return _cols - 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] + def get_order(self, table_conf: dict): + order = [] + for oc, od in table_conf["order"].items(): + _c = table_conf["columns"][od["column"]] + if _c["orderable"]: + if od["column"] == 'desc': + order.append('-' + self.columns[int(od["column"])][0]) + else: + order.append(self.columns[int(od["column"])][0]) return order def render_template(self, request: HttpRequest, template: str, ctx: dict): @@ -102,30 +77,26 @@ class DataTablesView(View): ) 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) - + table_conf = self.get_table_config(request.GET) + draw = int(table_conf["draw"]) + start = int(table_conf["start"]) + length = int(table_conf["length"]) limit = start + length - column_conf = self.get_columns_config(request.GET) - # Searches - filter_q = Q() | self.filter_qs(search_string, column_conf) + filter_q = Q() | self.filter_qs(table_conf) # Build response rows items = [] - qs = self.get_model_qs(request, *args, **kwargs).filter(filter_q).order_by() - - # Apply ordering - order = self.order_str(order_col, order_dir, column_conf) - if order != "": - qs = qs.order_by(order) + qs = self.get_model_qs( + request, + *args, + **kwargs + ).filter( + filter_q + ).order_by( + *self.get_order(table_conf) + ) # build output for row in qs[start:limit]: From e44802fa735309d67a1f7b14aed538890cadd3fb Mon Sep 17 00:00:00 2001 From: Aaron Kable Date: Thu, 8 Jan 2026 20:49:50 +0800 Subject: [PATCH 13/25] fix tests --- allianceauth/framework/datatables.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/allianceauth/framework/datatables.py b/allianceauth/framework/datatables.py index 9b6b42ba..487a6cd9 100644 --- a/allianceauth/framework/datatables.py +++ b/allianceauth/framework/datatables.py @@ -34,18 +34,18 @@ class DataTablesView(View): def filter_qs(self, table_conf: dict): # Search - filter_q = Q() + filter_qs = Q() for id, c in table_conf["columns"].items(): _c = self.columns[int(id)][0] if c["searchable"] and len(_c) > 0: if len(c["search"]["value"]) and len(_c): if c["search"]["regex"]: - filter_q |= Q(**{f'{_c}__iregex': c["search"]["value"]}) + filter_qs |= Q(**{f'{_c}__iregex': c["search"]["value"]}) else: - filter_q |= Q(**{f'{_c}__icontains': c["search"]["value"]}) + filter_qs |= Q(**{f'{_c}__icontains': c["search"]["value"]}) if len(table_conf["search"]["value"]) > 0: - filter_q |= Q(**{f'{_c}__icontains': table_conf["search"]["value"]}) - return filter_q + filter_qs |= Q(**{f'{_c}__icontains': table_conf["search"]["value"]}) + return filter_qs def get_table_config(self, get: dict): _cols = nested_param_dict() @@ -58,9 +58,9 @@ class DataTablesView(View): order = [] for oc, od in table_conf["order"].items(): _c = table_conf["columns"][od["column"]] - if _c["orderable"]: - if od["column"] == 'desc': - order.append('-' + self.columns[int(od["column"])][0]) + if _c["orderable"] == "true": + if od["dir"] == "desc": + order.append("-" + self.columns[int(od["column"])][0]) else: order.append(self.columns[int(od["column"])][0]) return order @@ -83,8 +83,6 @@ class DataTablesView(View): length = int(table_conf["length"]) limit = start + length - # Searches - filter_q = Q() | self.filter_qs(table_conf) # Build response rows items = [] @@ -93,7 +91,7 @@ class DataTablesView(View): *args, **kwargs ).filter( - filter_q + self.filter_qs(table_conf) ).order_by( *self.get_order(table_conf) ) From a01598e088392a0090cc9006434f1f614c437e52 Mon Sep 17 00:00:00 2001 From: Aaron Kable Date: Thu, 8 Jan 2026 21:03:45 +0800 Subject: [PATCH 14/25] tidy up casting --- allianceauth/framework/datatables.py | 42 ++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/allianceauth/framework/datatables.py b/allianceauth/framework/datatables.py index 487a6cd9..4d562b50 100644 --- a/allianceauth/framework/datatables.py +++ b/allianceauth/framework/datatables.py @@ -15,15 +15,31 @@ logger = get_extension_logger(__name__) class nested_param_dict(dict): + """ + Helper to create infinite depth default dicts for setting from params + """ def __setitem__(self, item, value): if "." in item: head, path = item.split(".", 1) + try: + head = int(head) + except ValueError: + pass obj = self.setdefault(head, nested_param_dict()) obj[path] = value else: super().__setitem__(item, value) +def defaultdict_to_dict(d): + """ + Helper to convert default dict back to dict + """ + if isinstance(d, defaultdict): + d = {k: defaultdict_to_dict(v) for k, v in d.items()} + return d + + class DataTablesView(View): model: Model = None @@ -38,27 +54,37 @@ class DataTablesView(View): for id, c in table_conf["columns"].items(): _c = self.columns[int(id)][0] if c["searchable"] and len(_c) > 0: - if len(c["search"]["value"]) and len(_c): + _sv = str(c["search"]["value"]) + if len(_sv) > 0: if c["search"]["regex"]: - filter_qs |= Q(**{f'{_c}__iregex': c["search"]["value"]}) + filter_qs |= Q(**{f'{_c}__iregex': _sv}) else: - filter_qs |= Q(**{f'{_c}__icontains': c["search"]["value"]}) - if len(table_conf["search"]["value"]) > 0: - filter_qs |= Q(**{f'{_c}__icontains': table_conf["search"]["value"]}) + filter_qs |= Q(**{f'{_c}__icontains': _sv}) + _gsv = str(table_conf["search"]["value"]) + if len(_gsv) > 0: + filter_qs |= Q(**{f'{_c}__icontains': _gsv}) return filter_qs def get_table_config(self, get: dict): _cols = nested_param_dict() for c, v in get.items(): _keys = [_k for _k in c.replace("]", "").split("[")] - _cols[".".join(_keys)] = v - return _cols + _v = v + if v in ["true", "false"]: + _v = _v == "true" + else: + try: + _v = int(_v) + except ValueError: + pass # not an integer + _cols[".".join(_keys)] = _v + return defaultdict_to_dict(_cols) def get_order(self, table_conf: dict): order = [] for oc, od in table_conf["order"].items(): _c = table_conf["columns"][od["column"]] - if _c["orderable"] == "true": + if _c["orderable"]: if od["dir"] == "desc": order.append("-" + self.columns[int(od["column"])][0]) else: From 6c2cbb069f94c1abb182a5437da92b473f69f802 Mon Sep 17 00:00:00 2001 From: Aaron Kable Date: Fri, 9 Jan 2026 07:51:06 +0800 Subject: [PATCH 15/25] check ordering exists --- allianceauth/framework/datatables.py | 30 +++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/allianceauth/framework/datatables.py b/allianceauth/framework/datatables.py index 4d562b50..5c855658 100644 --- a/allianceauth/framework/datatables.py +++ b/allianceauth/framework/datatables.py @@ -54,12 +54,28 @@ class DataTablesView(View): for id, c in table_conf["columns"].items(): _c = self.columns[int(id)][0] if c["searchable"] and len(_c) > 0: - _sv = str(c["search"]["value"]) - if len(_sv) > 0: - if c["search"]["regex"]: - filter_qs |= Q(**{f'{_c}__iregex': _sv}) - else: - filter_qs |= Q(**{f'{_c}__icontains': _sv}) + if c["columnControl"]: + _sv = str(c["columnControl"]["search"]["value"]) + _logic = str(c["columnControl"]["search"]["logic"]) + _type = str(c["columnControl"]["search"]["type"]) + if len(_sv) > 0: + if _logic == "contains": + filter_qs |= Q(**{f'{_c}__icontains': _sv}) + elif _logic == "starts": + filter_qs |= Q(**{f'{_c}__istartswith': _sv}) + elif _logic == "ends": + filter_qs |= Q(**{f'{_c}__iendswith': _sv}) + elif _logic == "equal": + filter_qs |= Q(**{f'{_c}': _sv}) + elif _logic == "empty": + filter_qs |= Q(**{f'{_c}': ""}) + else: + _sv = str(c["search"]["value"]) + if len(_sv) > 0: + if c["search"]["regex"]: + filter_qs |= Q(**{f'{_c}__iregex': _sv}) + else: + filter_qs |= Q(**{f'{_c}__icontains': _sv}) _gsv = str(table_conf["search"]["value"]) if len(_gsv) > 0: filter_qs |= Q(**{f'{_c}__icontains': _gsv}) @@ -82,7 +98,7 @@ class DataTablesView(View): def get_order(self, table_conf: dict): order = [] - for oc, od in table_conf["order"].items(): + for oc, od in table_conf.get("order", {}).items(): _c = table_conf["columns"][od["column"]] if _c["orderable"]: if od["dir"] == "desc": From 775db62c7a8305a886b4afafc0cc99ab38d3eac4 Mon Sep 17 00:00:00 2001 From: Aaron Kable Date: Fri, 9 Jan 2026 08:55:58 +0800 Subject: [PATCH 16/25] add basic column control fitlering --- allianceauth/framework/datatables.py | 37 ++++++++++++++++--- .../framework/tests/test_datatables.py | 11 ++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/allianceauth/framework/datatables.py b/allianceauth/framework/datatables.py index 5c855658..229bc50d 100644 --- a/allianceauth/framework/datatables.py +++ b/allianceauth/framework/datatables.py @@ -53,12 +53,16 @@ class DataTablesView(View): filter_qs = Q() for id, c in table_conf["columns"].items(): _c = self.columns[int(id)][0] - if c["searchable"] and len(_c) > 0: - if c["columnControl"]: + if c.get("searchable", False) and len(_c) > 0: + if c.get("columnControl", False): _sv = str(c["columnControl"]["search"]["value"]) + """contains, equal, ends, starts, empty""" _logic = str(c["columnControl"]["search"]["logic"]) + """text, date, num""" _type = str(c["columnControl"]["search"]["type"]) - if len(_sv) > 0: + if _logic == "empty": + filter_qs |= Q(**{f'{_c}': ""}) + elif len(_sv) > 0: if _logic == "contains": filter_qs |= Q(**{f'{_c}__icontains': _sv}) elif _logic == "starts": @@ -67,8 +71,7 @@ class DataTablesView(View): filter_qs |= Q(**{f'{_c}__iendswith': _sv}) elif _logic == "equal": filter_qs |= Q(**{f'{_c}': _sv}) - elif _logic == "empty": - filter_qs |= Q(**{f'{_c}': ""}) + else: _sv = str(c["search"]["value"]) if len(_sv) > 0: @@ -81,6 +84,28 @@ class DataTablesView(View): filter_qs |= Q(**{f'{_c}__icontains': _gsv}) return filter_qs + def except_qs(self, table_conf: dict): + # Search + except_qs = Q() + for id, c in table_conf["columns"].items(): + _c = self.columns[int(id)][0] + if c.get("searchable", False) and len(_c) > 0: + if c.get("columnControl", False): + _sv = str(c["columnControl"]["search"]["value"]) + """notContains, notEqual, notEmpty""" + _logic = str(c["columnControl"]["search"]["logic"]) + """text, date, num""" + _type = str(c["columnControl"]["search"]["type"]) + if _logic == "notEmpty": + except_qs |= Q(**{f'{_c}': ""}) + elif len(_sv) > 0: + if _logic == "notContains": + except_qs |= Q(**{f'{_c}__icontains': _sv}) + elif _logic == "notEqual": + except_qs |= Q(**{f'{_c}': _sv}) + + return except_qs + def get_table_config(self, get: dict): _cols = nested_param_dict() for c, v in get.items(): @@ -134,6 +159,8 @@ class DataTablesView(View): **kwargs ).filter( self.filter_qs(table_conf) + ).exclude( + self.except_qs(table_conf) ).order_by( *self.get_order(table_conf) ) diff --git a/allianceauth/framework/tests/test_datatables.py b/allianceauth/framework/tests/test_datatables.py index 3e1a64be..ce641a43 100644 --- a/allianceauth/framework/tests/test_datatables.py +++ b/allianceauth/framework/tests/test_datatables.py @@ -104,6 +104,17 @@ class TestDataTables(TestCase): self.assertEqual(data[0][0], "1020") self.assertEqual(data[9][0], "1011") + def test_view_no_sort(self): + self.get_params.pop("order[0][column]") + self.get_params.pop("order[0][dir]") + 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_non_sortable_sort(self): self.get_params["order[0][dir]"] = "desc" self.get_params["order[0][column]"] = "0" From 7e338d09a2e070da56976b78652d56d170e47052 Mon Sep 17 00:00:00 2001 From: Aaron Kable Date: Fri, 9 Jan 2026 09:25:28 +0800 Subject: [PATCH 17/25] Add Colum Control --- .../custom/framework/datatables.md | 66 +++++++------------ 1 file changed, 25 insertions(+), 41 deletions(-) diff --git a/docs/development/custom/framework/datatables.md b/docs/development/custom/framework/datatables.md index 1f4b1765..818f43e5 100644 --- a/docs/development/custom/framework/datatables.md +++ b/docs/development/custom/framework/datatables.md @@ -52,58 +52,42 @@ Given the `EveCharacter` Model as our model of choice we would define our stubs {% translate "Alliance" %} - - - - - - - {% translate "Name" %} - {% translate "Corporation" %} - {% translate "Alliance" %} - - - - - {% endblock content %} {% block extra_css %} - {% include 'bundles/datatables-css-bs5.html' %} + + {% endblock %} {% block extra_javascript %} - {% include 'bundles/datatables-js-bs5.html' %} + + +