from typing import Any, TypedDict from django.contrib.auth.decorators import login_required from django.db.models import Q from django.http import HttpRequest, HttpResponse from django.middleware.csrf import get_token from django.shortcuts import get_object_or_404, redirect from django.urls import reverse from django.utils import timezone from django.utils.safestring import mark_safe from common.components import ( A, AddForm, ButtonGroup, Div, FormFields, Fragment, Icon, ModuleScript, NameWithIcon, Node, Popover, SearchField, SessionDeviceSelector, SessionTimestampButtons, StyledButton, TableRow, paginated_table_content, ) from common.layout import NavbarPlaytime, render_page from games.views.general import model_counts from common.time import ( dateformat, local_strftime, timeformat, ) from common.utils import paginate, truncate from games.forms import SessionForm from games.models import Device, Game, Session class SessionRowData(TypedDict): row_id: str hx_trigger: str hx_get: str hx_select: str hx_swap: str cell_data: list[Node | str] def session_row_data(session: Session, device_list, csrf_token: str) -> SessionRowData: """Canonical session-list row. Single source of truth shared by list_sessions and the htmx finish/reset fragments.""" row_selector = f"#session-row-{session.pk}" end_url = reverse("games:list_sessions_end_session", args=[session.pk]) reset_url = reverse("games:list_sessions_reset_session_start", args=[session.pk]) actions = ButtonGroup( [ { "href": end_url, "hx_get": end_url, "hx_target": row_selector, "hx_swap": "outerHTML", "slot": Icon("end"), "title": "Finish session now", "color": "green", } if session.timestamp_end is None else {}, { "href": reset_url, "hx_get": reset_url, "hx_target": row_selector, "hx_swap": "outerHTML", "hx_confirm": "Reset this session's start time to now?", "slot": Icon("reset"), "title": "Reset start to now", "color": "gray", } 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", }, ] ) return SessionRowData( row_id=f"session-row-{session.pk}", hx_trigger="device-changed from:body", hx_get="", hx_select=row_selector, hx_swap="outerHTML", cell_data=[ NameWithIcon(session=session), f"{local_strftime(session.timestamp_start)}" f"{f' — {local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}", session.duration_formatted_with_mark(), SessionDeviceSelector(session, device_list, csrf_token), session.created_at.strftime(dateformat), actions, ], ) def session_row(session: Session, device_list, csrf_token: str) -> Node: """The single-session node, rendered through the same TableRow path the list table uses.""" return TableRow(session_row_data(session, device_list, csrf_token)) @login_required def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse: sessions = Session.objects.order_by("-timestamp_start", "created_at") device_list = Device.objects.order_by("name") # ── Structured filter (JSON) ── filter_json = request.GET.get("filter", "") if filter_json: from games.filters import parse_session_filter session_filter = parse_session_filter(filter_json) if session_filter is not None: sessions = sessions.filter(session_filter.to_q()) else: # ── Legacy free-text search ── search_string = request.GET.get("search_string", search_string) if search_string != "": sessions = sessions.filter( Q(game__name__icontains=search_string) | Q(game__name__icontains=search_string) | Q(game__platform__name__icontains=search_string) | Q(device__name__icontains=search_string) | Q(device__type__icontains=search_string) ) try: last_session = sessions.latest() except Session.DoesNotExist: last_session = None sessions, page_obj, elided_page_range = paginate(request, sessions) csrf_token = get_token(request) data = { "header_action": Div( children=[ SearchField(search_string=search_string), Div( children=[ A( href=reverse("games:add_session"), )[ StyledButton( icon=True, size="xs", )[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=[ StyledButton( 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": [ session_row_data(session, device_list, csrf_token) for session in sessions ], } content = paginated_table_content( data, page_obj=page_obj, elided_page_range=elided_page_range, request=request, ) from common.components import SessionFilterBar filter_json = request.GET.get("filter", "") filter_bar = SessionFilterBar( 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 sessions", ) @login_required def search_sessions(request: HttpRequest) -> HttpResponse: return list_sessions(request, search_string=request.GET.get("search_string", "")) def _timestamp_buttons(field_name: str) -> Node: """The now/toggle/copy helper buttons appended to a timestamp field's row.""" this_side = "start" if field_name == "timestamp_start" else "end" other_side = "end" if field_name == "timestamp_start" else "start" return SessionTimestampButtons( class_="flex flex-row gap-3 justify-start mt-3", hx_boost="false", )[ StyledButton(data_target=field_name, data_type="now", size="xs")["Set to now"], StyledButton(data_target=field_name, data_type="toggle", size="xs")[ "Toggle text" ], StyledButton(data_target=field_name, data_type="copy", size="xs")[ f"Copy {this_side} value to {other_side}" ], ] def _session_fields(form) -> Node: """Session form fields via the shared renderer, with timestamp helper buttons appended to the two timestamp rows.""" return FormFields( form, extras={ name: _timestamp_buttons(name) for name in ("timestamp_start", "timestamp_end") }, ) @login_required def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse: initial: dict[str, Any] = {"timestamp_start": timezone.now()} if request.method == "POST": form = SessionForm(request.POST or None, initial=initial) if form.is_valid(): form.save() return redirect("games:list_sessions") else: if game_id: game = get_object_or_404(Game, id=game_id) form = SessionForm( initial={ **initial, "game": game, } ) else: form = SessionForm(initial=initial) # TODO: re-add custom buttons #91 return render_page( request, AddForm(form, request=request, fields=_session_fields(form), submit_class=""), title="Add New Session", scripts=mark_safe(ModuleScript("dist/elements/search-select.js")), ) @login_required def edit_session(request: HttpRequest, session_id: int) -> HttpResponse: 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") return render_page( request, AddForm(form, request=request, fields=_session_fields(form), submit_class=""), title="Edit Session", scripts=mark_safe(ModuleScript("dist/elements/search-select.js")), ) def _row_with_navbar(request: HttpRequest, session: Session) -> HttpResponse: device_list = Device.objects.order_by("name") counts = model_counts(request) fragment = Fragment( session_row(session, device_list, get_token(request)), NavbarPlaytime( counts["today_played"], counts["last_7_played"], today_url=counts["today_url"], last_7_url=counts["last_7_url"], oob=True, ), ) return HttpResponse(str(fragment)) def clone_session_by_id(session_id: int) -> Session: session = get_object_or_404(Session, id=session_id) clone = session clone.pk = None clone.timestamp_start = timezone.now() clone.timestamp_end = None clone.note = "" clone.save() return clone @login_required def new_session_from_existing_session( request: HttpRequest, session_id: int ) -> HttpResponse: clone_session_by_id(session_id) if request.htmx: # Clone adds a new row whose position depends on sort + pagination, # which a single-row swap cannot place — refresh the list instead. response = HttpResponse(status=204) response["HX-Refresh"] = "true" return response return redirect("games:list_sessions") @login_required 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: return _row_with_navbar(request, session) return redirect("games:list_sessions") @login_required def reset_session_start(request: HttpRequest, session_id: int) -> HttpResponse: session = get_object_or_404(Session, id=session_id) session.timestamp_start = timezone.now() session.save() if request.htmx: return _row_with_navbar(request, session) 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 redirect("games:list_sessions")