Compare commits

..

6 Commits

Author SHA1 Message Date
Lukáš Kucharczyk fc0d8db8e8
consistently format prices everywhere
Django CI/CD / test (push) Successful in 1m7s Details
Django CI/CD / build-and-push (push) Successful in 2m10s Details
2024-11-15 22:53:07 +01:00
Lukáš Kucharczyk 8acc4f9c5b
make table work better on small screens
Django CI/CD / test (push) Successful in 56s Details
Django CI/CD / build-and-push (push) Successful in 2m12s Details
2024-11-13 21:28:44 +01:00
Lukáš Kucharczyk 6b7a96dc06
make PopoverTruncated customizable 2024-11-13 21:28:17 +01:00
Lukáš Kucharczyk 5c5fd5f26a
truncate: strip trailing whitespace 2024-11-13 21:07:26 +01:00
Lukáš Kucharczyk 7181b6472c
fix mistakenly hardcoded value in truncate() 2024-11-13 21:06:52 +01:00
Lukáš Kucharczyk af06d07ee3
pre-commit hook: disable isort 2024-11-13 21:06:38 +01:00
10 changed files with 162 additions and 365 deletions

View File

@ -6,11 +6,11 @@ repos:
# rev: 24.8.0
# hooks:
# - id: black
- repo: https://github.com/pycqa/isort
rev: 5.13.2
hooks:
- id: isort
name: isort (python)
# - repo: https://github.com/pycqa/isort
# rev: 5.13.2
# hooks:
# - id: isort
# name: isort (python)
- repo: https://github.com/Riverside-Healthcare/djLint
rev: v1.34.0
hooks:

View File

@ -3,6 +3,7 @@ from string import ascii_lowercase
from typing import Any, Callable
from django.template import TemplateDoesNotExist
from django.template.defaultfilters import floatformat
from django.template.loader import render_to_string
from django.urls import NoReverseMatch, reverse
from django.utils.safestring import SafeText, mark_safe
@ -50,6 +51,7 @@ def randomid(seed: str = "", length: int = 10) -> str:
def Popover(
popover_content: str,
wrapped_content: str = "",
wrapped_classes: str = "",
children: list[HTMLTag] = [],
attributes: list[HTMLAttribute] = [],
) -> str:
@ -62,14 +64,15 @@ def Popover(
("id", id),
("wrapped_content", wrapped_content),
("popover_content", popover_content),
("wrapped_classes", wrapped_classes),
],
children=children,
template="cotton/popover.html",
)
def PopoverTruncated(input_string: str) -> str:
if (truncated := truncate(input_string)) != input_string:
def PopoverTruncated(input_string: str, length: int = 30, ellipsis: str = "") -> str:
if (truncated := truncate(input_string, length, ellipsis)) != input_string:
return Popover(wrapped_content=truncated, popover_content=input_string)
else:
return input_string
@ -125,33 +128,14 @@ def Div(
return Component(tag_name="div", attributes=attributes, children=children)
def Label(
attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [],
):
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,
return Component(
tag_name="input", attributes=attributes + [("type", type)], 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(
@ -167,74 +151,6 @@ 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] = [],
@ -280,3 +196,11 @@ def NameWithPlatformIcon(name: str, platform: str) -> SafeText:
)
return mark_safe(content)
def PurchasePrice(purchase) -> str:
return Popover(
popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}",
wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}",
wrapped_classes="underline decoration-dotted",
)

View File

