diff --git a/common/utils.py b/common/utils.py
index 264c9ca..ea2ea39 100644
--- a/common/utils.py
+++ b/common/utils.py
@@ -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"{childrenBlob}"
+ tag: str = ""
+ if tag_name != "":
+ tag = f"{childrenBlob}"
+ 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.
diff --git a/games/static/base.css b/games/static/base.css
index 7679a6d..ccaff9f 100644
--- a/games/static/base.css
+++ b/games/static/base.css
@@ -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;
}
diff --git a/games/templates/cotton/button.html b/games/templates/cotton/button.html
index 83f0695..3cc1f1f 100644
--- a/games/templates/cotton/button.html
+++ b/games/templates/cotton/button.html
@@ -1,4 +1,5 @@
diff --git a/games/templates/cotton/simple_table.html b/games/templates/cotton/simple_table.html
index 62c413f..8f08b99 100644
--- a/games/templates/cotton/simple_table.html
+++ b/games/templates/cotton/simple_table.html
@@ -2,6 +2,11 @@
+ {% if header_action %}
+
+ {{ header_action }}
+
+ {% endif %}
{% for column in columns %}{{ column }} | {% endfor %}
diff --git a/games/templates/cotton/table_header.html b/games/templates/cotton/table_header.html
new file mode 100644
index 0000000..2fa28cc
--- /dev/null
+++ b/games/templates/cotton/table_header.html
@@ -0,0 +1,3 @@
+
+ {{ slot }}
+
diff --git a/games/views/device.py b/games/views/device.py
index 7842a40..3fc1a2b 100644
--- a/games/views/device.py
+++ b/games/views/device.py
@@ -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",
diff --git a/games/views/edition.py b/games/views/edition.py
index f569390..a2568cf 100644
--- a/games/views/edition.py
+++ b/games/views/edition.py
@@ -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",
diff --git a/games/views/game.py b/games/views/game.py
index 51132e7..db12442 100644
--- a/games/views/game.py
+++ b/games/views/game.py
@@ -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",
diff --git a/games/views/platform.py b/games/views/platform.py
index 49995b1..3906510 100644
--- a/games/views/platform.py
+++ b/games/views/platform.py
@@ -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",
diff --git a/games/views/purchase.py b/games/views/purchase.py
index 87b4f77..9680948 100644
--- a/games/views/purchase.py
+++ b/games/views/purchase.py
@@ -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",
diff --git a/games/views/session.py b/games/views/session.py
index 9022a32..b859580 100644
--- a/games/views/session.py
+++ b/games/views/session.py
@@ -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(
- "view_game",
- args=[session.purchase.edition.game.pk],
- ),
- )
- ],
- truncate_with_popover(session.purchase.edition.name),
+ children=truncate_with_popover(session.purchase.edition.name),
+ url=reverse(
+ "view_game",
+ args=[session.purchase.edition.game.pk],
+ ),
),
f"{session.timestamp_start.strftime(datetimeformat)}{f" — {session.timestamp_end.strftime(timeformat)}" if session.timestamp_end else ""}",
(