from typing import Any
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, HttpResponseRedirect
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.safestring import SafeText
from common.components import (
H1,
A,
AddForm,
ButtonGroup,
CsrfInput,
Div,
Element,
FilterBar,
Fragment,
GameStatus,
GameStatusSelector,
Icon,
LinkedPurchase,
Modal,
ModuleScript,
NameWithIcon,
Node,
Popover,
PopoverTruncated,
PurchasePrice,
Safe,
SearchField,
SimpleTable,
StyledButton,
Ul,
paginated_table_content,
)
from common.components.primitives import Li, P, Span, Strong
from common.layout import render_page
from common.time import (
dateformat,
format_duration,
local_strftime,
timeformat,
)
from common.utils import build_dynamic_filter, paginate, safe_division, truncate
from games.filters import parse_game_filter
from games.forms import GameForm
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:
games = Game.objects.order_by("-created_at")
# ── Structured filter (Stash-style JSON) ──
filter_json = request.GET.get("filter", "")
if filter_json:
game_filter = parse_game_filter(filter_json)
if game_filter is not None:
games = games.filter(game_filter.to_q())
else:
# ── Legacy free-text search ──
search_string = request.GET.get("search_string", search_string)
if search_string != "":
filters = [
Q(name__icontains=search_string),
Q(sort_name__icontains=search_string),
Q(platform__name__icontains=search_string),
]
try:
year_value = int(search_string)
except ValueError:
year_value = None
if year_value:
filters.append(Q(year_released=year_value))
search_string_parts = search_string.split()
if len(search_string_parts) == 1:
if search_string.title() in Game.Status.labels:
search_status = Game.Status[search_string.upper()]
filters.append(Q(status=search_status))
games = games.filter(build_dynamic_filter(filters, "|"))
games, page_obj, elided_page_range = paginate(request, games)
data = {
"header_action": Div(
class_="flex justify-between",
)[
SearchField(search_string=search_string),
A(href=reverse("games:add_game"))[StyledButton()["Add game"]],
],
"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(
[
{
"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
],
}
content = paginated_table_content(
data,
page_obj=page_obj,
elided_page_range=elided_page_range,
request=request,
)
# Prepend the filter bar above the table
filter_bar = FilterBar(
filter_json=filter_json,
preset_list_url=reverse("games:list_presets"),
preset_save_url=reverse("games:save_preset"),
)
content = Fragment(filter_bar, content)
return render_page(
request,
content,
title="Manage games",
)
@login_required
def add_game(request: HttpRequest) -> HttpResponse:
form = GameForm(request.POST or None)
if form.is_valid():
game = form.save()
if "submit_and_redirect" in request.POST:
return HttpResponseRedirect(
reverse("games:add_purchase_for_game", kwargs={"game_id": game.id})
)
else:
return redirect("games:list_games")
return render_page(
request,
AddForm(
form,
request=request,
additional_row=StyledButton(
[],
"Submit & Create Purchase",
color="gray",
type="submit",
name="submit_and_redirect",
),
),
title="Add New Game",
scripts=ModuleScript("search_select.js") + 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(Li(children=[f"{session_count} session(s)"]))
if purchase_count:
data_items.append(Li(children=[f"{purchase_count} purchase(s)"]))
if playevent_count:
data_items.append(Li(children=[f"{playevent_count} play event(s)"]))
if not (session_count or purchase_count or playevent_count):
data_items.append(Li(children=["No associated data"]))
form = Element(
"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),
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:"
],
),
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,
),
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")],
[
StyledButton(
[("class", "w-full")],
"Delete",
color="red",
size="lg",
type="submit",
),
StyledButton(
[("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=[
P(
attributes=[
(
"class",
"text-2xl leading-6 font-medium dark:text-white text-center",
)
],
children=["Delete Game"],
),
P(
attributes=[("class", "dark:text-white text-center mt-5")],
children=[
"Are you sure you want to delete ",
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)
return HttpResponse(
_delete_game_confirmation_modal(
game,
game.sessions.count(),
game.purchases.count(),
game.playevents.count(),
request,
)
)
@login_required
def delete_game(request: HttpRequest, game_id: int) -> HttpResponse:
game = get_object_or_404(Game, id=game_id)
game.delete()
return redirect("games:list_sessions")
@login_required
@use_custom_redirect
def edit_game(request: HttpRequest, game_id: int) -> HttpResponse:
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")
return render_page(
request,
AddForm(form, request=request),
title="Edit Game",
scripts=ModuleScript("search_select.js"),
)
# --- view_game content builders -------------------------------------------
_STAT_SVGS = {
"hours": '',
"sessions": '',
"average": '',
"playrange": '',
}
_PLAYED_BTN = (
"px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 "
"hover:bg-gray-100 hover:text-blue-700 dark:bg-gray-800 dark:border-gray-700 "
"dark:text-white dark:hover:bg-gray-700 hover:cursor-pointer"
)
_PLAYED_MENU = (
"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"
)
def _played_row(game: Game, request: HttpRequest) -> Node:
"""'Played N times' control as a custom element (ts/elements/play-event-row.ts)."""
from common.components import Element
from common.components.custom_elements import _PlayEventRow
from common.components.primitives import Button
played: int = 0
played = game.playevents.count()
count_button = A(href=reverse("games:add_playevent"))[
Button(class_=_PLAYED_BTN + " rounded-s-lg")[
Span(data_count="")[str(played)], " times"
]
]
menu = Div(data_menu="", hidden=True, class_=_PLAYED_MENU)[
Ul()[
Li(class_="px-4 py-2")[
A(href=reverse("games:add_playevent_for_game", args=[game.id]))[
"Add playthrough..."
]
],
Li(class_="px-4 py-2 cursor-pointer")[
Element(
"button",
[("type", "button"), ("data-add-play", "")],
children=["Played times +1"],
)
],
]
]
toggle = Element(
"button",
[
("type", "button"),
("data-toggle", ""),
("class", _PLAYED_BTN + " rounded-e-lg"),
],
[Icon("arrowdown")],
)
# Menu is a SIBLING of the toggle (not nested inside it): a