Filters (backend and frontend) #74

Open
lukas wants to merge 2 commits from filters into main
11 changed files with 370 additions and 29 deletions

View File

@ -1,8 +1,9 @@
from random import choices
from string import ascii_lowercase
from typing import Any
from typing import Any, Callable
from django.template.loader import render_to_string
from django.urls import NoReverseMatch, reverse
from django.utils.safestring import mark_safe
@ -31,16 +32,68 @@ HTMLAttribute = tuple[str, str]
HTMLTag = str
def A(attributes: list[HTMLAttribute], children: list[HTMLTag] | HTMLTag) -> HTMLTag:
def Component(
attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [],
template: str = "",
tag_name: str = "",
) -> HTMLTag:
if not tag_name and not template:
raise ValueError("One of template or tag_name is required.")
if isinstance(children, str):
children = [children]
childrenBlob = "\n".join(children)
attributesList = [f'{name} = "{value}"' for name, value in attributes]
attributesBlob = " ".join(attributesList)
tag: str = f"<a {attributesBlob}>{childrenBlob}</a>"
tag: str = ""
if tag_name != "":
tag = f"<a {attributesBlob}>{childrenBlob}</a>"
elif template != "":
tag = render_to_string(
template,
{name: value for name, value in attributes} | {"slot": "\n".join(children)},
)
return mark_safe(tag)
def A(
attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [],
url: str | Callable[..., Any] = "",
):
"""
Returns the HTML tag "a".
"url" can either be:
- URL (string)
- path name passed to reverse() (string)
- function
"""
additional_attributes = []
if url:
if type(url) is str:
try:
url_result = reverse(url)
except NoReverseMatch:
url_result = url
elif callable(url):
url_result = url()
else:
raise TypeError("'url' is neither str nor function.")
additional_attributes = [("href", url_result)]
return Component(
tag_name="a", attributes=attributes + additional_attributes, children=children
)
def Button(
attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [],
):
return Component(
template="cotton/button.html", attributes=attributes, children=children
)
def safe_division(numerator: int | float, denominator: int | float) -> int | float:
"""
Divides without triggering division by zero exception.

View File

@ -1,5 +1,5 @@
/*
! tailwindcss v3.4.10 | MIT License | https://tailwindcss.com
! tailwindcss v3.4.7 | MIT License | https://tailwindcss.com
*/
/*
@ -1816,11 +1816,6 @@ input:checked + .toggle-bg {
border-color: rgb(220 215 254 / var(--tw-border-opacity));
}
.\!bg-gray-50 {
--tw-bg-opacity: 1 !important;
background-color: rgb(249 250 251 / var(--tw-bg-opacity)) !important;
}
.bg-blue-100 {
--tw-bg-opacity: 1;
background-color: rgb(225 239 254 / var(--tw-bg-opacity));
@ -1966,6 +1961,10 @@ input:checked + .toggle-bg {
text-align: center;
}
.text-right {
text-align: right;
}
.align-top {
vertical-align: top;
}
@ -2722,11 +2721,6 @@ textarea:disabled:is(.dark *) {
border-color: transparent;
}
.dark\:\!bg-gray-700:is(.dark *) {
--tw-bg-opacity: 1 !important;
background-color: rgb(55 65 81 / var(--tw-bg-opacity)) !important;
}
.dark\:bg-blue-200:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(195 221 253 / var(--tw-bg-opacity));
@ -3075,6 +3069,10 @@ textarea:disabled:is(.dark *) {
--tw-space-x-reverse: 1;
}
.rtl\:text-left:where([dir="rtl"], [dir="rtl"] *) {
text-align: left;
}
.rtl\:text-right:where([dir="rtl"], [dir="rtl"] *) {
text-align: right;
}

View File

@ -1,4 +1,5 @@
<button type="button"
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 mt-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
{{ text }}
{{ slot }}
</button>

View File

@ -2,6 +2,11 @@
<div class="shadow-md sm:rounded-lg" hx-boost="false">
<div class="relative overflow-x-auto sm:rounded-lg">
<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">
{% if header_action %}
<c-table-header>
{{ header_action }}
</c-table-header>
{% endif %}
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
<tr>
{% for column in columns %}<th scope="col" class="px-6 py-3">{{ column }}</th>{% endfor %}

View File

@ -0,0 +1,3 @@
<caption class="p-2 text-lg font-semibold rtl:text-left text-right text-gray-900 bg-white dark:text-white dark:bg-gray-900">
{{ slot }}
</caption>

View File

@ -7,6 +7,7 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string
from django.urls import reverse
from common.utils import A, Button
from games.forms import DeviceForm
from games.models import Device
from games.views.general import dateformat
@ -35,6 +36,7 @@ def list_devices(request: HttpRequest) -> HttpResponse:
else None
),
"data": {
"header_action": A([], Button([], "Add device"), url="add_device"),
"columns": [
"Name",
"Type",

View File

@ -7,7 +7,7 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string
from django.urls import reverse
from common.utils import A, truncate_with_popover
from common.utils import A, Button, truncate_with_popover
from games.forms import EditionForm
from games.models import Edition, Game
from games.views.general import dateformat
@ -36,6 +36,7 @@ def list_editions(request: HttpRequest) -> HttpResponse:
else None
),
"data": {
"header_action": A([], Button([], "Add edition"), url="add_edition"),
"columns": [
"Game",
"Name",

View File

@ -9,7 +9,7 @@ from django.template.loader import render_to_string
from django.urls import reverse
from common.time import format_duration
from common.utils import A, safe_division, truncate_with_popover
from common.utils import A, Button, safe_division, truncate_with_popover
from games.forms import GameForm
from games.models import Edition, Game, Purchase, Session
from games.views.general import (
@ -45,6 +45,7 @@ def list_games(request: HttpRequest) -> HttpResponse:
else None
),
"data": {
"header_action": A([], Button([], "Add game"), url="add_game"),
"columns": [
"Name",
"Sort Name",

View File

@ -7,6 +7,7 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string
from django.urls import reverse
from common.utils import A, Button
from games.forms import PlatformForm
from games.models import Platform
from games.views.general import dateformat, use_custom_redirect
@ -35,6 +36,7 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
else None
),
"data": {
"header_action": A([], Button([], "Add platform"), url="add_platform"),
"columns": [
"Name",
"Group",

View File

@ -13,7 +13,7 @@ from django.template.loader import render_to_string
from django.urls import reverse
from django.utils import timezone
from common.utils import A, truncate_with_popover
from common.utils import A, Button, truncate_with_popover
from games.forms import PurchaseForm
from games.models import Edition, Purchase
from games.views.general import dateformat, use_custom_redirect
@ -42,6 +42,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
else None
),
"data": {
"header_action": A([], Button([], "Add purchase"), url="add_purchase"),
"columns": [
"Name",
"Type",

View File

@ -1,15 +1,22 @@
from typing import Any
import operator
from functools import reduce
from json import dumps as json_dumps
from json import loads as json_loads
from typing import Any, NotRequired, TypeAlias, TypedDict, TypeGuard
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models.query import QuerySet
from django.db.models.query_utils import Q
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils import timezone
from typing_extensions import TypeGuard
from common.time import format_duration
from common.utils import A, truncate_with_popover
from common.utils import A, Button, truncate_with_popover
from games.forms import SessionForm
from games.models import Purchase, Session
from games.views.general import (
@ -22,12 +29,283 @@ from games.views.general import (
)
class Filter(TypedDict):
filter_id: str
filter_display: str
filter_string: str
def is_filter(obj: dict[Any, Any]) -> TypeGuard[Filter]:
return (
isinstance(obj, dict)
and "filter_id" in obj
and isinstance(obj["filter_id"], str)
and "filter_display" in obj
and isinstance(obj["filter_display"], str)
and "filter_string" in obj
and isinstance(obj["filter_string"], str)
)
FilterList: TypeAlias = list[Filter]
def is_filterlist(obj: list[Any]) -> TypeGuard[FilterList]:
return isinstance(obj, list) and all([is_filter(item) for item in obj])
ModelFilterSet: TypeAlias = list[dict[str, FilterList]]
class FieldFilter(TypedDict):
filtered_field: str
filtered_value: str
negated: NotRequired[bool]
filter: Filter
def is_fieldfilter(obj: dict) -> TypeGuard[FieldFilter]:
return (
isinstance(obj, dict)
and "filtered_field" in obj
and isinstance(obj["filtered_field"], str)
and "filtered_value" in obj
and isinstance(obj["filtered_value"], str)
and "filter" in obj
and is_filter(obj["filter"])
)
FilterSet: TypeAlias = list[FieldFilter]
def is_filterset(obj: list) -> TypeGuard[FilterSet]:
return isinstance(obj, list) and all([is_fieldfilter(item) for item in obj])
iexact_filter: Filter = {
"filter_id": "IEXACT",
"filter_display": "Equals (case-insensitive)",
"filter_string": "__iexact",
}
exact_filter: Filter = {
"filter_id": "EXACT",
"filter_display": "Equals (case-sensitive)",
"filter_string": "__exact",
}
isnull_filter: Filter = {
"filter_id": "ISNULL",
"filter_display": "Is null",
"filter_string": "__isnull",
}
contains_filter: Filter = {
"filter_id": "CONTAINS",
"filter_display": "Contains",
"filter_string": "__contains",
}
startswith_filter: Filter = {
"filter_id": "STARTSWITH",
"filter_display": "Starts with",
"filter_string": "__startswith",
}
endswith_filter: Filter = {
"filter_id": "ENDSWITH",
"filter_display": "Ends with",
"filter_string": "__endswith",
}
gt_filter: Filter = {
"filter_id": "GT",
"filter_display": "Greater than",
"filter_string": "__gt",
}
lt_filter: Filter = {
"filter_id": "LT",
"filter_display": "Lesser than",
"filter_string": "__lt",
}
year_gt_filter: Filter = {
"filter_id": "YEARGT",
"filter_display": "Greater than",
"filter_string": "__year__gt",
}
year_lt_filter: Filter = {
"filter_id": "YEARLT",
"filter_display": "Lesser than",
"filter_string": "__year__lt",
}
year_exact_filter: Filter = {
"filter_id": "YEAREXACT",
"filter_display": "Equals (case-sensitive)",
"filter_string": "__year__exact",
}
defined_filters = [
iexact_filter,
exact_filter,
isnull_filter,
contains_filter,
startswith_filter,
endswith_filter,
gt_filter,
lt_filter,
year_gt_filter,
year_lt_filter,
year_exact_filter,
]
defined_filters_list = {list["filter_id"]: list for list in defined_filters}
char_filter: FilterList = [
iexact_filter,
isnull_filter,
contains_filter,
startswith_filter,
endswith_filter,
]
text_filter: FilterList = [
isnull_filter,
contains_filter,
]
num_filter: FilterList = [exact_filter, gt_filter, lt_filter]
date_filter: FilterList = [
year_exact_filter,
isnull_filter,
year_gt_filter,
year_lt_filter,
]
conditions = ["and", "or"]
session_filters: ModelFilterSet = [
{"name": char_filter},
{"timestamp_start": date_filter},
{"timestamp_end": date_filter},
{"duration_manual": num_filter},
{"duration_calculated": num_filter},
{"note": text_filter},
{"device": char_filter},
{"created_at": date_filter},
{"modified_at": date_filter},
]
name_contains_age: FieldFilter = {
"filtered_field": "name",
"filtered_value": "age",
"filter": contains_filter,
}
simple_example_filter: FilterSet = [name_contains_age]
timestamp_start_year_2024: FieldFilter = {
"filtered_field": "timestamp_start",
"filtered_value": "2024",
"filter": year_exact_filter,
}
physical_only: FieldFilter = {
"filtered_field": "purchase__ownership_type",
"filtered_value": "ph",
"filter": exact_filter,
}
def negate_filter(filter: FieldFilter) -> FieldFilter:
return {**filter, "negated": True}
without_physical: FieldFilter = negate_filter(physical_only)
combined_example_filter: FilterSet = [name_contains_age, timestamp_start_year_2024]
combined_with_negated_example_filter = [timestamp_start_year_2024, without_physical]
def string_to_dict(s: str) -> dict[str, str]:
key, value = s.split("=")
return {key: value}
def create_django_filter_dict(
filter: Filter, field: str, filtered_value: str
) -> dict[str, str]:
"""
Creates a dict that can be used with the Django
filter function by unpacking it:
Model.objects.filter(**return_value)
"""
if not is_filter(filter):
raise ValueError("filter is not of type Filter")
return {f"{field}{filter["filter_string"]}": filtered_value}
def join_filter_with_condition(filters: FilterSet, condition: str):
if not is_filterset(filters):
raise ValueError("filters is not FilterSet")
conditions = {"AND": operator.and_, "OR": operator.or_, "XOR": operator.xor}
condition = condition.upper()
if condition not in conditions:
raise ValueError(f"Condition '{condition}' not one of '{conditions.keys()}'.")
q_objects: list[Q] = []
for filter_item in filters:
q = Q(
**create_django_filter_dict(
filter_item["filter"],
filter_item["filtered_field"],
filter_item["filtered_value"],
)
)
if filter_item.get("negated", False):
q = ~q
q_objects.append(q)
return reduce(conditions[condition], q_objects)
def apply_filters(
filters: FilterSet,
queryset: QuerySet[Any],
) -> QuerySet[Any] | None:
if len(filters) == 0:
return queryset
if type(filters) is not list:
raise ValueError("filters argument not of type list")
# TODO: modify FilterSet so it includes the condition to use
# so we can remove the hard-coding of "AND" here
return queryset.filter(join_filter_with_condition(filters, "AND"))
def filters_to_string(filters: FilterSet) -> str:
constructed_filters: list[dict[str, str | bool]] = []
for filter in filters:
constructed_filters.append(
{
"id": filter["filter"]["filter_id"],
"field": filter["filtered_field"],
"value": filter["filtered_value"],
"negated": filter.get("negated", False),
}
)
return json_dumps(constructed_filters)
def string_to_filters(filter_string: str) -> FilterSet:
obj = json_loads(filter_string)
filters = [
{
"filter": defined_filters_list[item["id"]],
"filtered_field": item["field"],
"filtered_value": item["value"],
"negated": item.get("negated", False),
}
for item in obj
]
if not is_filterset(filters):
raise ValueError("filters is not of type FilterSet")
return filters
@login_required
def list_sessions(request: HttpRequest) -> HttpResponse:
context: dict[Any, Any] = {}
page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10)
filters = request.GET.get("filters", "")
sessions = Session.objects.order_by("-timestamp_start")
if filters != "":
filter_obj = string_to_filters(filters)
sessions = apply_filters(filter_obj, queryset=sessions)
page_obj = None
if int(limit) != 0:
paginator = Paginator(sessions, limit)
@ -45,6 +323,7 @@ def list_sessions(request: HttpRequest) -> HttpResponse:
else None
),
"data": {
"header_action": A([], Button([], "Add session"), url="add_session"),
"columns": [
"Name",
"Date",
@ -57,16 +336,11 @@ def list_sessions(request: HttpRequest) -> HttpResponse:
"rows": [
[
A(
[
(
"href",
reverse(
children=truncate_with_popover(session.purchase.edition.name),
url=reverse(
"view_game",
args=[session.purchase.edition.game.pk],
),
)
],
truncate_with_popover(session.purchase.edition.name),
),
f"{session.timestamp_start.strftime(datetimeformat)}{f"{session.timestamp_end.strftime(timeformat)}" if session.timestamp_end else ""}",
(