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 random import choices
from string import ascii_lowercase from string import ascii_lowercase
from typing import Any from typing import Any, Callable
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.urls import NoReverseMatch, reverse
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
@ -31,16 +32,68 @@ HTMLAttribute = tuple[str, str]
HTMLTag = 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): if isinstance(children, str):
children = [children] children = [children]
childrenBlob = "\n".join(children) childrenBlob = "\n".join(children)
attributesList = [f'{name} = "{value}"' for name, value in attributes] attributesList = [f'{name} = "{value}"' for name, value in attributes]
attributesBlob = " ".join(attributesList) 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) 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: def safe_division(numerator: int | float, denominator: int | float) -> int | float:
""" """
Divides without triggering division by zero exception. 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)); 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 { .bg-blue-100 {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(225 239 254 / var(--tw-bg-opacity)); background-color: rgb(225 239 254 / var(--tw-bg-opacity));
@ -1966,6 +1961,10 @@ input:checked + .toggle-bg {
text-align: center; text-align: center;
} }
.text-right {
text-align: right;
}
.align-top { .align-top {
vertical-align: top; vertical-align: top;
} }
@ -2722,11 +2721,6 @@ textarea:disabled:is(.dark *) {
border-color: transparent; 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 *) { .dark\:bg-blue-200:is(.dark *) {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(195 221 253 / var(--tw-bg-opacity)); background-color: rgb(195 221 253 / var(--tw-bg-opacity));
@ -3075,6 +3069,10 @@ textarea:disabled:is(.dark *) {
--tw-space-x-reverse: 1; --tw-space-x-reverse: 1;
} }
.rtl\:text-left:where([dir="rtl"], [dir="rtl"] *) {
text-align: left;
}
.rtl\:text-right:where([dir="rtl"], [dir="rtl"] *) { .rtl\:text-right:where([dir="rtl"], [dir="rtl"] *) {
text-align: right; text-align: right;
} }

View File

@ -1,4 +1,5 @@
<button type="button" <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"> 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 }} {{ text }}
{{ slot }}
</button> </button>

View File

@ -2,6 +2,11 @@
<div class="shadow-md sm:rounded-lg" hx-boost="false"> <div class="shadow-md sm:rounded-lg" hx-boost="false">
<div class="relative overflow-x-auto sm:rounded-lg"> <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"> <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"> <thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
<tr> <tr>
{% for column in columns %}<th scope="col" class="px-6 py-3">{{ column }}</th>{% endfor %} {% 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.template.loader import render_to_string
from django.urls import reverse from django.urls import reverse
from common.utils import A, Button
from games.forms import DeviceForm from games.forms import DeviceForm
from games.models import Device from games.models import Device
from games.views.general import dateformat from games.views.general import dateformat
@ -35,6 +36,7 @@ def list_devices(request: HttpRequest) -> HttpResponse:
else None else None
), ),
"data": { "data": {
"header_action": A([], Button([], "Add device"), url="add_device"),
"columns": [ "columns": [
"Name", "Name",
"Type", "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.template.loader import render_to_string
from django.urls import reverse 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.forms import EditionForm
from games.models import Edition, Game from games.models import Edition, Game
from games.views.general import dateformat from games.views.general import dateformat
@ -36,6 +36,7 @@ def list_editions(request: HttpRequest) -> HttpResponse:
else None else None
), ),
"data": { "data": {
"header_action": A([], Button([], "Add edition"), url="add_edition"),
"columns": [ "columns": [
"Game", "Game",
"Name", "Name",

View File

@ -9,7 +9,7 @@ from django.template.loader import render_to_string
from django.urls import reverse from django.urls import reverse
from common.time import format_duration 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.forms import GameForm
from games.models import Edition, Game, Purchase, Session from games.models import Edition, Game, Purchase, Session
from games.views.general import ( from games.views.general import (
@ -45,6 +45,7 @@ def list_games(request: HttpRequest) -> HttpResponse:
else None else None
), ),
"data": { "data": {
"header_action": A([], Button([], "Add game"), url="add_game"),
"columns": [ "columns": [
"Name", "Name",
"Sort 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.template.loader import render_to_string
from django.urls import reverse from django.urls import reverse
from common.utils import A, Button
from games.forms import PlatformForm from games.forms import PlatformForm
from games.models import Platform from games.models import Platform
from games.views.general import dateformat, use_custom_redirect from games.views.general import dateformat, use_custom_redirect
@ -35,6 +36,7 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
else None else None
), ),
"data": { "data": {
"header_action": A([], Button([], "Add platform"), url="add_platform"),
"columns": [ "columns": [
"Name", "Name",
"Group", "Group",

View File

@ -13,7 +13,7 @@ from django.template.loader import render_to_string
from django.urls import reverse from django.urls import reverse
from django.utils import timezone 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.forms import PurchaseForm
from games.models import Edition, Purchase from games.models import Edition, Purchase
from games.views.general import dateformat, use_custom_redirect from games.views.general import dateformat, use_custom_redirect
@ -42,6 +42,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
else None else None
), ),
"data": { "data": {
"header_action": A([], Button([], "Add purchase"), url="add_purchase"),
"columns": [ "columns": [
"Name", "Name",
"Type", "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.contrib.auth.decorators import login_required
from django.core.paginator import Paginator 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.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from typing_extensions import TypeGuard
from common.time import format_duration 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.forms import SessionForm
from games.models import Purchase, Session from games.models import Purchase, Session
from games.views.general import ( 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 @login_required
def list_sessions(request: HttpRequest) -> HttpResponse: def list_sessions(request: HttpRequest) -> HttpResponse:
context: dict[Any, Any] = {} context: dict[Any, Any] = {}
page_number = request.GET.get("page", 1) page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10) limit = request.GET.get("limit", 10)
filters = request.GET.get("filters", "")
sessions = Session.objects.order_by("-timestamp_start") 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 page_obj = None
if int(limit) != 0: if int(limit) != 0:
paginator = Paginator(sessions, limit) paginator = Paginator(sessions, limit)
@ -45,6 +323,7 @@ def list_sessions(request: HttpRequest) -> HttpResponse:
else None else None
), ),
"data": { "data": {
"header_action": A([], Button([], "Add session"), url="add_session"),
"columns": [ "columns": [
"Name", "Name",
"Date", "Date",
@ -57,16 +336,11 @@ def list_sessions(request: HttpRequest) -> HttpResponse:
"rows": [ "rows": [
[ [
A( A(
[ children=truncate_with_popover(session.purchase.edition.name),
( url=reverse(
"href", "view_game",
reverse( args=[session.purchase.edition.game.pk],
"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 ""}", f"{session.timestamp_start.strftime(datetimeformat)}{f"{session.timestamp_end.strftime(timeformat)}" if session.timestamp_end else ""}",
( (