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")