Compare commits

..

1 Commits

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

See merge request allianceauth/allianceauth!1785
2026-01-08 13:05:51 +00:00
3 changed files with 52 additions and 193 deletions

View File

@@ -43,7 +43,7 @@ def defaultdict_to_dict(d):
class DataTablesView(View): class DataTablesView(View):
model: Model = None model: Model = None
columns: List[tuple] = [] columns: list[tuple] = []
def get_model_qs(self, request: HttpRequest, *args, **kwargs): def get_model_qs(self, request: HttpRequest, *args, **kwargs):
return self.model.objects return self.model.objects
@@ -53,59 +53,18 @@ class DataTablesView(View):
filter_qs = Q() filter_qs = Q()
for id, c in table_conf["columns"].items(): for id, c in table_conf["columns"].items():
_c = self.columns[int(id)][0] _c = self.columns[int(id)][0]
if c.get("searchable", False) and len(_c) > 0: if c["searchable"] and len(_c) > 0:
if c.get("columnControl", False): _sv = str(c["search"]["value"])
_sv = str(c["columnControl"]["search"]["value"]) if len(_sv) > 0:
"""contains, equal, ends, starts, empty""" if c["search"]["regex"]:
_logic = str(c["columnControl"]["search"]["logic"]) filter_qs |= Q(**{f'{_c}__iregex': _sv})
"""text, date, num""" else:
_type = str(c["columnControl"]["search"]["type"]) filter_qs |= Q(**{f'{_c}__icontains': _sv})
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":
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})
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"]) _gsv = str(table_conf["search"]["value"])
if len(_gsv) > 0: if len(_gsv) > 0:
filter_qs |= Q(**{f'{_c}__icontains': _gsv}) filter_qs |= Q(**{f'{_c}__icontains': _gsv})
return filter_qs 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): def get_table_config(self, get: dict):
_cols = nested_param_dict() _cols = nested_param_dict()
for c, v in get.items(): for c, v in get.items():
@@ -123,7 +82,7 @@ class DataTablesView(View):
def get_order(self, table_conf: dict): def get_order(self, table_conf: dict):
order = [] order = []
for oc, od in table_conf.get("order", {}).items(): for oc, od in table_conf["order"].items():
_c = table_conf["columns"][od["column"]] _c = table_conf["columns"][od["column"]]
if _c["orderable"]: if _c["orderable"]:
if od["dir"] == "desc": if od["dir"] == "desc":
@@ -159,8 +118,6 @@ class DataTablesView(View):
**kwargs **kwargs
).filter( ).filter(
self.filter_qs(table_conf) self.filter_qs(table_conf)
).exclude(
self.except_qs(table_conf)
).order_by( ).order_by(
*self.get_order(table_conf) *self.get_order(table_conf)
) )

View File

