Files
timetracker/games/views/purchase.py
T
lukas ed8589a972
Django CI/CD / test (push) Successful in 39s
Django CI/CD / build-and-push (push) Successful in 1m19s
Fix more code smells
2026-06-06 13:14:55 +02:00

417 lines
13 KiB
Python

from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import (
HttpRequest,
HttpResponse,
HttpResponseRedirect,
)
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 django.template.defaultfilters import date as date_filter
from django.template.defaultfilters import floatformat
from django.utils.safestring import SafeText, mark_safe
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 common.utils import paginate
from games.forms import PurchaseForm
from games.models import Game, Purchase
from games.views.general import use_custom_redirect
def _render_purchase_buttons(purchase_id, is_refunded):
"""Return button group HTML for a purchase row."""
return ButtonGroup(
[
{
"href": "#",
"hx_get": reverse(
"games:refund_purchase_confirmation",
args=[purchase_id],
),
"hx_target": "#global-modal-container",
"slot": Icon("refund"),
"title": "Mark as refunded",
}
if not is_refunded
else {},
{
"href": reverse("games:edit_purchase", args=[purchase_id]),
"slot": Icon("edit"),
"title": "Edit",
"color": "gray",
},
{
"href": reverse("games:delete_purchase", args=[purchase_id]),
"slot": Icon("delete"),
"title": "Delete",
"color": "red",
},
]
)
def _render_purchase_row(purchase):
"""Return a row dict for simple-table rendering."""
return {
"row_id": f"purchase-row-{purchase.id}",
"cell_data": [
LinkedPurchase(purchase),
purchase.get_type_display(),
PurchasePrice(purchase),
purchase.infinite,
purchase.date_purchased.strftime(dateformat),
(
purchase.date_refunded.strftime(dateformat)
if purchase.date_refunded
else "-"
),
purchase.created_at.strftime(dateformat),
_render_purchase_buttons(purchase.id, bool(purchase.date_refunded)),
],
}
@login_required
def list_purchases(request: HttpRequest) -> HttpResponse:
purchases = Purchase.objects.order_by("-date_purchased", "-created_at")
filter_json = request.GET.get("filter", "")
if filter_json:
from games.filters import parse_purchase_filter
pf = parse_purchase_filter(filter_json)
if pf is not None:
purchases = purchases.filter(pf.to_q())
purchases, page_obj, elided_page_range = paginate(request, purchases)
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],
}
content = paginated_table_content(
data,
page_obj=page_obj,
elided_page_range=elided_page_range,
request=request,
)
from common.components import PurchaseFilterBar, ModuleScript
filter_bar = PurchaseFilterBar(
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 purchases",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("selectable_filter.js")
+ ModuleScript("filter_bar.js"),
)
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:
initial = {"date_purchased": timezone.now()}
if request.method == "POST":
form = PurchaseForm(request.POST or None, initial=initial)
if form.is_valid():
purchase = form.save()
if "submit_and_redirect" in request.POST:
return HttpResponseRedirect(
reverse(
"games:add_session_for_game",
kwargs={"game_id": purchase.first_game.id},
)
)
else:
return redirect("games:list_purchases")
else:
if game_id:
game = Game.objects.get(id=game_id)
form = PurchaseForm(
initial={
**initial,
"games": [game],
"platform": game.platform,
}
)
else:
form = PurchaseForm(initial=initial)
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:
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")
return render_page(
request,
AddForm(form, request=request, additional_row=_purchase_additional_row()),
title="Edit Purchase",
scripts=ModuleScript("add_purchase.js"),
)
@login_required
def delete_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
purchase = get_object_or_404(Purchase, id=purchase_id)
purchase.delete()
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_page(
request,
_view_purchase_content(purchase),
title=f"Purchase: {purchase.full_name}",
)
@login_required
def drop_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
purchase = get_object_or_404(Purchase, id=purchase_id)
for game in purchase.games.all():
game.status = Game.Status.ABANDONED
game.save()
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 HttpResponse(_refund_confirmation_modal(purchase_id, request))
@login_required
@require_POST
def refund_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
purchase = get_object_or_404(Purchase, id=purchase_id)
for game in purchase.games.all():
game.status = Game.Status.ABANDONED
game.save()
purchase.refund()
messages.success(request, "Purchase refunded")
row_data = _render_purchase_row(purchase)
row_html = str(TableRow(data=row_data))
modal_close = (
'<template id="refund-confirmation-modal" hx-swap-oob="outerHTML"></template>'
)
return HttpResponse(row_html + modal_close, status=200)
@login_required
def finish_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
purchase = get_object_or_404(Purchase, id=purchase_id)
for game in purchase.games.all():
game.status = Game.Status.FINISHED
game.save()
return redirect("games:list_purchases")
def related_purchase_by_game(request: HttpRequest) -> HttpResponse:
games: list[str] = request.GET.getlist("games")
if games:
form = PurchaseForm()
qs = Purchase.objects.filter(games__in=games, type=Purchase.GAME).order_by(
"games__sort_name"
)
form.fields["related_purchase"].queryset = qs
first_option = qs.first()
if first_option:
form.fields["related_purchase"].initial = first_option.id
return HttpResponse(str(form["related_purchase"]))
else:
# abort swap
return HttpResponse(status=204)