from typing import Any from django.contrib.auth.decorators import login_required from django.core.paginator import Paginator 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 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, FilterBar, 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, 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( 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, 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 = mark_safe(str(filter_bar) + str(content)) return render_page( request, content, title="Manage games", scripts=ModuleScript("range_slider.js") + ModuleScript("selectable_filter.js") + ModuleScript("filter_bar.js"), ) @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=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) 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") # --- view_game content builders ------------------------------------------- _STAT_SVGS = { "hours": '', "sessions": '', "average": '', "playrange": '', } _PLAYED_ROW_TEMPLATE = """
Played
""" 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, ], ) def _game_overview_metrics(game: Game) -> dict[str, Any]: """Request-free header metrics: total session count, play range, and the per-session average (excluding manually-logged sessions).""" sessions = game.sessions session_count = sessions.count() session_count_without_manual = sessions.without_manual().count() if sessions.exists(): start = local_strftime(sessions.earliest().timestamp_start, "%b %Y") end = local_strftime(sessions.latest().timestamp_start, "%b %Y") playrange = start if start == end else f"{start} — {end}" else: playrange = "N/A" total_hours_without_manual = float( format_duration(sessions.calculated_duration_unformatted(), "%2.1H") ) session_average_without_manual = round( safe_division(total_hours_without_manual, int(session_count_without_manual)), 1 ) return { "session_count": session_count, "playrange": playrange, "session_average_without_manual": session_average_without_manual, } def _game_header(game: Game, request: HttpRequest, metrics: dict[str, Any]) -> SafeText: 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], ), ] + ( [ 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 [] ), ) 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", metrics["session_count"], ), _stat_popover( "popover-average", "Average playtime per session", "average", metrics["session_average_without_manual"], ), _stat_popover( "popover-playrange", "Earliest and latest dates played", "playrange", metrics["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", GameStatusSelector(game, Game.Status.choices, get_token(request)), "👑" if game.mastered else "", ), _played_row(game, request), _meta_row( "Platform", Component( tag_name="span", attributes=[("class", grey_value_class)], children=[str(game.platform)], ), ), ], ) return Div( [("id", "game-info"), ("class", "mb-10")], [ Div([("class", "flex gap-5 mb-3")], [title_span]), stats_row, metadata, _game_action_buttons(game), ], ) def _purchases_section(game: Game) -> SafeText: purchases = game.purchases.order_by("date_purchased") rows = [ [ LinkedPurchase(purchase), purchase.get_type_display(), purchase.date_purchased.strftime(dateformat), PurchasePrice(purchase), ButtonGroup( [ { "href": reverse("games:edit_purchase", args=[purchase.pk]), "slot": Icon("edit"), "color": "gray", }, { "href": reverse("games:delete_purchase", args=[purchase.pk]), "slot": Icon("delete"), "color": "red", }, ] ), ] for purchase in purchases ] table = SimpleTable(columns=["Name", "Type", "Date", "Price", "Actions"], rows=rows) return _game_section("Purchases", purchases.count(), table, "No purchases yet.") def _sessions_section(game: Game, request: HttpRequest) -> SafeText: sessions_all = game.sessions.order_by("-timestamp_start") session_count = sessions_all.count() last_session = sessions_all.latest() if sessions_all.exists() else None page_number = request.GET.get("page", 1) page_obj = Paginator(sessions_all, 5).get_page(page_number) elided_page_range = ( page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1) if session_count > 5 else None ) header_action = 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], ), 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 "", ], ) rows = [ [ 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(), 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"), "color": "gray", }, { "href": reverse("games:delete_session", args=[session.pk]), "slot": Icon("delete"), "color": "red", }, ] ), ] for session in page_obj.object_list ] table = SimpleTable( columns=["Game", "Date", "Duration", "Actions"], rows=rows, header_action=header_action, page_obj=page_obj, elided_page_range=elided_page_range, request=request, ) return _game_section("Sessions", session_count, table, "No sessions yet.") def _playevents_section(game: Game) -> SafeText: playevents = game.playevents.all() data = create_playevent_tabledata(playevents, exclude_columns=["Game"]) table = SimpleTable(columns=data["columns"], rows=data["rows"]) return _game_section( "Play Events", playevents.count(), table, "No play events yet." ) def _history_section(game: Game) -> SafeText: statuschanges = game.status_changes.all() return 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=statuschanges.count()), _game_history(statuschanges), ], ) _GET_SESSION_COUNT_SCRIPT = mark_safe( "" ) @login_required def view_game(request: HttpRequest, game_id: int) -> HttpResponse: game = Game.objects.get(id=game_id) content = Div( [("class", "dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto")], [ _game_header(game, request, _game_overview_metrics(game)), _purchases_section(game), _sessions_section(game, request), _playevents_section(game), _history_section(game), _GET_SESSION_COUNT_SCRIPT, ], ) request.session["return_path"] = request.path return render_page( request, content, title=f"Game Overview - {game.name}", mastered=game.mastered, )