3 Commits

Author SHA1 Message Date
98c9c1faee move time-related functionality out of views.general
All checks were successful
Django CI/CD / test (push) Successful in 58s
Django CI/CD / build-and-push (push) Successful in 1m52s
2024-09-04 21:55:22 +02:00
645ffa0dad update styles
All checks were successful
Django CI/CD / test (push) Successful in 1m3s
Django CI/CD / build-and-push (push) Successful in 2m4s
2024-09-03 22:39:25 +02:00
4358708262 add links to add a new X to: game, edition, purchase, session, device, platform
All checks were successful
Django CI/CD / test (push) Successful in 55s
Django CI/CD / build-and-push (push) Successful in 1m57s
2024-09-03 15:48:58 +02:00
15 changed files with 158 additions and 63 deletions

View File

@ -1,5 +1,13 @@
import re
from datetime import timedelta
from datetime import datetime, timedelta
from django.utils import timezone
dateformat: str = "%d/%m/%Y"
datetimeformat: str = "%d/%m/%Y %H:%M"
timeformat: str = "%H:%M"
durationformat: str = "%2.1H hours"
durationformat_manual: str = "%H hours"
def _safe_timedelta(duration: timedelta | int | None):
@ -70,3 +78,9 @@ def format_duration(
rf"%\d*\.?\d*{pattern}", replacement, formatted_string
)
return formatted_string
def local_strftime(datetime: datetime, format: str = datetimeformat) -> str:
return timezone.localtime(datetime).strftime(format)

View File

@ -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"<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)
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.

View File

