first pass framework for datatables erver rendering

This commit is contained in:
Aaron Kable
2026-01-07 20:00:52 +08:00
parent 99c65d2a5d
commit 940f6b14b7
2 changed files with 298 additions and 0 deletions

View File

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

View File

@@ -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 }} <span class="text-small">({{row.character_ownership.user.username}})</span>
```
### 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 %}
<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: '/appname/tables/data_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
# 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.