Move from HTML templates to pure Python
Remove cruft
This commit is contained in:
@@ -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
@@ -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
@@ -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(" "),
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user