@@ -74,18 +74,10 @@ class TestDataTables(TestCase):
cls.user.save() cls.user.save()
EveCharacter.objects.all().delete() EveCharacter.objects.all().delete()
for i in range(1,16): for i in range(1,21):
EveCharacter.objects.create( EveCharacter.objects.create(
character_id=1000+i, character_id=1000+i,
character_name=f"{1000+i} - Test Character - {1000+i}", character_name=f"{1000+i} - Test Character",
corporation_id=2000+i,
corporation_name=f"{2000+i} - Test Corporation",
)
for i in range(16,21):
EveCharacter.objects.create(
character_id=1000+i,
character_name=f"{1000+i} - Test Character - {1000+i}",
corporation_id=2000+i, corporation_id=2000+i,
corporation_name=f"{2000+i} - Test Corporation", corporation_name=f"{2000+i} - Test Corporation",
alliance_id=3000+i, alliance_id=3000+i,
@@ -112,17 +104,6 @@ class TestDataTables(TestCase):
self.assertEqual(data[0][0], "1020") self.assertEqual(data[0][0], "1020")
self.assertEqual(data[9][0], "1011") 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): def test_view_non_sortable_sort(self):
self.get_params["order[0][dir]"] = "desc" self.get_params["order[0][dir]"] = "desc"
self.get_params["order[0][column]"] = "0" self.get_params["order[0][column]"] = "0"
@@ -166,104 +147,9 @@ class TestDataTables(TestCase):
def test_view_col_1_search_empty(self): def test_view_col_1_search_empty(self):
self.get_params["columns[1][search][value]"] = "zzz" self.get_params["columns[1][search][value]"] = "zzz"
self.client.force_login(self.user) self.client.force_login(self.user)
request = self.factory.get('/fake-url/', data=self.get_params) request = self.factory.get('/fake-url/', data=self.get_params)
response = TestView() response = TestView()
response.setup(request) response.setup(request)
data = json.loads(response.get(request).content)["data"] data = json.loads(response.get(request).content)["data"]
self.assertEqual(len(data), 0) self.assertEqual(len(data), 0)
def test_view_cc_3_search_empty(self):
self.get_params["columns[3][columnControl][search][value]"] = ""
self.get_params["columns[3][columnControl][search][logic]"] = "empty"
self.get_params["columns[3][columnControl][search][type]"] = "text"
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(len(data), 15)
def test_view_cc_3_search_not_empty(self):
self.get_params["columns[3][columnControl][search][value]"] = ""
self.get_params["columns[3][columnControl][search][logic]"] = "notEmpty"
self.get_params["columns[3][columnControl][search][type]"] = "text"
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), 5)
def test_view_cc_1_search_ends_with(self):
self.get_params["columns[1][columnControl][search][value]"] = "9"
self.get_params["columns[1][columnControl][search][logic]"] = "ends"
self.get_params["columns[1][columnControl][search][type]"] = "text"
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), 2)
def test_view_cc_1_search_starts_with(self):
self.get_params["columns[1][columnControl][search][value]"] = "1009"
self.get_params["columns[1][columnControl][search][logic]"] = "starts"
self.get_params["columns[1][columnControl][search][type]"] = "text"
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)
def test_view_cc_1_search_not_contains(self):
self.get_params["columns[1][columnControl][search][value]"] = "100"
self.get_params["columns[1][columnControl][search][logic]"] = "notContains"
self.get_params["columns[1][columnControl][search][type]"] = "text"
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(len(data), 11)
def test_view_cc_1_search_contains(self):
self.get_params["columns[1][columnControl][search][value]"] = "100"
self.get_params["columns[1][columnControl][search][logic]"] = "contains"
self.get_params["columns[1][columnControl][search][type]"] = "text"
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(len(data), 9)
def test_view_cc_1_search_equal(self):
self.get_params["columns[1][columnControl][search][value]"] = "1001 - Test Character - 1001"
self.get_params["columns[1][columnControl][search][logic]"] = "equal"
self.get_params["columns[1][columnControl][search][type]"] = "text"
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(len(data), 1)
def test_view_cc_1_search_not_equal(self):
self.get_params["columns[1][columnControl][search][value]"] = "1001 - Test Character - 1001"
self.get_params["columns[1][columnControl][search][logic]"] = "notEqual"
self.get_params["columns[1][columnControl][search][type]"] = "text"
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(len(data), 19)

View File

@@ -52,42 +52,58 @@ Given the `EveCharacter` Model as our model of choice we would define our stubs
<th>{% translate "Alliance" %}</th> <th>{% translate "Alliance" %}</th>
</tr> </tr>
</thead> </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> </table>
{% endblock content %} {% endblock content %}
{% block extra_css %} {% block extra_css %}
<link rel="stylesheet" href="https://cdn.datatables.net/2.3.6/css/dataTables.bootstrap5.css" /> {% include 'bundles/datatables-css-bs5.html' %}
<link href="https://cdn.datatables.net/columncontrol/1.2.0/css/columnControl.bootstrap5.min.css" rel="stylesheet">
{% endblock %} {% endblock %}
{% block extra_javascript %} {% block extra_javascript %}
<script src="https://cdn.datatables.net/2.3.6/js/dataTables.js"></script> {% include 'bundles/datatables-js-bs5.html' %}
<script src="https://cdn.datatables.net/2.3.6/js/dataTables.bootstrap5.js"></script>
<script src="https://cdn.datatables.net/columncontrol/1.2.0/js/dataTables.columnControl.min.js"></script>
<script> <script>
$(document).ready(function() { $(document).ready(function() {
$('#table').DataTable({ $('#table').DataTable({
serverSide: true, serverSide: true,
ajax: '{% url "appname:table" %}', ajax: '{% url "appname:table" %}',
columnDefs: [ // This is for the column searching
{ initComplete: function () {
targets: [0], this.api()
columnControl: [], .columns()
sortable: false, .every(function () {
searchable: false 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', () => {
targets: [1,2,3], if (column.search() !== this.value) {
columnControl: [ column.search(input.value).draw();
{ }
target: 0, });
content: [] }
}, });
{ },
target: 1, columnDefs: [
content: ['search'] { "searchable": false, "targets": [0] },
} { "sortable": false, "targets": [0] },
],
}
], ],
order: [ order: [
[1, "asc"] [1, "asc"]