diff --git a/common/components.py b/common/components.py index 9d7e276..bc1474b 100644 --- a/common/components.py +++ b/common/components.py @@ -125,14 +125,33 @@ def Div( return Component(tag_name="div", attributes=attributes, children=children) -def Input( - type: str = "text", +def Label( attributes: list[HTMLAttribute] = [], children: list[HTMLTag] | HTMLTag = [], ): - return Component( - tag_name="input", attributes=attributes + [("type", type)], children=children + return Component(tag_name="label", attributes=attributes, children=children) + + +def Input( + type: str = "text", + label: str = "", + id: str = "", + attributes: list[HTMLAttribute] = [], + children: list[HTMLTag] | HTMLTag = [], +): + input_component = Component( + tag_name="input", + attributes=attributes + [("type", type), ("id", id)], + children=children, ) + if label != "": + if id == "": + raise ValueError("Label is set but element ID is missing.") + return Label( + attributes=[("for", id)], children=[label, input_component, *children] + ) + else: + return input_component def Form( @@ -148,6 +167,74 @@ def Form( ) +def Fieldset( + label: str = "", + attributes: list[HTMLAttribute] = [], + children: list[HTMLTag] | HTMLTag = [], +): + if label != "": + children = [Label(children=[label, *children])] + return Component(tag_name="fieldset", attributes=attributes, children=children) + + +def RadioFieldset(name: str, label: str, radio_buttons: list[dict[str, str]]): + return Component( + tag_name="span", + children=[ + Component(tag_name="legend", children=label), + Component( + tag_name="fieldset", + children=[ + Component( + tag_name="label", + attributes=[ + ("for", f"{name}__{radio["value"]}"), + ], + children=[ + radio["label"], + Input( + type="radio", + attributes=[ + ("id", f"{name}__{radio["value"]}"), + ("name", name), + ("value", radio["value"]), + ("onClick", radio.get("onclick", "")), + ], + ), + ], + ) + for radio in radio_buttons + ], + ), + ], + ) + + +def BooleanRadioFieldset(name: str, label: str): + return RadioFieldset( + name=name, + label=label, + radio_buttons=[ + {"label": "True", "value": "true"}, + {"label": "False", "value": "false"}, + ], + ) + + +def SubmitButton(label: str): + return Input(type="submit", attributes=[("value", label)]) + + +# RadioFieldset( +# name="filter__dropped", +# label="Dropped", +# radio_buttons=[ +# {"label": "True", "value": "true"}, +# {"label": "False", "value": "false"}, +# ], +# ) + + def Icon( name: str, attributes: list[HTMLAttribute] = [], diff --git a/common/utils.py b/common/utils.py index 30bade1..6ef6efe 100644 --- a/common/utils.py +++ b/common/utils.py @@ -1,6 +1,9 @@ from datetime import date from typing import Any, Generator, TypeVar +from django.apps import apps +from django.db.models import Model + def safe_division(numerator: int | float, denominator: int | float) -> int | float: """ @@ -64,3 +67,17 @@ def generate_split_ranges( def format_float_or_int(number: int | float): return int(number) if float(number).is_integer() else f"{number:03.2f}" + + +def get_model_by_string(app_label: str, model_name: str): + return apps.get_model(app_label, model_name) + + +def get_field(model: Model, field_name: str): + field = model._meta.get_field(field_name) + return field + + +def get_field_type(model: Model, field_name: str): + field = model._meta.get_field(field_name) + return type(field) diff --git a/games/filters.py b/games/filters.py new file mode 100644 index 0000000..c7c2471 --- /dev/null +++ b/games/filters.py @@ -0,0 +1,101 @@ +from enum import Enum +from typing import Any + +from django.db.models import ( + BooleanField, + CharField, + FloatField, + IntegerField, + QuerySet, + TextField, +) +from django.http import HttpRequest + +from common.components import * +from common.utils import get_field, get_model_by_string + +filter_param_prefix = "f_" + + +class Modifier(Enum): + EQUALS = "__exact" + GT = "__gt" + LT = "__lt" + CONTAINS = "__contains" + REGEX = "__regex" + ISNULL = "__isnull" + BETWEEN = "__gt", "__lt" + + +def create_filter_form(model: str, fields: list[str]): + filter_model = get_model_by_string("games", model) + automatic_filter_form_parts = [] + for field in fields: + html_field_name = f"{filter_param_prefix}{field}" + match get_field(filter_model, field): + case BooleanField(): + automatic_filter_form_parts.append( + BooleanRadioFieldset(name=html_field_name, label=field) + ) + case TextField(): + pass + case CharField(): + js = str + onclick_handler: js = """f_price_currency.disabled = true;""" + automatic_filter_form_parts.extend( + [ + RadioFieldset( + name=f"{field}_switch", + label="Modifier", + radio_buttons=[ + { + "label": "Equals", + "value": Modifier.EQUALS.value, + "onclick": onclick_handler, + }, + {"label": "Contains", "value": Modifier.CONTAINS.value}, + ], + ), + Input( + label=field, + id=html_field_name, + attributes=[ + ("name", html_field_name + str(Modifier.EQUALS.value)) + ], + ), + ] + ) + case IntegerField(): + pass + case FloatField(): + html = Input( + label=field, + type="number", + id=html_field_name, + attributes=[("name", html_field_name)], + ) + automatic_filter_form_parts.append(html) + case _: + print(f"Field type of {field} not handled yet.") + automatic_filter_form = Form( + children=[*automatic_filter_form_parts, SubmitButton("Apply")] + ) + return automatic_filter_form + + +def apply_filters(request: HttpRequest, queryset: QuerySet[Any]): + for parameter in request.GET: + if parameter.startswith(filter_param_prefix): + field_name = parameter.removeprefix(filter_param_prefix) + field_value = request.GET.get(parameter) + if field_value == "": + continue + match field_value: + case "true": + field_value = True + case "false": + field_value = False + case _: + pass + queryset = queryset.filter(**{f"{field_name}": field_value}) + return queryset diff --git a/games/views/purchase.py b/games/views/purchase.py index 005f08f..07ac518 100644 --- a/games/views/purchase.py +++ b/games/views/purchase.py @@ -13,9 +13,17 @@ from django.template.loader import render_to_string from django.urls import reverse from django.utils import timezone -from common.components import A, Button, Icon, LinkedNameWithPlatformIcon +from common.components import ( + BooleanRadioFieldset, + Form, + Icon, + Input, + LinkedNameWithPlatformIcon, + SubmitButton, +) from common.time import dateformat from common.utils import format_float_or_int +from games.filters import apply_filters, create_filter_form from games.forms import PurchaseForm from games.models import Edition, Purchase from games.views.general import use_custom_redirect @@ -27,120 +35,141 @@ def list_purchases(request: HttpRequest) -> HttpResponse: page_number = request.GET.get("page", 1) limit = request.GET.get("limit", 10) purchases = Purchase.objects.order_by("-date_purchased", "-created_at") + + filter_form = create_filter_form( + "Purchase", ["infinite", "price", "price_currency"] + ) + purchases = apply_filters(request, purchases) + + test_form = Form( + children=[ + BooleanRadioFieldset( + name="filter__infinite", + label="Infinite", + ), + Input( + label="Original currency", + id="filter__price_currency", + attributes=[("name", "filter__price_currency")], + ), + SubmitButton("Apply"), + ] + ) + page_obj = None if int(limit) != 0: paginator = Paginator(purchases, limit) page_obj = paginator.get_page(page_number) purchases = page_obj.object_list - context = { - "title": "Manage purchases", - "page_obj": page_obj or None, - "elided_page_range": ( - page_obj.paginator.get_elided_page_range( - page_number, on_each_side=1, on_ends=1 - ) - if page_obj - else None - ), - "data": { - "header_action": A([], Button([], "Add purchase"), url="add_purchase"), - "columns": [ - "Name", - "Type", - "Price", - "Currency", - "Infinite", - "Purchased", - "Refunded", - "Finished", - "Dropped", - "Created", - "Actions", - ], - "rows": [ - [ - LinkedNameWithPlatformIcon( - name=purchase.edition.name, - game_id=purchase.edition.game.pk, - platform=purchase.platform, - ), - purchase.get_type_display(), - format_float_or_int(purchase.price), - purchase.price_currency, - purchase.infinite, - purchase.date_purchased.strftime(dateformat), - ( - purchase.date_refunded.strftime(dateformat) - if purchase.date_refunded - else "-" - ), - ( - purchase.date_finished.strftime(dateformat) - if purchase.date_finished - else "-" - ), - ( - purchase.date_dropped.strftime(dateformat) - if purchase.date_dropped - else "-" - ), - purchase.created_at.strftime(dateformat), - render_to_string( - "cotton/button_group.html", - { - "buttons": [ - { - "href": reverse( - "finish_purchase", args=[purchase.pk] - ), - "slot": Icon("checkmark"), - "title": "Mark as finished", - } - if not purchase.date_finished - else {}, - { - "href": reverse( - "drop_purchase", args=[purchase.pk] - ), - "slot": Icon("eject"), - "title": "Mark as dropped", - } - if not purchase.date_dropped - else {}, - { - "href": reverse( - "refund_purchase", args=[purchase.pk] - ), - "slot": Icon("refund"), - "title": "Mark as refunded", - } - if not purchase.date_refunded - else {}, - { - "href": reverse( - "edit_purchase", args=[purchase.pk] - ), - "slot": Icon("edit"), - "title": "Edit", - "color": "gray", - }, - { - "href": reverse( - "delete_purchase", args=[purchase.pk] - ), - "slot": Icon("delete"), - "title": "Delete", - "color": "red", - }, - ] - }, - ), - ] - for purchase in purchases - ], - }, - } + context = { + "title": "Manage purchases", + "page_obj": page_obj or None, + "elided_page_range": ( + page_obj.paginator.get_elided_page_range( + page_number, on_each_side=1, on_ends=1 + ) + if page_obj + else None + ), + "data": { + "header_action": filter_form, + "columns": [ + "Name", + "Type", + "Price", + "Currency", + "Infinite", + "Purchased", + "Refunded", + "Finished", + "Dropped", + "Created", + "Actions", + ], + "rows": [ + [ + LinkedNameWithPlatformIcon( + name=purchase.edition.name, + game_id=purchase.edition.game.pk, + platform=purchase.platform, + ), + purchase.get_type_display(), + format_float_or_int(purchase.price), + purchase.price_currency, + purchase.infinite, + purchase.date_purchased.strftime(dateformat), + ( + purchase.date_refunded.strftime(dateformat) + if purchase.date_refunded + else "-" + ), + ( + purchase.date_finished.strftime(dateformat) + if purchase.date_finished + else "-" + ), + ( + purchase.date_dropped.strftime(dateformat) + if purchase.date_dropped + else "-" + ), + purchase.created_at.strftime(dateformat), + render_to_string( + "cotton/button_group.html", + { + "buttons": [ + { + "href": reverse( + "finish_purchase", args=[purchase.pk] + ), + "slot": Icon("checkmark"), + "title": "Mark as finished", + } + if not purchase.date_finished + else {}, + { + "href": reverse( + "drop_purchase", args=[purchase.pk] + ), + "slot": Icon("eject"), + "title": "Mark as dropped", + } + if not purchase.date_dropped + else {}, + { + "href": reverse( + "refund_purchase", args=[purchase.pk] + ), + "slot": Icon("refund"), + "title": "Mark as refunded", + } + if not purchase.date_refunded + else {}, + { + "href": reverse( + "edit_purchase", args=[purchase.pk] + ), + "slot": Icon("edit"), + "title": "Edit", + "color": "gray", + }, + { + "href": reverse( + "delete_purchase", args=[purchase.pk] + ), + "slot": Icon("delete"), + "title": "Delete", + "color": "red", + }, + ] + }, + ), + ] + for purchase in purchases + ], + }, + } return render(request, "list_purchases.html", context)