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 %}{% 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 @@ + 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 ""}", (
{{ column }}
+ {{ slot }} +