@ -1782,10 +1782,6 @@ input:checked + .toggle-bg {
border-width: 0px;
}
.border-b {
border-bottom-width: 1px;
}
.border-blue-600 {
--tw-border-opacity: 1;
border-color: rgb(28 100 242 / var(--tw-border-opacity));
@ -1966,6 +1962,10 @@ input:checked + .toggle-bg {
text-align: center;
}
.text-right {
text-align: right;
}
.align-top {
vertical-align: top;
}
@ -2693,6 +2693,12 @@ textarea:disabled:is(.dark *) {
outline-color: #AC94FA;
}
.dark\:divide-y:is(.dark *) > :not([hidden]) ~ :not([hidden]) {
--tw-divide-y-reverse: 0;
border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
border-bottom-width: calc(1px * var(--tw-divide-y-reverse));
}
.dark\:divide-gray-600:is(.dark *) > :not([hidden]) ~ :not([hidden]) {
--tw-divide-opacity: 1;
border-color: rgb(75 85 99 / var(--tw-divide-opacity));
@ -2926,6 +2932,16 @@ textarea:disabled:is(.dark *) {
border-radius: 0.5rem;
}
.sm\:rounded-b-lg {
border-bottom-right-radius: 0.5rem;
border-bottom-left-radius: 0.5rem;
}
.sm\:rounded-t-lg {
border-top-left-radius: 0.5rem;
border-top-right-radius: 0.5rem;
}
.sm\:px-4 {
padding-left: 1rem;
padding-right: 1rem;
@ -3075,6 +3091,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;
}

View File

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

View File

@ -1,19 +1,24 @@
{% load param_utils %}
<div class="shadow-md sm:rounded-lg" hx-boost="false">
<div class="relative overflow-x-auto sm:rounded-lg">
<div class="shadow-md" hx-boost="false">
<div class="relative overflow-x-auto sm:rounded-t-lg">
<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">
<tr>
{% for column in columns %}<th scope="col" class="px-6 py-3">{{ column }}</th>{% endfor %}
</tr>
</thead>
<tbody>
<tbody class="dark:divide-y">
{% 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"
<nav class="flex items-center flex-column md:flex-row 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>
<ul class="inline-flex -space-x-px rtl:space-x-reverse text-sm h-8">

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

@ -1,4 +1,4 @@
<tr class="odd:bg-white odd:dark:bg-gray-900 even:bg-gray-50 even:dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 border-b [&_a]:underline [&_a]:underline-offset-4 [&_a]:decoration-2">
<tr class="odd:bg-white odd:dark:bg-gray-900 even:bg-gray-50 even:dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 [&_a]:underline [&_a]:underline-offset-4 [&_a]:decoration-2">
{% if slot %}
{{ slot }}
{% else %}

View File

@ -5,6 +5,6 @@
{% endblock title %}
{% block content %}
<div class="2xl:max-w-screen-2xl xl:max-w-screen-xl md:max-w-screen-md sm:max-w-screen-sm self-center">
<c-simple-table :columns=data.columns :rows=data.rows :page_obj=page_obj :elided_page_range=elided_page_range />
<c-simple-table :columns=data.columns :rows=data.rows :page_obj=page_obj :elided_page_range=elided_page_range :header_action=data.header_action />
</div>
{% endblock content %}

View File

@ -7,9 +7,10 @@ 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.time import dateformat, local_strftime
from common.utils import A, Button
from games.forms import DeviceForm
from games.models import Device
from games.views.general import dateformat
@login_required
@ -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",
@ -45,7 +47,7 @@ def list_devices(request: HttpRequest) -> HttpResponse:
[
device.name,
device.get_type_display(),
device.created_at.strftime(dateformat),
local_strftime(device.created_at, dateformat),
render_to_string(
"cotton/button_group_sm.html",
{

View File

@ -7,10 +7,10 @@ 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.time import dateformat, local_strftime
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
@login_required
@ -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",
@ -74,7 +75,7 @@ def list_editions(request: HttpRequest) -> HttpResponse:
truncate_with_popover(str(edition.platform)),
edition.year_released,
edition.wikidata,
edition.created_at.strftime(dateformat),
local_strftime(edition.created_at, dateformat),
render_to_string(
"cotton/button_group_sm.html",
{

View File

@ -8,18 +8,18 @@ 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.time import format_duration
from common.utils import A, safe_division, truncate_with_popover
from games.forms import GameForm
from games.models import Edition, Game, Purchase, Session
from games.views.general import (
from common.time import (
dateformat,
datetimeformat,
durationformat,
durationformat_manual,
format_duration,
local_strftime,
timeformat,
use_custom_redirect,
)
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 use_custom_redirect
@login_required
@ -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",
@ -74,7 +75,7 @@ def list_games(request: HttpRequest) -> HttpResponse:
),
game.year_released,
game.wikidata,
game.created_at.strftime(dateformat),
local_strftime(game.created_at, dateformat),
render_to_string(
"cotton/button_group_sm.html",
{
@ -174,9 +175,9 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
)
if sessions:
playrange_start = sessions.earliest().timestamp_start.strftime("%b %Y")
playrange_start = local_strftime(sessions.earliest().timestamp_start, "%b %Y")
latest_session = sessions.latest()
playrange_end = latest_session.timestamp_start.strftime("%b %Y")
playrange_end = local_strftime(latest_session.timestamp_start, "%b %Y")
playrange = (
playrange_start
@ -268,7 +269,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
"columns": ["Date", "Duration", "Duration (manual)", "Actions"],
"rows": [
[
f"{session.timestamp_start.strftime(datetimeformat)}{f"{session.timestamp_end.strftime(timeformat)}" if session.timestamp_end else ""}",
f"{local_strftime(session.timestamp_start)}{f"{session.timestamp_end.strftime(timeformat)}" if session.timestamp_end else ""}",
(
format_duration(session.duration_calculated, durationformat)
if session.duration_calculated

View File

@ -12,12 +12,6 @@ from common.time import format_duration
from common.utils import safe_division
from games.models import Edition, Game, Platform, Purchase, Session
dateformat: str = "%d/%m/%Y"
datetimeformat: str = "%d/%m/%Y %H:%M"
timeformat: str = "%H:%M"
durationformat: str = "%2.1H hours"
durationformat_manual: str = "%H hours"
def model_counts(request: HttpRequest) -> dict[str, bool]:
return {

View File

@ -7,9 +7,11 @@ 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.time import dateformat, local_strftime
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
from games.views.general import use_custom_redirect
@login_required
@ -35,6 +37,7 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
else None
),
"data": {
"header_action": A([], Button([], "Add platform"), url="add_platform"),
"columns": [
"Name",
"Group",
@ -45,7 +48,7 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
[
platform.name,
platform.group,
platform.created_at.strftime(dateformat),
local_strftime(platform.created_at, dateformat),
render_to_string(
"cotton/button_group_sm.html",
{

View File

@ -13,10 +13,11 @@ 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.time import dateformat, local_strftime
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
from games.views.general import use_custom_redirect
@login_required
@ -42,6 +43,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
else None
),
"data": {
"header_action": A([], Button([], "Add purchase"), url="add_purchase"),
"columns": [
"Name",
"Type",
@ -79,23 +81,23 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
purchase.price,
purchase.price_currency,
purchase.infinite,
purchase.date_purchased.strftime(dateformat),
local_strftime(purchase.date_purchased, dateformat),
(
purchase.date_refunded.strftime(dateformat)
local_strftime(purchase.date_refunded, dateformat)
if purchase.date_refunded
else "-"
),
(
purchase.date_finished.strftime(dateformat)
local_strftime(purchase.date_finished, dateformat)
if purchase.date_finished
else "-"
),
(
purchase.date_dropped.strftime(dateformat)
local_strftime(purchase.date_dropped, dateformat)
if purchase.date_dropped
else "-"
),
purchase.created_at.strftime(dateformat),
local_strftime(purchase.created_at, dateformat),
render_to_string(
"cotton/button_group_sm.html",
{

View File

@ -8,18 +8,18 @@ from django.template.loader import render_to_string
from django.urls import reverse
from django.utils import timezone
from common.time import format_duration
from common.utils import A, truncate_with_popover
from games.forms import SessionForm
from games.models import Purchase, Session
from games.views.general import (
from common.time import (
dateformat,
datetimeformat,
durationformat,
durationformat_manual,
format_duration,
local_strftime,
timeformat,
use_custom_redirect,
)
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 use_custom_redirect
@login_required
@ -45,6 +45,7 @@ def list_sessions(request: HttpRequest) -> HttpResponse:
else None
),
"data": {
"header_action": A([], Button([], "Add session"), url="add_session"),
"columns": [
"Name",
"Date",
@ -57,18 +58,13 @@ 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 ""}",
f"{local_strftime(session.timestamp_start)}{f"{local_strftime(session.timestamp_end, timeformat)}" if session.timestamp_end else ""}",
(
format_duration(session.duration_calculated, durationformat)
if session.duration_calculated