@ -1,9 +1,6 @@
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:
"""
@ -39,8 +36,8 @@ def safe_getattr(obj: object, attr_chain: str, default: Any | None = None) -> ob
def truncate(input_string: str, length: int = 30, ellipsis: str = "") -> str:
return (
(f"{input_string[:length-len(ellipsis)]}{ellipsis}")
if len(input_string) > 30
(f"{input_string[:length-len(ellipsis)].rstrip()}{ellipsis}")
if len(input_string) > length
else input_string
)
@ -67,17 +64,3 @@ 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)

View File

@ -1,101 +0,0 @@
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

View File

@ -2192,6 +2192,10 @@ input:checked + .toggle-bg {
text-decoration-color: #64748b;
}
.decoration-dotted {
text-decoration-style: dotted;
}
.opacity-0 {
opacity: 0;
}
@ -3117,6 +3121,10 @@ textarea:disabled:is(.dark *) {
flex-direction: row;
}
.md\:justify-between {
justify-content: space-between;
}
.md\:space-x-8 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(2rem * var(--tw-space-x-reverse));
@ -3260,3 +3268,13 @@ textarea:disabled:is(.dark *) {
.\[\&_td\:last-child\]\:text-right td:last-child {
text-align: right;
}
@media not all and (min-width: 640px) {
.\[\&_td\:not\(\:first-child\)\:not\(\:last-child\)\]\:max-sm\:hidden td:not(:first-child):not(:last-child) {
display: none;
}
.\[\&_th\:not\(\:first-child\)\:not\(\:last-child\)\]\:max-sm\:hidden th:not(:first-child):not(:last-child) {
display: none;
}
}

View File

@ -1,8 +1,10 @@
<span data-popover-target={{ id }} class="{{ class }}">{{ wrapped_content|default:slot }}</span>
<span data-popover-target={{ id }} class="{{ wrapped_classes }}">{{ wrapped_content|default:slot }}</span>
<div data-popover
id="{{ id }}"
role="tooltip"
class="absolute z-10 invisible inline-block text-sm text-white transition-opacity duration-300 bg-white border border-purple-200 rounded-lg shadow-sm opacity-0 dark:text-white dark:border-purple-600 dark:bg-purple-800">
<div class="px-3 py-2">{{ popover_content }}</div>
<div data-popper-arrow></div>
<!-- for Tailwind CSS to generate decoration-dotted CSS from Python component -->
<span class="hidden decoration-dotted"></span>
</div>

View File

@ -7,20 +7,20 @@
{{ 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 [&_th:not(:first-child):not(:last-child)]:max-sm:hidden">
<tr>
{% for column in columns %}<th scope="col" class="px-6 py-3">{{ column }}</th>{% endfor %}
</tr>
</thead>
<tbody class="dark:divide-y">
<tbody class="dark:divide-y [&_td:not(:first-child):not(:last-child)]:max-sm:hidden">
{% for row in rows %}<c-table-row :data=row />{% endfor %}
</tbody>
</table>
</div>
{% if page_obj and elided_page_range %}
<nav class="flex items-center flex-column md:flex-row justify-between px-6 py-4 dark:bg-gray-900 sm:rounded-b-lg"
<nav class="flex items-center flex-col md:flex-row md:justify-between px-6 py-4 dark:bg-gray-900 sm:rounded-b-lg"
aria-label="Table navigation">
<span class="text-sm font-normal text-gray-500 dark:text-gray-400 mb-4 md:mb-0 block w-full md:inline md:w-auto">Showing <span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.start_index }}</span><span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.end_index }}</span> of <span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.paginator.count }}</span></span>
<span class="text-sm text-center font-normal text-gray-500 dark:text-gray-400 mb-4 md:mb-0 block w-full md:inline md:w-auto"><span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.start_index }}</span><span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.end_index }}</span> of <span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.paginator.count }}</span></span>
<ul class="inline-flex -space-x-px rtl:space-x-reverse text-sm h-8">
<li>
{% if page_obj.has_previous %}

View File

@ -142,7 +142,9 @@
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Spendings ({{ total_spent_currency }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_spent }} ({{ spent_per_game }}/game)</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ total_spent | floatformat }} ({{ spent_per_game | floatformat }}/game)
</td>
</tr>
</tbody>
</table>
@ -253,7 +255,7 @@
{% for purchase in purchased_unfinished %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.converted_price }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.converted_price | floatformat }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
@ -274,7 +276,7 @@
{% for purchase in all_purchased_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.converted_price }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.converted_price | floatformat }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
</tr>
{% endfor %}

View File

@ -16,6 +16,7 @@ from common.components import (
NameWithPlatformIcon,
Popover,
PopoverTruncated,
PurchasePrice,
)
from common.time import (
dateformat,
@ -25,7 +26,7 @@ from common.time import (
local_strftime,
timeformat,
)
from common.utils import format_float_or_int, safe_division, truncate
from common.utils import safe_division, truncate
from games.forms import GameForm
from games.models import Edition, Game, Purchase, Session
from games.views.general import use_custom_redirect
@ -247,7 +248,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
),
purchase.get_type_display(),
purchase.date_purchased.strftime(dateformat),
f"{format_float_or_int(purchase.price)} {purchase.price_currency}",
PurchasePrice(purchase),
render_to_string(
"cotton/button_group.html",
{

View File

@ -13,17 +13,8 @@ from django.template.loader import render_to_string
from django.urls import reverse
from django.utils import timezone
from common.components import (
BooleanRadioFieldset,
Form,
Icon,
Input,
LinkedNameWithPlatformIcon,
SubmitButton,
)
from common.components import A, Button, Icon, LinkedNameWithPlatformIcon, PurchasePrice
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
@ -35,141 +26,118 @@ 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": 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
],
},
}
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",
"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(),
PurchasePrice(purchase),
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)