Move from HTML templates to pure Python

Remove cruft
This commit is contained in:
2026-06-06 07:11:46 +02:00
parent 09db54e940
commit d101aecd70
109 changed files with 2903 additions and 2949 deletions
+57
View File
@@ -0,0 +1,57 @@
"""Authentication views rendered with the Python layout (replaces
registration/login.html)."""
from django.contrib.auth import views as auth_views
from django.http import HttpResponse
from django.utils.safestring import SafeText, mark_safe
from common.components import Component, CsrfInput, Div, Input
from common.layout import render_page
def _login_content(form, request) -> SafeText:
table = Component(
tag_name="table",
children=[
CsrfInput(request),
mark_safe(str(form.as_table())),
Component(
tag_name="tr",
children=[
Component(tag_name="td"),
Component(
tag_name="td",
children=[
Input(type="submit", attributes=[("value", "Login")])
],
),
],
),
],
)
return Div(
[("class", "flex items-center flex-col")],
[
Component(
tag_name="h2",
attributes=[("class", "text-3xl text-white mb-8")],
children=["Please log in to continue"],
),
Component(
tag_name="form",
attributes=[("method", "post")],
children=[table],
),
],
)
class LoginView(auth_views.LoginView):
"""Django's LoginView, but the page body is built in Python."""
def render_to_response(self, context, **response_kwargs) -> HttpResponse:
return render_page(
self.request,
_login_content(context["form"], self.request),
title="Login",
)
+54 -53
View File
@@ -1,12 +1,18 @@
from typing import Any
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from common.components import A, Button, ButtonGroup, Icon
from common.components import (
A,
AddForm,
Button,
ButtonGroup,
Icon,
paginated_table_content,
)
from common.layout import render_page
from common.time import dateformat, local_strftime
from games.forms import DeviceForm
from games.models import Device
@@ -14,7 +20,6 @@ from games.models import Device
@login_required
def list_devices(request: HttpRequest) -> HttpResponse:
context: dict[Any, Any] = {}
page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10)
devices = Device.objects.order_by("-created_at")
@@ -23,50 +28,50 @@ def list_devices(request: HttpRequest) -> HttpResponse:
paginator = Paginator(devices, limit)
page_obj = paginator.get_page(page_number)
devices = page_obj.object_list
elided_page_range = (
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
if page_obj
else None
)
context = {
"title": "Manage devices",
"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 device"), url_name="games:add_device"),
"columns": [
"Name",
"Type",
"Created",
"Actions",
],
"rows": [
[
device.name,
device.get_type_display(),
local_strftime(device.created_at, dateformat),
ButtonGroup(
[
{
"href": reverse("games:edit_device", args=[device.pk]),
"slot": Icon("edit"),
"color": "gray",
},
{
"href": reverse("games:delete_device", args=[device.pk]),
"slot": Icon("delete"),
"color": "red",
},
]
),
]
for device in devices
],
},
data = {
"header_action": A([], Button([], "Add device"), url_name="games:add_device"),
"columns": [
"Name",
"Type",
"Created",
"Actions",
],
"rows": [
[
device.name,
device.get_type_display(),
local_strftime(device.created_at, dateformat),
ButtonGroup(
[
{
"href": reverse("games:edit_device", args=[device.pk]),
"slot": Icon("edit"),
"color": "gray",
},
{
"href": reverse("games:delete_device", args=[device.pk]),
"slot": Icon("delete"),
"color": "red",
},
]
),
]
for device in devices
],
}
return render(request, "list_purchases.html", context)
content = paginated_table_content(
data,
page_obj=page_obj,
elided_page_range=elided_page_range,
request=request,
)
return render_page(request, content, title="Manage devices")
@login_required
@@ -77,8 +82,7 @@ def edit_device(request: HttpRequest, device_id: int = 0) -> HttpResponse:
form.save()
return redirect("games:list_devices")
context: dict[str, Any] = {"form": form, "title": "Edit device"}
return render(request, "add.html", context)
return render_page(request, AddForm(form, request=request), title="Edit device")
@login_required
@@ -90,12 +94,9 @@ def delete_device(request: HttpRequest, device_id: int) -> HttpResponse:
@login_required
def add_device(request: HttpRequest) -> HttpResponse:
context: dict[str, Any] = {}
form = DeviceForm(request.POST or None)
if form.is_valid():
form.save()
return redirect("games:index")
context["form"] = form
context["title"] = "Add New Device"
return render(request, "add.html", context)
return render_page(request, AddForm(form, request=request), title="Add New Device")
+586 -152
View File
@@ -2,25 +2,39 @@ from typing import Any
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Prefetch, Q
from django.middleware.csrf import get_token
from django.db.models import Q
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string
from django.shortcuts import get_object_or_404, redirect
from django.template.defaultfilters import date as date_filter
from django.urls import reverse
from django.utils.safestring import SafeText, mark_safe
from common.components import (
A,
AddForm,
Button,
ButtonGroup,
Component,
CsrfInput,
Div,
GameStatus,
GameStatusSelector,
H1,
Icon,
SearchField,
LinkedPurchase,
Modal,
ModuleScript,
NameWithIcon,
Popover,
PopoverTruncated,
PurchasePrice,
SimpleTable,
paginated_table_content,
)
from common.icons import get_icon
from common.layout import render_page
from common.time import (
dateformat,
format_duration,
@@ -29,14 +43,13 @@ from common.time import (
)
from common.utils import build_dynamic_filter, safe_division, truncate
from games.forms import GameForm
from games.models import Game, Purchase
from games.models import Game
from games.views.general import use_custom_redirect
from games.views.playevent import create_playevent_tabledata
@login_required
def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
context: dict[Any, Any] = {}
page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10)
games = Game.objects.order_by("-created_at")
@@ -66,77 +79,70 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
page_obj = paginator.get_page(page_number)
games = page_obj.object_list
context = {
"title": "Manage games",
"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
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": Div(
children=[
SearchField(search_string=search_string),
A([], Button([], "Add game"), url_name="games:add_game"),
],
attributes=[("class", "flex justify-between")],
),
"data": {
"header_action": Div(
children=[
SearchField(search_string=search_string),
A([], Button([], "Add game"), url_name="games:add_game"),
],
attributes=[("class", "flex justify-between")],
),
"columns": [
"Name",
"Sort Name",
"Year",
"Status",
"Wikidata",
"Created",
"Actions",
],
"rows": [
[
NameWithIcon(game=game),
PopoverTruncated(
game.sort_name
if game.sort_name is not None and game.name != game.sort_name
else "(identical)"
),
game.year_released,
render_to_string(
"partials/gamestatus_selector.html",
"columns": [
"Name",
"Sort Name",
"Year",
"Status",
"Wikidata",
"Created",
"Actions",
],
"rows": [
[
NameWithIcon(game=game),
PopoverTruncated(
game.sort_name
if game.sort_name is not None and game.name != game.sort_name
else "(identical)"
),
game.year_released,
GameStatusSelector(game, Game.Status.choices, get_token(request)),
game.wikidata,
local_strftime(game.created_at, dateformat),
ButtonGroup(
[
{
"game": game,
"game_statuses": Game.Status.choices,
"href": reverse("games:edit_game", args=[game.pk]),
"slot": Icon("edit"),
"color": "gray",
},
request=request,
),
game.wikidata,
local_strftime(game.created_at, dateformat),
ButtonGroup(
[
{
"href": reverse("games:edit_game", args=[game.pk]),
"slot": Icon("edit"),
"color": "gray",
},
{
"href": reverse("games:delete_game", args=[game.pk]),
"slot": Icon("delete"),
"color": "red",
},
]
),
]
for game in games
],
},
{
"href": reverse("games:delete_game", args=[game.pk]),
"slot": Icon("delete"),
"color": "red",
},
]
),
]
for game in games
],
}
return render(request, "list_purchases.html", context)
content = paginated_table_content(
data,
page_obj=page_obj,
elided_page_range=elided_page_range,
request=request,
)
return render_page(request, content, title="Manage games")
@login_required
def add_game(request: HttpRequest) -> HttpResponse:
context: dict[str, Any] = {}
form = GameForm(request.POST or None)
if form.is_valid():
game = form.save()
@@ -147,27 +153,154 @@ def add_game(request: HttpRequest) -> HttpResponse:
else:
return redirect("games:list_games")
context["form"] = form
context["title"] = "Add New Game"
context["script_name"] = "add_game.js"
return render(request, "add_game.html", context)
return render_page(
request,
AddForm(
form,
request=request,
additional_row=Button(
[],
"Submit & Create Purchase",
color="gray",
type="submit",
name="submit_and_redirect",
),
),
title="Add New Game",
scripts=ModuleScript("add_game.js"),
)
def _delete_game_confirmation_modal(
game: Game,
session_count: int,
purchase_count: int,
playevent_count: int,
request: HttpRequest,
) -> SafeText:
data_items = []
if session_count:
data_items.append(
Component(tag_name="li", children=[f"{session_count} session(s)"])
)
if purchase_count:
data_items.append(
Component(tag_name="li", children=[f"{purchase_count} purchase(s)"])
)
if playevent_count:
data_items.append(
Component(tag_name="li", children=[f"{playevent_count} play event(s)"])
)
if not (session_count or purchase_count or playevent_count):
data_items.append(Component(tag_name="li", children=["No associated data"]))
form = Component(
tag_name="form",
attributes=[
("hx-post", reverse("games:delete_game", args=[game.id])),
("hx-replace-url", "true"),
("hx-target", "#main-container"),
("hx-select", "#main-container"),
("hx-swap", "outerHTML"),
],
children=[
CsrfInput(request),
Component(
tag_name="p",
attributes=[
(
"class",
"dark:text-white text-center mt-3 text-sm text-gray-600 "
"dark:text-gray-400",
)
],
children=[
"This will permanently delete this game and all associated data:"
],
),
Component(
tag_name="ul",
attributes=[
(
"class",
"dark:text-white text-center mt-1 text-sm text-gray-600 "
"dark:text-gray-400 list-disc list-inside",
)
],
children=data_items,
),
Component(
tag_name="p",
attributes=[
(
"class",
"dark:text-white text-center mt-3 text-sm font-medium "
"text-red-600 dark:text-red-400",
)
],
children=["This action cannot be undone."],
),
Div(
[("class", "items-center mt-5")],
[
Button(
[("class", "w-full")],
"Delete",
color="red",
size="lg",
type="submit",
),
Button(
[("class", "mt-0 w-full")],
"Cancel",
color="gray",
size="base",
onclick=(
"this.closest('#delete-game-confirmation-modal').remove()"
),
),
],
),
],
)
return Modal(
"delete-game-confirmation-modal",
children=[
Component(
tag_name="h1",
attributes=[
(
"class",
"text-2xl leading-6 font-medium dark:text-white text-center",
)
],
children=["Delete Game"],
),
Component(
tag_name="p",
attributes=[("class", "dark:text-white text-center mt-5")],
children=[
"Are you sure you want to delete ",
Component(tag_name="strong", children=[game.name]),
"?",
],
),
form,
],
)
@login_required
def delete_game_confirmation(request: HttpRequest, game_id: int) -> HttpResponse:
game = get_object_or_404(Game, id=game_id)
session_count = game.sessions.count()
purchase_count = game.purchases.count()
playevent_count = game.playevents.count()
return render(
request,
"partials/delete_game_confirmation.html",
{
"game": game,
"session_count": session_count,
"purchase_count": purchase_count,
"playevent_count": playevent_count,
},
return HttpResponse(
_delete_game_confirmation_modal(
game,
game.sessions.count(),
game.purchases.count(),
game.playevents.count(),
request,
)
)
@@ -181,35 +314,224 @@ def delete_game(request: HttpRequest, game_id: int) -> HttpResponse:
@login_required
@use_custom_redirect
def edit_game(request: HttpRequest, game_id: int) -> HttpResponse:
context = {}
purchase = get_object_or_404(Game, id=game_id)
form = GameForm(request.POST or None, instance=purchase)
if form.is_valid():
form.save()
return redirect("games:list_sessions")
context["title"] = "Edit Game"
context["form"] = form
return render(request, "add.html", context)
return render_page(request, AddForm(form, request=request), title="Edit Game")
# --- view_game content builders -------------------------------------------
_STAT_SVGS = {
"hours": '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>',
"sessions": '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M5.25 8.25h15m-16.5 7.5h15m-1.8-13.5-3.9 19.5m-2.1-19.5-3.9 19.5" /></svg>',
"average": '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 14.25v2.25m3-4.5v4.5m3-6.75v6.75m3-9v9M6 20.25h12A2.25 2.25 0 0 0 20.25 18V6A2.25 2.25 0 0 0 18 3.75H6A2.25 2.25 0 0 0 3.75 6v12A2.25 2.25 0 0 0 6 20.25Z" /></svg>',
"playrange": '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12v-.008ZM12 15h.008v.008H12V15Zm0 2.25h.008v.008H12v-.008ZM9.75 15h.008v.008H9.75V15Zm0 2.25h.008v.008H9.75v-.008ZM7.5 15h.008v.008H7.5V15Zm0 2.25h.008v.008H7.5v-.008Zm6.75-4.5h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V15Zm0 2.25h.008v.008h-.008v-.008Zm2.25-4.5h.008v.008H16.5v-.008Zm0 2.25h.008v.008H16.5V15Z" /></svg>',
}
_PLAYED_ROW_TEMPLATE = """<div class="flex gap-2 items-center" x-data="{ open: false }">
<span class="uppercase">Played</span>
<div class="inline-flex rounded-md shadow-2xs" role="group" x-data="{ played: @@PLAYED_COUNT@@ }">
<a href="@@ADD_PE@@">
<button type="button" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
<span x-text="played"></span> times
</button>
</a>
<button type="button" x-on:click="open = !open" @click.outside="open = false" class="relative px-4 py-2 text-sm font-medium text-gray-900 bg-white border-e border-b border-t border-gray-200 rounded-e-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white align-middle hover:cursor-pointer">
@@ARROWDOWN@@
<div
class="absolute top-full -left-px w-auto whitespace-nowrap z-10 text-sm font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-tl-none border border-gray-200 dark:border-gray-700"
x-show="open"
>
<ul
class=""
>
<li class="px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-tr-md">
<a href="@@ADD_PE_FOR_GAME@@">Add playthrough...</a>
</li>
<li
x-on:click="createPlayEvent"
class="relative px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-b-md"
>
Played times +1
</li>
<script>
function createPlayEvent() {
this.played++;
// TODO: migrate to hx-post + hx-on::after-request for HTMX-native toast handling
fetchWithHtmxTriggers('@@API_CREATE@@', {
method: 'POST',
headers: { 'X-CSRFToken': '@@CSRF@@', 'Content-Type': 'application/json' },
body: '{"game_id": @@GAME_ID@@}'
})
.catch(() => {
this.played--;
console.error('Failed to record play');
});
}
</script>
</ul>
</div>
</button>
</div>
</div>"""
def _played_row(game: Game, request: HttpRequest) -> SafeText:
"""The 'Played N times' control with its Alpine.js dropdown."""
replacements = {
"@@PLAYED_COUNT@@": str(game.playevents.count()),
"@@ADD_PE@@": reverse("games:add_playevent"),
"@@ARROWDOWN@@": get_icon("arrowdown"),
"@@ADD_PE_FOR_GAME@@": reverse("games:add_playevent_for_game", args=[game.id]),
"@@API_CREATE@@": reverse("api-1.0.0:create_playevent"),
"@@CSRF@@": get_token(request),
"@@GAME_ID@@": str(game.id),
}
html = _PLAYED_ROW_TEMPLATE
for token, value in replacements.items():
html = html.replace(token, value)
return mark_safe(html)
def _stat_popover(popover_id: str, tooltip: str, svg_key: str, value: str) -> SafeText:
return Popover(
popover_content=tooltip,
wrapped_classes="flex gap-2 items-center",
id=popover_id,
children=[mark_safe(_STAT_SVGS[svg_key]), str(value)],
)
def _meta_row(
label: str, value: SafeText | str, extra: SafeText | str = ""
) -> SafeText:
children: list[SafeText | str] = [
Component(
tag_name="span", attributes=[("class", "uppercase")], children=[label]
),
value,
]
if extra:
children.append(extra)
return Div([("class", "flex gap-2 items-center")], children)
def _game_action_buttons(game: Game) -> SafeText:
edit_class = (
"px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 "
"rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 "
"focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 "
"dark:text-white dark:hover:text-white dark:hover:bg-gray-700 "
"dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer"
)
delete_class = (
"px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 "
"rounded-e-lg hover:bg-red-100 hover:text-blue-700 focus:z-10 focus:ring-2 "
"focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 "
"dark:text-white dark:hover:text-white dark:hover:bg-red-700 "
"dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer"
)
edit_link = Component(
tag_name="a",
attributes=[("href", reverse("games:edit_game", args=[game.id]))],
children=[
Component(
tag_name="button",
attributes=[("type", "button"), ("class", edit_class)],
children=["Edit"],
)
],
)
delete_link = Component(
tag_name="a",
attributes=[
("href", "#"),
("hx-get", reverse("games:delete_game_confirmation", args=[game.id])),
("hx-target", "#global-modal-container"),
],
children=[
Component(
tag_name="button",
attributes=[("type", "button"), ("class", delete_class)],
children=["Delete"],
)
],
)
return Div(
[("class", "inline-flex rounded-md shadow-xs mb-3"), ("role", "group")],
[edit_link, delete_link],
)
def _game_history(statuschanges) -> SafeText:
items = []
for change in statuschanges:
if change.timestamp:
prefix = f"{date_filter(change.timestamp, 'd/m/Y H:i')}: Changed"
else:
prefix = "At some point changed"
old_status = GameStatus(
status=change.old_status or "u",
children=[change.get_old_status_display() if change.old_status else "-"],
)
new_status = GameStatus(
status=change.new_status,
children=[change.get_new_status_display()],
)
edit = Component(
tag_name="a",
attributes=[("href", reverse("games:edit_statuschange", args=[change.id]))],
children=["Edit"],
)
delete = Component(
tag_name="a",
attributes=[
("href", reverse("games:delete_statuschange", args=[change.id]))
],
children=["Delete"],
)
items.append(
Component(
tag_name="li",
attributes=[("class", "text-slate-500")],
children=[
f"{prefix} status from ",
old_status,
" to ",
new_status,
" (",
edit,
", ",
delete,
")",
],
)
)
return Component(
tag_name="ul",
attributes=[("class", "list-disc list-inside")],
children=items,
)
def _game_section(
title: str, count: int, table: SafeText, empty_message: str
) -> SafeText:
return Div(
[("class", "mb-6")],
[
H1(children=[title], badge=count),
table if count else empty_message,
],
)
@login_required
def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
game = Game.objects.get(id=game_id)
nongame_related_purchases_prefetch: Prefetch[Purchase] = Prefetch(
"related_purchases",
queryset=Purchase.objects.exclude(type=Purchase.GAME).order_by(
"date_purchased"
),
to_attr="nongame_related_purchases",
)
game_purchases_prefetch: Prefetch[Purchase] = Prefetch(
"purchases",
queryset=Purchase.objects.filter(type=Purchase.GAME).prefetch_related(
nongame_related_purchases_prefetch
),
to_attr="game_purchases",
)
purchases = game.purchases.order_by("date_purchased")
sessions = game.sessions
@@ -230,7 +552,6 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
playrange = "N/A"
latest_session = None
total_hours = float(format_duration(sessions.total_duration_unformatted(), "%2.1H"))
total_hours_without_manual = float(
format_duration(sessions.calculated_duration_unformatted(), "%2.1H")
)
@@ -251,7 +572,9 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
"color": "gray",
},
{
"href": reverse("games:delete_purchase", args=[purchase.pk]),
"href": reverse(
"games:delete_purchase", args=[purchase.pk]
),
"slot": Icon("delete"),
"color": "red",
},
@@ -349,55 +672,166 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
statuschanges = game.status_changes.all()
statuschange_count = statuschanges.count()
statuschange_data = {
"columns": [
"Old Status",
"New Status",
"Timestamp",
],
"rows": [
[
statuschange.get_old_status_display()
if statuschange.old_status
else "-",
statuschange.get_new_status_display(),
local_strftime(statuschange.timestamp, dateformat),
]
for statuschange in statuschanges
],
}
context: dict[str, Any] = {
"statuschange_data": statuschange_data,
"statuschange_count": statuschange_count,
"statuschanges": statuschanges,
"game": game,
"game_statuses": Game.Status.choices,
"playrange": playrange,
"purchase_count": game.purchases.count(),
"session_average_without_manual": round(
safe_division(
total_hours_without_manual, int(session_count_without_manual)
purchase_count = game.purchases.count()
status_selector_html = GameStatusSelector(
game, Game.Status.choices, get_token(request)
)
session_average_without_manual = round(
safe_division(total_hours_without_manual, int(session_count_without_manual)),
1,
)
grey_value_class = "text-black dark:text-slate-300"
title_span = Component(
tag_name="span",
attributes=[("class", "text-balance max-w-120 text-4xl")],
children=[
Component(
tag_name="span",
attributes=[("class", "font-bold font-serif")],
children=[game.name],
),
1,
]
+ (
[
mark_safe("&nbsp;"),
Popover(
popover_content="Original release year",
wrapped_classes="text-slate-500 text-2xl",
id="popover-year",
children=[str(game.year_released)],
),
]
if game.year_released
else []
),
"session_count": session_count,
"sessions": sessions,
"title": f"Game Overview - {game.name}",
"hours_sum": total_hours,
"purchase_data": purchase_data,
"playevent_data": playevent_data,
"playevent_count": playevent_count,
"session_data": session_data,
"session_page_obj": session_page_obj,
"session_elided_page_range": (
session_page_obj.paginator.get_elided_page_range(
page_number, on_each_side=1, on_ends=1
)
if session_page_obj and session_count > 5
else None
),
}
)
title_row = Div([("class", "flex gap-5 mb-3")], [title_span])
stats_row = Div(
[("class", "flex gap-4 dark:text-slate-400 mb-3")],
[
_stat_popover(
"popover-hours",
"Total hours played",
"hours",
game.playtime_formatted(),
),
_stat_popover(
"popover-sessions", "Number of sessions", "sessions", session_count
),
_stat_popover(
"popover-average",
"Average playtime per session",
"average",
session_average_without_manual,
),
_stat_popover(
"popover-playrange",
"Earliest and latest dates played",
"playrange",
playrange,
),
],
)
metadata = Div(
[("class", "flex flex-col mb-6 text-gray-600 dark:text-slate-400 gap-y-4")],
[
_meta_row(
"Original year",
Component(
tag_name="span",
attributes=[("class", grey_value_class)],
children=[str(game.original_year_released)],
),
),
_meta_row("Status", status_selector_html, "👑" if game.mastered else ""),
_played_row(game, request),
_meta_row(
"Platform",
Component(
tag_name="span",
attributes=[("class", grey_value_class)],
children=[str(game.platform)],
),
),
],
)
game_info = Div(
[("id", "game-info"), ("class", "mb-10")],
[title_row, stats_row, metadata, _game_action_buttons(game)],
)
session_elided_page_range = (
session_page_obj.paginator.get_elided_page_range(
page_number, on_each_side=1, on_ends=1
)
if session_page_obj and session_count > 5
else None
)
purchases_table = SimpleTable(
columns=purchase_data["columns"], rows=purchase_data["rows"]
)
sessions_table = SimpleTable(
columns=session_data["columns"],
rows=session_data["rows"],
header_action=session_data["header_action"],
page_obj=session_page_obj,
elided_page_range=session_elided_page_range,
request=request,
)
playevents_table = SimpleTable(
columns=playevent_data["columns"], rows=playevent_data["rows"]
)
history = Div(
[
("class", "mb-6"),
("id", "history-container"),
("hx-get", ""),
("hx-trigger", "status-changed from:body"),
("hx-select", "#history-container"),
("hx-swap", "outerHTML"),
],
[
H1(children=["History"], badge=statuschange_count),
_game_history(statuschanges),
],
)
content = Div(
[("class", "dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto")],
[
game_info,
_game_section(
"Purchases", purchase_count, purchases_table, "No purchases yet."
),
_game_section(
"Sessions", session_count, sessions_table, "No sessions yet."
),
_game_section(
"Play Events", playevent_count, playevents_table, "No play events yet."
),
history,
mark_safe(
"<script>\n"
" function getSessionCount() {\n"
" return document.getElementById('session-count')"
'.textContent.match("[0-9]+");\n'
" }\n"
" </script>"
),
],
)
request.session["return_path"] = request.path
return render(request, "view_game.html", context)
return render_page(
request,
content,
title=f"Game Overview - {game.name}",
mastered=game.mastered,
)
+42 -42
View File
@@ -2,17 +2,31 @@ from datetime import datetime, timedelta
from typing import Any, Callable
from django.contrib.auth.decorators import login_required
from django.db.models import Avg, Count, ExpressionWrapper, F, Max, OuterRef, Prefetch, Q, Subquery, Sum, fields
from django.db.models import (
Avg,
Count,
ExpressionWrapper,
F,
Max,
OuterRef,
Prefetch,
Q,
Subquery,
Sum,
fields,
)
from django.db.models.functions import TruncDate, TruncMonth
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import redirect, render
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.timezone import now as timezone_now
from common.layout import render_page
from common.time import available_stats_year_range, dateformat, format_duration
from common.utils import safe_division
from games.models import Game, Platform, Purchase, Session
from games.views.stats_content import stats_content
def model_counts(request: HttpRequest) -> dict[str, bool]:
@@ -90,15 +104,12 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
this_year_purchases = Purchase.objects.all()
this_year_purchases_with_currency = this_year_purchases.select_related("games")
this_year_purchases_without_refunded = Purchase.objects.filter(
date_refunded=None
)
this_year_purchases_without_refunded = Purchase.objects.filter(date_refunded=None)
this_year_purchases_refunded = Purchase.objects.refunded()
this_year_purchases_unfinished_dropped_nondropped = (
this_year_purchases_without_refunded.filter(
~Q(games__status="f")
& ~Q(games__playevents__ended__isnull=False)
~Q(games__status="f") & ~Q(games__playevents__ended__isnull=False)
)
.filter(infinite=False)
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
@@ -106,14 +117,12 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
this_year_purchases_unfinished = (
this_year_purchases_unfinished_dropped_nondropped.filter(
~Q(games__status="r")
& ~Q(games__status="a")
~Q(games__status="r") & ~Q(games__status="a")
)
)
this_year_purchases_dropped = (
this_year_purchases.filter(
~Q(games__status="f")
& ~Q(games__playevents__ended__isnull=False)
~Q(games__status="f") & ~Q(games__playevents__ended__isnull=False)
)
.filter(Q(games__status="a") | Q(date_refunded__isnull=False))
.filter(infinite=False)
@@ -144,27 +153,18 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
purchases_finished_this_year_released_this_year = _finished_with_date.order_by(
"-date_finished"
)
purchased_this_year_finished_this_year = (
this_year_purchases_without_refunded.filter(pk__in=_finished_purchases_qs.values("pk"))
.annotate(
date_finished=Subquery(
Purchase.objects.filter(pk=OuterRef("pk"))
.annotate(max_ended=Max("games__playevents__ended"))
.values("max_ended")[:1]
)
)
).order_by("-date_finished")
this_year_spendings = this_year_purchases_without_refunded.aggregate(
total_spent=Sum(F("converted_price"))
)
total_spent = this_year_spendings["total_spent"] or 0
games_with_playtime = Game.objects.filter(
sessions__in=this_year_sessions
).distinct().annotate(
total_playtime=Sum(F("sessions__duration_total"))
).filter(total_playtime__gt=timedelta(0))
games_with_playtime = (
Game.objects.filter(sessions__in=this_year_sessions)
.distinct()
.annotate(total_playtime=Sum(F("sessions__duration_total")))
.filter(total_playtime__gt=timedelta(0))
)
month_playtimes = (
this_year_sessions.annotate(month=TruncMonth("timestamp_start"))
.values("month")
@@ -190,9 +190,7 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
.order_by("-playtime")
)
backlog_decrease_count = (
purchases_finished_this_year.count()
)
backlog_decrease_count = purchases_finished_this_year.count()
first_play_date = "N/A"
last_play_date = "N/A"
@@ -277,14 +275,16 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
}
request.session["return_path"] = request.path
return render(request, "stats.html", context)
return render_page(request, stats_content(context), title=context["title"])
@login_required
def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
selected_year = request.GET.get("year")
if selected_year:
return HttpResponseRedirect(reverse("games:stats_by_year", args=[selected_year]))
return HttpResponseRedirect(
reverse("games:stats_by_year", args=[selected_year])
)
if year == 0:
return HttpResponseRedirect(reverse("games:stats_alltime"))
this_year_sessions = Session.objects.filter(
@@ -338,8 +338,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
# only Game and DLC
this_year_purchases_unfinished_dropped_nondropped = (
this_year_purchases_without_refunded.filter(
~Q(games__status="f")
& ~Q(games__playevents__ended__year=year)
~Q(games__status="f") & ~Q(games__playevents__ended__year=year)
)
.filter(infinite=False)
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
@@ -348,15 +347,13 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
# unfinished = not finished AND not dropped
this_year_purchases_unfinished = (
this_year_purchases_unfinished_dropped_nondropped.filter(
~Q(games__status="r")
& ~Q(games__status="a")
~Q(games__status="r") & ~Q(games__status="a")
)
)
# dropped = abandoned OR retired OR refunded (OR logic for transition)
this_year_purchases_dropped = (
this_year_purchases.filter(
~Q(games__status="f")
& ~Q(games__playevents__ended__year=year)
~Q(games__status="f") & ~Q(games__playevents__ended__year=year)
)
.filter(Q(games__status="a") | Q(date_refunded__isnull=False))
.filter(infinite=False)
@@ -375,9 +372,13 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
* 100
)
purchases_finished_this_year = Purchase.objects.finished().filter(
games__playevents__ended__year=year
).annotate(game_name=F("games__name"), date_finished=F("games__playevents__ended"))
purchases_finished_this_year = (
Purchase.objects.finished()
.filter(games__playevents__ended__year=year)
.annotate(
game_name=F("games__name"), date_finished=F("games__playevents__ended")
)
)
purchases_finished_this_year_released_this_year = (
purchases_finished_this_year.filter(games__year_released=year).order_by(
"games__playevents__ended"
@@ -472,7 +473,6 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
"total_playtime_per_platform": total_playtime_per_platform,
"total_spent": total_spent,
"total_spent_currency": selected_currency,
"all_purchased_this_year": this_year_purchases_without_refunded,
"spent_per_game": int(
safe_division(total_spent, this_year_purchases_without_refunded_count)
),
@@ -539,7 +539,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
}
request.session["return_path"] = request.path
return render(request, "stats.html", context)
return render_page(request, stats_content(context), title=context["title"])
@login_required
+61 -56
View File
@@ -1,12 +1,18 @@
from typing import Any
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from common.components import A, Button, ButtonGroup, Icon
from common.components import (
A,
AddForm,
Button,
ButtonGroup,
Icon,
paginated_table_content,
)
from common.layout import render_page
from common.time import dateformat, local_strftime
from games.forms import PlatformForm
from games.models import Platform
@@ -15,7 +21,6 @@ from games.views.general import use_custom_redirect
@login_required
def list_platforms(request: HttpRequest) -> HttpResponse:
context: dict[Any, Any] = {}
page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10)
platforms = Platform.objects.order_by("name")
@@ -24,52 +29,56 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
paginator = Paginator(platforms, limit)
page_obj = paginator.get_page(page_number)
platforms = page_obj.object_list
elided_page_range = (
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
if page_obj
else None
)
context = {
"title": "Manage platforms",
"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 platform"), url_name="games:add_platform"
),
"data": {
"header_action": A([], Button([], "Add platform"), url_name="games:add_platform"),
"columns": [
"Name",
"Icon",
"Group",
"Created",
"Actions",
],
"rows": [
[
platform.name,
Icon(platform.icon),
platform.group,
local_strftime(platform.created_at, dateformat),
ButtonGroup(
[
{
"href": reverse("games:edit_platform", args=[platform.pk]),
"slot": Icon("edit"),
"color": "gray",
},
{
"href": reverse("games:delete_platform", args=[platform.pk]),
"slot": Icon("delete"),
"color": "red",
},
]
),
]
for platform in platforms
],
},
"columns": [
"Name",
"Icon",
"Group",
"Created",
"Actions",
],
"rows": [
[
platform.name,
Icon(platform.icon),
platform.group,
local_strftime(platform.created_at, dateformat),
ButtonGroup(
[
{
"href": reverse("games:edit_platform", args=[platform.pk]),
"slot": Icon("edit"),
"color": "gray",
},
{
"href": reverse(
"games:delete_platform", args=[platform.pk]
),
"slot": Icon("delete"),
"color": "red",
},
]
),
]
for platform in platforms
],
}
return render(request, "list_purchases.html", context)
content = paginated_table_content(
data,
page_obj=page_obj,
elided_page_range=elided_page_range,
request=request,
)
return render_page(request, content, title="Manage platforms")
@login_required
@@ -82,25 +91,21 @@ def delete_platform(request: HttpRequest, platform_id: int) -> HttpResponse:
@login_required
@use_custom_redirect
def edit_platform(request: HttpRequest, platform_id: int) -> HttpResponse:
context = {}
platform = get_object_or_404(Platform, id=platform_id)
form = PlatformForm(request.POST or None, instance=platform)
if form.is_valid():
form.save()
return redirect("games:list_platforms")
context["title"] = "Edit Platform"
context["form"] = form
return render(request, "add.html", context)
return render_page(request, AddForm(form, request=request), title="Edit Platform")
@login_required
def add_platform(request: HttpRequest) -> HttpResponse:
context: dict[str, Any] = {}
form = PlatformForm(request.POST or None)
if form.is_valid():
form.save()
return redirect("games:index")
context["form"] = form
context["title"] = "Add New Platform"
return render(request, "add.html", context)
return render_page(
request, AddForm(form, request=request), title="Add New Platform"
)
+33 -24
View File
@@ -7,10 +7,18 @@ from django.core.paginator import Paginator
from django.db.models import QuerySet
from django.db.models.manager import BaseManager
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.shortcuts import get_object_or_404
from django.urls import reverse
from common.components import A, Button, ButtonGroup, Icon
from common.components import (
A,
AddForm,
Button,
ButtonGroup,
Icon,
paginated_table_content,
)
from common.layout import render_page
from common.time import dateformat, format_duration, local_strftime
from games.forms import PlayEventForm
from games.models import Game, PlayEvent, Session
@@ -74,7 +82,9 @@ def create_playevent_tabledata(
for row in row_list
]
return {
"header_action": A([], Button([], "Add play event"), url_name="games:add_playevent"),
"header_action": A(
[], Button([], "Add play event"), url_name="games:add_playevent"
),
"columns": list(filtered_column_list),
"rows": filtered_row_list,
}
@@ -123,19 +133,19 @@ def list_playevents(request: HttpRequest) -> HttpResponse:
paginator = Paginator(playevents, limit)
page_obj = paginator.get_page(page_number)
playevents = page_obj.object_list
context: dict[str, Any] = {
"title": "Manage play events",
"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": create_playevent_tabledata(playevents, request=request),
}
return render(request, "list_playevents.html", context)
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 = create_playevent_tabledata(playevents, request=request)
content = paginated_table_content(
data,
page_obj=page_obj,
elided_page_range=elided_page_range,
request=request,
)
return render_page(request, content, title="Manage play events")
@login_required
@@ -192,22 +202,21 @@ def add_playevent(request: HttpRequest, game_id: int = 0) -> HttpResponse:
game_id = form.instance.game.id
return HttpResponseRedirect(reverse("games:view_game", args=[game_id]))
return render(request, "add.html", {"form": form, "title": "Add new playthrough"})
return render_page(
request, AddForm(form, request=request), title="Add new playthrough"
)
def edit_playevent(request: HttpRequest, playevent_id: int) -> HttpResponse:
context: dict[str, Any] = {}
playevent = get_object_or_404(PlayEvent, id=playevent_id)
form = PlayEventForm(request.POST or None, instance=playevent)
if form.is_valid():
form.save()
return HttpResponseRedirect(reverse("games:view_game", args=[playevent.game.id]))
return HttpResponseRedirect(
reverse("games:view_game", args=[playevent.game.id])
)
context = {
"form": form,
"title": "Edit Play Event",
}
return render(request, "add.html", context)
return render_page(request, AddForm(form, request=request), title="Edit Play Event")
def delete_playevent(request: HttpRequest, playevent_id: int) -> HttpResponse:
+203 -53
View File
@@ -1,5 +1,3 @@
from typing import Any
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
@@ -8,12 +6,34 @@ from django.http import (
HttpResponse,
HttpResponseRedirect,
)
from django.shortcuts import get_object_or_404, redirect, render
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.http import require_POST
from common.components import A, Button, ButtonGroup, Icon, LinkedPurchase, PurchasePrice, TableRow
from django.template.defaultfilters import date as date_filter
from django.template.defaultfilters import floatformat
from django.utils.safestring import SafeText
from common.components import (
A,
AddForm,
Button,
ButtonGroup,
Component,
CsrfInput,
Div,
GameLink,
Icon,
LinkedPurchase,
Modal,
ModuleScript,
PriceConverted,
PurchasePrice,
TableRow,
paginated_table_content,
)
from common.layout import render_page
from common.time import dateformat
from games.forms import PurchaseForm
from games.models import Game, Purchase
@@ -75,7 +95,6 @@ def _render_purchase_row(purchase):
@login_required
def list_purchases(request: HttpRequest) -> HttpResponse:
context: dict[Any, Any] = {}
page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10)
purchases = Purchase.objects.order_by("-date_purchased", "-created_at")
@@ -84,38 +103,61 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
paginator = Paginator(purchases, limit)
page_obj = paginator.get_page(page_number)
purchases = page_obj.object_list
elided_page_range = (
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
if page_obj
else None
)
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_name="games:add_purchase"
),
"data": {
"header_action": A([], Button([], "Add purchase"), url_name="games:add_purchase"),
"columns": [
"Name",
"Type",
"Price",
"Infinite",
"Purchased",
"Refunded",
"Created",
"Actions",
],
"rows": [_render_purchase_row(purchase) for purchase in purchases],
},
"columns": [
"Name",
"Type",
"Price",
"Infinite",
"Purchased",
"Refunded",
"Created",
"Actions",
],
"rows": [_render_purchase_row(purchase) for purchase in purchases],
}
return render(request, "list_purchases.html", context)
content = paginated_table_content(
data,
page_obj=page_obj,
elided_page_range=elided_page_range,
request=request,
)
return render_page(request, content, title="Manage purchases")
def _purchase_additional_row() -> SafeText:
"""The 'Submit & Create Session' row shown below the main Submit button."""
return Component(
tag_name="tr",
children=[
Component(tag_name="td"),
Component(
tag_name="td",
children=[
Button(
[],
"Submit & Create Session",
color="gray",
type="submit",
name="submit_and_redirect",
)
],
),
],
)
@login_required
def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
context: dict[str, Any] = {}
initial = {"date_purchased": timezone.now()}
if request.method == "POST":
@@ -144,26 +186,28 @@ def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
else:
form = PurchaseForm(initial=initial)
context["form"] = form
context["title"] = "Add New Purchase"
context["script_name"] = "add_purchase.js"
return render(request, "add_purchase.html", context)
return render_page(
request,
AddForm(form, request=request, additional_row=_purchase_additional_row()),
title="Add New Purchase",
scripts=ModuleScript("add_purchase.js"),
)
@login_required
@use_custom_redirect
def edit_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
context = {}
purchase = get_object_or_404(Purchase, id=purchase_id)
form = PurchaseForm(request.POST or None, instance=purchase)
if form.is_valid():
form.save()
return redirect("games:list_sessions")
context["title"] = "Edit Purchase"
context["form"] = form
context["purchase_id"] = str(purchase_id)
context["script_name"] = "add_purchase.js"
return render(request, "add_purchase.html", context)
return render_page(
request,
AddForm(form, request=request, additional_row=_purchase_additional_row()),
title="Edit Purchase",
scripts=ModuleScript("add_purchase.js"),
)
@login_required
@@ -173,13 +217,67 @@ def delete_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
return redirect("games:list_purchases")
def _view_purchase_content(purchase: Purchase) -> SafeText:
first_game = purchase.first_game
owned = f"Owned on {date_filter(purchase.date_purchased, 'd/m/Y')}"
if purchase.date_refunded:
owned += f" (refunded {date_filter(purchase.date_refunded, 'd/m/Y')})"
row_class = "text-slate-500 text-xl"
inner = Div(
[("class", "flex flex-col gap-5 mb-3")],
[
Div(
[("class", "font-bold font-serif text-slate-500 text-2xl")],
[
A(
[],
first_game.name,
href=reverse("games:view_game", args=[first_game.id]),
)
],
),
Div([("class", row_class)], [purchase.get_type_display()]),
Div([("class", row_class)], [owned]),
Div(
[("class", row_class)], [PriceConverted([purchase.standardized_price])]
),
Div(
[("class", row_class)],
[
Component(
tag_name="p",
children=[
"Price per game: ",
PriceConverted([floatformat(purchase.price_per_game, 0)]),
f" {purchase.converted_currency}",
],
)
],
),
Div([("class", row_class)], ["Games included in this purchase:"]),
Component(
tag_name="ul",
children=[
Component(tag_name="li", children=[GameLink(game.id, game.name)])
for game in purchase.games.all()
],
),
],
)
return Div(
[("class", "dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto")],
[inner],
)
@login_required
def view_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
purchase = get_object_or_404(Purchase, id=purchase_id)
return render(
return render_page(
request,
"view_purchase.html",
{"purchase": purchase, "title": f"Purchase: {purchase.full_name}"},
_view_purchase_content(purchase),
title=f"Purchase: {purchase.full_name}",
)
@@ -192,15 +290,70 @@ def drop_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
return redirect("games:list_purchases")
def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> SafeText:
form = Component(
tag_name="form",
attributes=[
("hx-post", reverse("games:refund_purchase", args=[purchase_id])),
("hx-target", f"#purchase-row-{purchase_id}"),
("hx-swap", "outerHTML"),
],
children=[
CsrfInput(request),
Component(
tag_name="p",
attributes=[("class", "dark:text-white text-center mt-3 text-sm")],
children=["Games will be marked as abandoned."],
),
Div(
[("class", "items-center mt-5")],
[
Button(
[("class", "w-full")],
"Refund",
color="blue",
size="lg",
type="submit",
),
Button(
[("class", "mt-0 w-full")],
"Cancel",
color="gray",
size="base",
onclick="this.closest('#refund-confirmation-modal').remove()",
),
],
),
],
)
return Modal(
"refund-confirmation-modal",
children=[
Component(
tag_name="h1",
attributes=[
(
"class",
"text-2xl leading-6 font-medium dark:text-white text-center",
)
],
children=["Confirm Refund"],
),
Component(
tag_name="p",
attributes=[("class", "dark:text-white text-center mt-5")],
children=["Are you sure you want to mark this purchase as refunded?"],
),
form,
],
)
@login_required
def refund_purchase_confirmation(
request: HttpRequest, purchase_id: int
) -> HttpResponse:
return render(
request,
"partials/refund_purchase_confirmation.html",
{"purchase_id": purchase_id},
)
return HttpResponse(_refund_confirmation_modal(purchase_id, request))
@login_required
@@ -233,9 +386,7 @@ def finish_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
def related_purchase_by_game(request: HttpRequest) -> HttpResponse:
games: list[str] = []
games = request.GET.getlist("games")
context = {}
games: list[str] = request.GET.getlist("games")
if games:
form = PurchaseForm()
qs = Purchase.objects.filter(games__in=games, type=Purchase.GAME).order_by(
@@ -246,8 +397,7 @@ def related_purchase_by_game(request: HttpRequest) -> HttpResponse:
first_option = qs.first()
if first_option:
form.fields["related_purchase"].initial = first_option.id
context["form"] = form
return render(request, "partials/related_purchase_field.html", context)
return HttpResponse(str(form["related_purchase"]))
else:
# abort swap
return HttpResponse(status=204)
+266 -143
View File
@@ -4,21 +4,29 @@ from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models 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.middleware.csrf import get_token
from django.shortcuts import get_object_or_404, redirect
from django.template.defaultfilters import date as date_filter
from django.urls import reverse
from django.utils import timezone
from django.utils.safestring import SafeText, mark_safe
from common.components import (
A,
AddForm,
Button,
ButtonGroup,
Component,
Div,
Icon,
SearchField,
ModuleScript,
NameWithIcon,
Popover,
SearchField,
SessionDeviceSelector,
paginated_table_content,
)
from common.layout import render_page
from common.time import (
dateformat,
local_strftime,
@@ -31,7 +39,6 @@ from games.models import Device, Game, Session
@login_required
def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse:
context: dict[Any, Any] = {}
page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10)
sessions = Session.objects.order_by("-timestamp_start", "created_at")
@@ -55,120 +62,115 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
page_obj = paginator.get_page(page_number)
sessions = page_obj.object_list
context = {
"title": "Manage sessions",
"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": Div(
children=[
SearchField(search_string=search_string),
Div(
children=[
A(
url_name="games:add_session",
children=Button(
icon=True,
size="xs",
children=[Icon("play"), "LOG"],
),
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": Div(
children=[
SearchField(search_string=search_string),
Div(
children=[
A(
url_name="games:add_session",
children=Button(
icon=True,
size="xs",
children=[Icon("play"), "LOG"],
),
A(
href=reverse(
"games:list_sessions_start_session_from_session",
args=[last_session.pk],
),
A(
href=reverse(
"games:list_sessions_start_session_from_session",
args=[last_session.pk],
),
children=Popover(
popover_content=last_session.game.name,
children=[
Button(
icon=True,
color="gray",
size="xs",
children=[
Icon("play"),
truncate(f"{last_session.game.name}"),
],
)
],
),
)
if last_session
else "",
]
),
],
attributes=[("class", "flex justify-between")],
),
"columns": [
"Name",
"Date",
"Duration",
"Device",
"Created",
"Actions",
],
"rows": [
{
"row_id": f"session-row-{session.pk}",
"hx_trigger": "device-changed from:body",
"hx_get": "",
"hx_select": f"#session-row-{session.pk}",
"hx_swap": "outerHTML",
"cell_data": [
NameWithIcon(session=session),
f"{local_strftime(session.timestamp_start)}{f'{local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
session.duration_formatted_with_mark(),
SessionDeviceSelector(session, device_list, get_token(request)),
session.created_at.strftime(dateformat),
ButtonGroup(
[
{
"href": reverse(
"games:list_sessions_end_session", args=[session.pk]
),
children=Popover(
popover_content=last_session.game.name,
children=[
Button(
icon=True,
color="gray",
size="xs",
children=[
Icon("play"),
truncate(f"{last_session.game.name}"),
],
)
],
"slot": Icon("end"),
"title": "Finish session now",
"color": "green",
}
if session.timestamp_end is None
else {},
{
"href": reverse(
"games:edit_session", args=[session.pk]
),
)
if last_session
else "",
"slot": Icon("edit"),
"title": "Edit",
},
{
"href": reverse(
"games:delete_session", args=[session.pk]
),
"slot": Icon("delete"),
"title": "Delete",
"color": "red",
},
]
),
],
attributes=[("class", "flex justify-between")],
),
"columns": [
"Name",
"Date",
"Duration",
"Device",
"Created",
"Actions",
],
"rows": [
{
"row_id": f"session-row-{session.pk}",
"hx_trigger": "device-changed from:body",
"hx_get": "",
"hx_select": f"#session-row-{session.pk}",
"hx_swap": "outerHTML",
"cell_data": [
NameWithIcon(session=session),
f"{local_strftime(session.timestamp_start)}{f'{local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
session.duration_formatted_with_mark(),
render_to_string(
"partials/sessiondevice_selector.html",
{
"session": session,
"session_device": session.device,
"session_devices": device_list,
},
request=request,
),
session.created_at.strftime(dateformat),
ButtonGroup(
[
{
"href": reverse(
"games:list_sessions_end_session", args=[session.pk]
),
"slot": Icon("end"),
"title": "Finish session now",
"color": "green",
}
if session.timestamp_end is None
else {},
{
"href": reverse("games:edit_session", args=[session.pk]),
"slot": Icon("edit"),
"title": "Edit",
},
{
"href": reverse(
"games:delete_session", args=[session.pk]
),
"slot": Icon("delete"),
"title": "Delete",
"color": "red",
},
]
),
],
}
for session in sessions
],
},
}
for session in sessions
],
}
return render(request, "list_purchases.html", context)
content = paginated_table_content(
data,
page_obj=page_obj,
elided_page_range=elided_page_range,
request=request,
)
return render_page(request, content, title="Manage sessions")
@login_required
@@ -176,13 +178,60 @@ def search_sessions(request: HttpRequest) -> HttpResponse:
return list_sessions(request, search_string=request.GET.get("search_string", ""))
def _session_fields(form) -> SafeText:
"""Manual per-field layout for the session form.
Mirrors the old add_session.html: each field gets its label and widget,
and the timestamp fields gain a row of now/toggle/copy helper buttons.
"""
rows: list[SafeText] = []
for field in form:
children: list[SafeText | str] = [
mark_safe(str(field.label_tag())),
mark_safe(str(field)),
]
if field.name in ("timestamp_start", "timestamp_end"):
this_side = "start" if field.name == "timestamp_start" else "end"
other_side = "end" if field.name == "timestamp_start" else "start"
children.append(
Component(
tag_name="span",
attributes=[
(
"class",
"form-row-button-group flex-row gap-3 justify-start mt-3",
),
("hx-boost", "false"),
],
children=[
Button(
[("data-target", field.name), ("data-type", "now")],
"Set to now",
size="xs",
),
Button(
[("data-target", field.name), ("data-type", "toggle")],
"Toggle text",
size="xs",
),
Button(
[("data-target", field.name), ("data-type", "copy")],
f"Copy {this_side} value to {other_side}",
size="xs",
),
],
)
)
rows.append(Div(children=children))
return mark_safe("\n".join(rows))
@login_required
def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
context = {}
initial: dict[str, Any] = {"timestamp_start": timezone.now()}
last = Session.objects.last()
if last != None:
if last is not None:
initial["game"] = last.game
if request.method == "POST":
@@ -202,25 +251,116 @@ def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
else:
form = SessionForm(initial=initial)
context["title"] = "Add New Session"
# TODO: re-add custom buttons #91
context["script_name"] = "add_session.js"
context["form"] = form
return render(request, "add_session.html", context)
return render_page(
request,
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
title="Add New Session",
scripts=ModuleScript("add_session.js"),
)
@login_required
def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
context = {}
session = get_object_or_404(Session, id=session_id)
form = SessionForm(request.POST or None, instance=session)
if form.is_valid():
form.save()
return redirect("games:list_sessions")
context["title"] = "Edit Session"
context["script_name"] = "add_session.js"
context["form"] = form
return render(request, "add_session.html", context)
return render_page(
request,
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
title="Edit Session",
scripts=ModuleScript("add_session.js"),
)
def _session_row_fragment(session: Session) -> SafeText:
"""A single session <tr> (the old list_sessions.html#session-row partial),
returned by the inline end/clone-session HTMX endpoints."""
name_link = Component(
tag_name="a",
attributes=[
(
"class",
"underline decoration-slate-500 sm:decoration-2 inline-block "
"truncate max-w-20char group-hover:absolute group-hover:max-w-none "
"group-hover:-top-8 group-hover:-left-6 group-hover:min-w-60 "
"group-hover:px-6 group-hover:py-3.5 group-hover:bg-purple-600 "
"group-hover:rounded-xs group-hover:outline-dashed "
"group-hover:outline-purple-400 group-hover:outline-4 "
"group-hover:decoration-purple-900 group-hover:text-purple-100",
),
("href", reverse("games:view_game", args=[session.game.id])),
],
children=[session.game.name],
)
name_td = Component(
tag_name="td",
attributes=[
(
"class",
"px-2 sm:px-4 md:px-6 md:py-2 purchase-name relative align-top "
"w-24 h-12 group",
)
],
children=[
Component(
tag_name="span",
attributes=[("class", "inline-block relative")],
children=[name_link],
)
],
)
start_td = Component(
tag_name="td",
attributes=[
("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell")
],
children=[date_filter(session.timestamp_start, "d/m/Y H:i")],
)
if not session.timestamp_end:
end_url = reverse("games:list_sessions_end_session", args=[session.id])
end_inner: SafeText | str = Component(
tag_name="a",
attributes=[
("href", end_url),
("hx-get", end_url),
("hx-target", "closest tr"),
("hx-swap", "outerHTML"),
("hx-indicator", "#indicator"),
(
"onClick",
"document.querySelector('#last-session-start')"
".classList.remove('invisible')",
),
],
children=[
Component(
tag_name="span",
attributes=[("class", "text-yellow-300")],
children=["Finish now?"],
)
],
)
elif session.duration_manual:
end_inner = "--"
else:
end_inner = date_filter(session.timestamp_end, "d/m/Y H:i")
end_td = Component(
tag_name="td",
attributes=[
("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell")
],
children=[end_inner],
)
duration_td = Component(
tag_name="td",
attributes=[("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono")],
children=[session.duration_formatted()],
)
return Component(tag_name="tr", children=[name_td, start_td, end_td, duration_td])
def clone_session_by_id(session_id: int) -> Session:
@@ -236,38 +376,21 @@ def clone_session_by_id(session_id: int) -> Session:
@login_required
def new_session_from_existing_session(
request: HttpRequest, session_id: int, template: str = ""
request: HttpRequest, session_id: int
) -> HttpResponse:
session = clone_session_by_id(session_id)
if request.htmx:
context = {
"session": session,
"session_count": int(request.GET.get("session_count", 0)) + 1,
}
return render(request, template, context)
return HttpResponse(_session_row_fragment(session))
return redirect("games:list_sessions")
@login_required
def end_session(
request: HttpRequest, session_id: int, template: str = ""
) -> HttpResponse:
def end_session(request: HttpRequest, session_id: int) -> HttpResponse:
session = get_object_or_404(Session, id=session_id)
session.timestamp_end = timezone.now()
session.save()
if request.htmx:
context = {
"session": session,
"session_count": request.GET.get("session_count", 0),
}
return render(request, template, context)
return redirect("games:list_sessions")
@login_required
def delete_session(request: HttpRequest, session_id: int = 0) -> HttpResponse:
session = get_object_or_404(Session, id=session_id)
session.delete()
return HttpResponse(_session_row_fragment(session))
return redirect("games:list_sessions")
+325
View File
@@ -0,0 +1,325 @@
"""Python builder for the stats page body (replaces stats.html).
Both stats views (`stats_alltime`-style and per-year) assemble a `context`
dict and pass it here. Optional sections are driven by `ctx.get(...)` exactly
like the old `{% if key %}` blocks: a missing or empty value hides the section.
"""
from django.template.defaultfilters import date as date_filter
from django.template.defaultfilters import floatformat
from django.utils.html import conditional_escape
from django.utils.safestring import SafeText, mark_safe
from common.components import Component, Div, GameLink
from common.time import durationformat, format_duration
_CELL = "px-2 sm:px-4 md:px-6 md:py-2"
_CELL_MONO = f"{_CELL} font-mono"
_NAME_TH = f"{_CELL} purchase-name truncate max-w-20char"
def _td(children, cls: str = _CELL_MONO) -> SafeText:
if not isinstance(children, list):
children = [children]
children = [c if isinstance(c, (str, SafeText)) else str(c) for c in children]
return Component(tag_name="td", attributes=[("class", cls)], children=children)
def _th(text: str, cls: str = _CELL) -> SafeText:
return Component(tag_name="th", attributes=[("class", cls)], children=[text])
def _tr(cells: list) -> SafeText:
return Component(tag_name="tr", children=cells)
def _kv(label, value) -> SafeText:
"""A label/value row: plain label cell + mono value cell."""
return _tr([_td(label, _CELL), _td(value)])
def _h1(title: str) -> SafeText:
return Component(
tag_name="h1",
attributes=[("class", "text-5xl text-center my-6")],
children=[title],
)
def _table(rows: list, thead: SafeText | None = None) -> SafeText:
children = []
if thead is not None:
children.append(thead)
children.append(Component(tag_name="tbody", children=rows))
return Component(
tag_name="table",
attributes=[("class", "responsive-table")],
children=children,
)
def _dur(value) -> str:
return format_duration(value, durationformat)
def _purchase_name(purchase) -> SafeText:
"""Mirror of the `purchase-name` partial in the old template."""
game_name = getattr(purchase, "game_name", None)
first_game = purchase.first_game
if purchase.type != "game":
name = game_name or purchase.name
link = GameLink(first_game.id, name)
suffix = f" ({first_game.name} {purchase.get_type_display()})"
return mark_safe(str(link) + conditional_escape(suffix))
name = game_name or first_game.name
return GameLink(first_game.id, name)
def _year_dropdown(year, year_range) -> SafeText:
options = []
for year_item in year_range or []:
attrs = [("value", str(year_item))]
if year == year_item:
attrs.append(("selected", True))
options.append(
Component(tag_name="option", attributes=attrs, children=[str(year_item)])
)
select = Component(
tag_name="select",
attributes=[
("name", "year"),
("id", "yearSelect"),
("onchange", "this.form.submit();"),
("class", "mx-2"),
],
children=options,
)
label = Component(
tag_name="label",
attributes=[
("class", "text-5xl text-center inline-block mb-10"),
("for", "yearSelect"),
],
children=["Stats for:"],
)
form = Component(
tag_name="form",
attributes=[("method", "get"), ("class", "text-center")],
children=[label, select],
)
return Div([("class", "flex justify-center items-center")], [form])
def _playtime_table(ctx) -> SafeText:
year = ctx.get("year")
rows = [
_kv("Hours", ctx.get("total_hours")),
_kv("Sessions", ctx.get("total_sessions")),
_kv(
"Days",
f"{ctx.get('unique_days')} ({ctx.get('unique_days_percent')}%)",
),
]
if ctx.get("total_games"):
rows.append(_kv("Games", ctx.get("total_games")))
rows.append(_kv(f"Games ({year})", ctx.get("total_year_games")))
if ctx.get("all_finished_this_year_count"):
rows.append(_kv("Finished", ctx.get("all_finished_this_year_count")))
rows.append(
_kv(f"Finished ({year})", ctx.get("this_year_finished_this_year_count"))
)
def _game_row(label, value, game):
return _tr(
[
_td(label, _CELL),
_td([str(value), " (", GameLink(game.id, game.name), ")"]),
]
)
longest_game = ctx.get("longest_session_game")
if longest_game and longest_game.id:
rows.append(
_game_row("Longest session", ctx.get("longest_session_time"), longest_game)
)
most_sessions_game = ctx.get("highest_session_count_game")
if most_sessions_game and most_sessions_game.id:
rows.append(
_game_row(
"Most sessions", ctx.get("highest_session_count"), most_sessions_game
)
)
avg_game = ctx.get("highest_session_average_game")
if avg_game and avg_game.id:
rows.append(
_game_row(
"Highest session average", ctx.get("highest_session_average"), avg_game
)
)
first_game = ctx.get("first_play_game")
if first_game and first_game.id:
rows.append(
_tr(
[
_td("First play", _CELL),
_td(
[
GameLink(first_game.id, first_game.name),
f" ({ctx.get('first_play_date')})",
]
),
]
)
)
last_game = ctx.get("last_play_game")
if last_game and last_game.id:
rows.append(
_tr(
[
_td("Last play", _CELL),
_td(
[
GameLink(last_game.id, last_game.name),
f" ({ctx.get('last_play_date')})",
]
),
]
)
)
return _table(rows)
def _purchases_table(ctx) -> SafeText:
rows = [
_kv("Total", ctx.get("all_purchased_this_year_count")),
_kv(
"Refunded",
f"{ctx.get('all_purchased_refunded_this_year_count')} "
f"({ctx.get('refunded_percent')}%)",
),
_kv(
"Dropped",
f"{ctx.get('dropped_count')} ({ctx.get('dropped_percentage')}%)",
),
_kv(
"Unfinished",
f"{ctx.get('purchased_unfinished_count')} "
f"({ctx.get('unfinished_purchases_percent')}%)",
),
_kv("Backlog Decrease", ctx.get("backlog_decrease_count")),
_kv(
f"Spendings ({ctx.get('total_spent_currency')})",
f"{floatformat(ctx.get('total_spent'))} "
f"({floatformat(ctx.get('spent_per_game'))}/game)",
),
]
return _table(rows)
def _two_col_table(header: str, items, name_key, value_fn) -> SafeText:
thead = Component(
tag_name="thead",
children=[_tr([_th(header), _th("Playtime")])],
)
rows = [_tr([_td(name_key(item)), _td(value_fn(item))]) for item in items]
return _table(rows, thead)
def _finished_table(purchases) -> SafeText:
thead = Component(
tag_name="thead",
children=[_tr([_th("Name", _NAME_TH), _th("Date")])],
)
rows = [
_tr([_td(_purchase_name(p)), _td(date_filter(p.date_finished, "d/m/Y"))])
for p in purchases
]
return _table(rows, thead)
def _priced_table(purchases, currency) -> SafeText:
thead = Component(
tag_name="thead",
children=[
_tr([_th("Name", _NAME_TH), _th(f"Price ({currency})"), _th("Date")])
],
)
rows = [
_tr(
[
_td(_purchase_name(p)),
_td(floatformat(p.converted_price)),
_td(date_filter(p.date_purchased, "d/m/Y")),
]
)
for p in purchases
]
return _table(rows, thead)
def stats_content(ctx: dict) -> SafeText:
year = ctx.get("year")
currency = ctx.get("total_spent_currency")
sections: list = [
_year_dropdown(year, ctx.get("stats_dropdown_year_range")),
_h1("Playtime"),
_playtime_table(ctx),
]
months = list(ctx.get("month_playtimes") or [])
if months:
sections.append(_h1("Playtime per month"))
month_rows = [
_kv(date_filter(m["month"], "F"), _dur(m["playtime"])) for m in months
]
sections.append(_table(month_rows))
sections += [
_h1("Purchases"),
_purchases_table(ctx),
_h1("Games by playtime"),
_two_col_table(
"Name",
ctx.get("top_10_games_by_playtime") or [],
lambda g: GameLink(g.id, g.name),
lambda g: _dur(g.total_playtime),
),
_h1("Platforms by playtime"),
_two_col_table(
"Platform",
ctx.get("total_playtime_per_platform") or [],
lambda item: item["platform_name"],
lambda item: _dur(item["playtime"]),
),
]
all_finished = list(ctx.get("all_finished_this_year") or [])
if all_finished:
sections += [_h1("Finished"), _finished_table(all_finished)]
year_finished = list(ctx.get("this_year_finished_this_year") or [])
if year_finished:
sections += [_h1(f"Finished ({year} games)"), _finished_table(year_finished)]
bought_finished = list(ctx.get("purchased_this_year_finished_this_year") or [])
if bought_finished:
sections += [
_h1(f"Bought and Finished ({year})"),
_finished_table(bought_finished),
]
unfinished = list(ctx.get("purchased_unfinished") or [])
if unfinished:
sections += [
_h1("Unfinished Purchases"),
_priced_table(unfinished, currency),
]
all_purchased = list(ctx.get("all_purchased_this_year") or [])
if all_purchased:
sections += [_h1("All Purchases"), _priced_table(all_purchased, currency)]
return Div(
[("class", "dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto")],
sections,
)
+117 -44
View File
@@ -1,57 +1,130 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import get_object_or_404
from django.urls import reverse_lazy
from django.views.generic import CreateView, DeleteView, ListView, UpdateView
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.safestring import SafeText
from common.components import (
A,
AddForm,
Button,
Component,
CsrfInput,
Div,
paginated_table_content,
)
from common.layout import render_page
from common.time import dateformat, local_strftime
from games.forms import GameStatusChangeForm
from games.models import GameStatusChange
class EditStatusChangeView(LoginRequiredMixin, UpdateView):
model = GameStatusChange
form_class = GameStatusChangeForm
template_name = "add.html"
context_object_name = "form"
def get_object(self, queryset=None):
return get_object_or_404(GameStatusChange, id=self.kwargs["statuschange_id"])
def get_success_url(self):
return reverse_lazy("games:list_platforms")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["title"] = "Edit Platform"
return context
@login_required
def add_statuschange(request: HttpRequest) -> HttpResponse:
form = GameStatusChangeForm(request.POST or None)
if form.is_valid():
obj = form.save()
return redirect("games:view_game", game_id=obj.game.id)
return render_page(
request, AddForm(form, request=request), title="Add status change"
)
class AddStatusChangeView(LoginRequiredMixin, CreateView):
model = GameStatusChange
form_class = GameStatusChangeForm
template_name = "add.html"
def get_success_url(self):
return reverse_lazy("games:view_game", kwargs={"pk": self.object.game.id})
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["title"] = "Add status change"
return context
@login_required
def edit_statuschange(request: HttpRequest, statuschange_id: int) -> HttpResponse:
statuschange = get_object_or_404(GameStatusChange, id=statuschange_id)
form = GameStatusChangeForm(request.POST or None, instance=statuschange)
if form.is_valid():
form.save()
return redirect("games:list_platforms")
return render_page(
request, AddForm(form, request=request), title="Edit status change"
)
class GameStatusChangeListView(LoginRequiredMixin, ListView):
model = GameStatusChange
template_name = "list_purchases.html"
context_object_name = "status_changes"
paginate_by = 10
@login_required
def list_statuschanges(request: HttpRequest) -> HttpResponse:
page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10)
statuschanges = GameStatusChange.objects.select_related("game").all()
page_obj = None
if int(limit) != 0:
paginator = Paginator(statuschanges, limit)
page_obj = paginator.get_page(page_number)
statuschanges = page_obj.object_list
elided_page_range = (
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
if page_obj
else None
)
def get_queryset(self):
return GameStatusChange.objects.select_related("game").all()
data = {
"header_action": None,
"columns": ["Game", "Old Status", "New Status", "Timestamp"],
"rows": [
[
sc.game.name,
sc.get_old_status_display() if sc.old_status else "-",
sc.get_new_status_display(),
local_strftime(sc.timestamp, dateformat) if sc.timestamp else "-",
]
for sc in statuschanges
],
}
content = paginated_table_content(
data,
page_obj=page_obj,
elided_page_range=elided_page_range,
request=request,
)
return render_page(request, content, title="Status changes")
class GameStatusChangeDeleteView(LoginRequiredMixin, DeleteView):
model = GameStatusChange
template_name = "gamestatuschange_confirm_delete.html"
def _delete_statuschange_content(statuschange, request: HttpRequest) -> SafeText:
inner = Div(
[],
[
Component(
tag_name="p",
children=["Are you sure you want to delete this status change?"],
),
Button(
[("class", "w-full")], "Delete", color="red", type="submit", size="lg"
),
A(
[("class", "")],
Button([("class", "w-full")], "Cancel", color="gray"),
href=reverse("games:view_game", args=[statuschange.game.id]),
),
],
)
form = Component(
tag_name="form",
attributes=[("method", "post"), ("class", "dark:text-white")],
children=[CsrfInput(request), inner],
)
return Div(
[
(
"class",
"2xl:max-w-(--breakpoint-2xl) xl:max-w-(--breakpoint-xl) "
"md:max-w-(--breakpoint-md) sm:max-w-(--breakpoint-sm) self-center",
)
],
[form],
)
def get_success_url(self):
return reverse_lazy("games:view_game", kwargs={"game_id": self.object.game.id})
@login_required
def delete_statuschange(request: HttpRequest, pk: int) -> HttpResponse:
statuschange = get_object_or_404(GameStatusChange, id=pk)
if request.method == "POST":
game_id = statuschange.game.id
statuschange.delete()
return redirect("games:view_game", game_id=game_id)
return render_page(
request,
_delete_statuschange_content(statuschange, request),
title="Delete status change",
)