Merge Edition into Game #85

Merged
lukas merged 3 commits from remove-edition into main 2025-01-29 21:11:44 +00:00
29 changed files with 286 additions and 467 deletions
Showing only changes of commit 6bd8271291 - Show all commits

View File

@ -9,7 +9,7 @@ from django.urls import NoReverseMatch, reverse
from django.utils.safestring import SafeText, mark_safe
from common.utils import truncate
from games.models import Edition, Game, Purchase, Session
from games.models import Game, Purchase, Session
HTMLAttribute = tuple[str, str | int | bool]
HTMLTag = str
@ -192,24 +192,24 @@ def LinkedPurchase(purchase: Purchase) -> SafeText:
link = reverse("view_purchase", args=[int(purchase.id)])
link_content = ""
popover_content = ""
edition_count = purchase.editions.count()
game_count = purchase.games.count()
popover_if_not_truncated = False
if edition_count == 1:
link_content += purchase.editions.first().name
if game_count == 1:
link_content += purchase.games.first().name
popover_content = link_content
if edition_count > 1:
if game_count > 1:
if purchase.name:
link_content += f"{purchase.name}"
popover_content += f"<h1>{purchase.name}</h1><br>"
else:
link_content += f"{edition_count} games"
link_content += f"{game_count} games"
popover_if_not_truncated = True
popover_content += f"""
<ul class="list-disc list-inside">
{"".join(f"<li>{edition.name}</li>" for edition in purchase.editions.all())}
{"".join(f"<li>{game.name}</li>" for game in purchase.games.all())}
</ul>
"""
icon = purchase.platform.icon if edition_count == 1 else "unspecified"
icon = purchase.platform.icon if game_count == 1 else "unspecified"
if link_content == "":
raise ValueError("link_content is empty!!")
a_content = Div(
@ -235,36 +235,25 @@ def NameWithIcon(
game_id: int = 0,
session_id: int = 0,
purchase_id: int = 0,
edition_id: int = 0,
linkify: bool = True,
emulated: bool = False,
) -> SafeText:
create_link = False
link = ""
edition = None
platform = None
if (
game_id != 0 or session_id != 0 or purchase_id != 0 or edition_id != 0
) and linkify:
if (game_id != 0 or session_id != 0 or purchase_id != 0) and linkify:
create_link = True
if session_id:
session = Session.objects.get(pk=session_id)
emulated = session.emulated
edition = session.purchase.first_edition
game_id = edition.game.pk
game_id = session.game.pk
if purchase_id:
purchase = Purchase.objects.get(pk=purchase_id)
edition = purchase.first_edition
game_id = purchase.edition.game.pk
if edition_id:
edition = Edition.objects.get(pk=edition_id)
game_id = edition.game.pk
game_id = purchase.games.first().pk
if game_id:
game = Game.objects.get(pk=game_id)
name = edition.name if edition else game.name
platform = edition.platform if edition else None
if game.platform:
platform = game.platform
name = game.name
platform = game.platform
link = reverse("view_game", args=[int(game_id)])
content = Div(
[("class", "inline-flex gap-2 items-center")],

View File

@ -2,7 +2,6 @@ from django.contrib import admin
from games.models import (
Device,
Edition,
ExchangeRate,
Game,
Platform,
@ -15,6 +14,5 @@ admin.site.register(Game)
admin.site.register(Purchase)
admin.site.register(Platform)
admin.site.register(Session)
admin.site.register(Edition)
admin.site.register(Device)
admin.site.register(ExchangeRate)

View File

@ -2,7 +2,7 @@ from django import forms
from django.urls import reverse
from common.utils import safe_getattr
from games.models import Device, Edition, Game, Platform, Purchase, Session
from games.models import Device, Game, Platform, Purchase, Session
custom_date_widget = forms.DateInput(attrs={"type": "date"})
custom_datetime_widget = forms.DateTimeInput(
@ -12,11 +12,8 @@ autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
class SessionForm(forms.ModelForm):
# purchase = forms.ModelChoiceField(
# queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name")
# )
purchase = forms.ModelChoiceField(
queryset=Purchase.objects.all(),
game = forms.ModelChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=forms.Select(attrs={"autofocus": "autofocus"}),
)
@ -29,7 +26,7 @@ class SessionForm(forms.ModelForm):
}
model = Session
fields = [
"purchase",
"game",
"timestamp_start",
"timestamp_end",
"duration_manual",
@ -39,7 +36,7 @@ class SessionForm(forms.ModelForm):
]
class EditionChoiceField(forms.ModelMultipleChoiceField):
class GameChoiceField(forms.ModelMultipleChoiceField):
def label_from_instance(self, obj) -> str:
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
@ -57,19 +54,19 @@ class PurchaseForm(forms.ModelForm):
super().__init__(*args, **kwargs)
# Automatically update related_purchase <select/>
# to only include purchases of the selected edition.
related_purchase_by_edition_url = reverse("related_purchase_by_edition")
self.fields["editions"].widget.attrs.update(
# to only include purchases of the selected game.
related_purchase_by_game_url = reverse("related_purchase_by_game")
self.fields["games"].widget.attrs.update(
{
"hx-trigger": "load, click",
"hx-get": related_purchase_by_edition_url,
"hx-get": related_purchase_by_game_url,
"hx-target": "#id_related_purchase",
"hx-swap": "outerHTML",
}
)
editions = EditionChoiceField(
queryset=Edition.objects.order_by("sort_name"),
games = GameChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
)
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
@ -87,7 +84,7 @@ class PurchaseForm(forms.ModelForm):
}
model = Purchase
fields = [
"editions",
"games",
"platform",
"date_purchased",
"date_refunded",
@ -139,21 +136,11 @@ class GameModelChoiceField(forms.ModelChoiceField):
return obj.sort_name
class EditionForm(forms.ModelForm):
game = GameModelChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=IncludeNameSelect(attrs={"autofocus": "autofocus"}),
)
class GameForm(forms.ModelForm):
platform = forms.ModelChoiceField(
queryset=Platform.objects.order_by("name"), required=False
)
class Meta:
model = Edition
fields = ["game", "name", "sort_name", "platform", "year_released", "wikidata"]
class GameForm(forms.ModelForm):
class Meta:
model = Game
fields = ["name", "sort_name", "platform", "year_released", "wikidata"]

View File

@ -1,5 +1,4 @@
from .device import Query as DeviceQuery
from .edition import Query as EditionQuery
from .game import Query as GameQuery
from .platform import Query as PlatformQuery
from .purchase import Query as PurchaseQuery

View File

@ -1,11 +0,0 @@
import graphene
from games.graphql.types import Edition
from games.models import Game as EditionModel
class Query(graphene.ObjectType):
editions = graphene.List(Edition)
def resolve_editions(self, info, **kwargs):
return EditionModel.objects.all()

View File

@ -29,12 +29,15 @@ def copy_platform_to_game(apps, schema_editor):
print(
f"Game '{game}' with ID '{game.pk}' missing edition with platform '{e.platform}', creating..."
)
Game.objects.create(
newgame = Game.objects.create(
name=e.name,
sort_name=e.sort_name,
platform=e.platform,
year_released=e.year_released,
)
print(f"Setting edition to a newly created game with id '{newgame.pk}'")
e.game = newgame
e.save()
class Migration(migrations.Migration):

View File

@ -0,0 +1,35 @@
# Generated by Django 5.1.5 on 2025-01-29 17:48
import django.db.models.deletion
from django.db import migrations, models
from games.models import Session
def connect_session_to_game(apps, schema_editor):
for session in Session.objects.all():
game = session.purchase.first_edition.game
session.game = game
session.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0049_alter_game_unique_together"),
]
operations = [
migrations.AddField(
model_name="session",
name="game",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="sessions",
to="games.game",
),
),
migrations.RunPython(connect_session_to_game),
]

View File

@ -0,0 +1,29 @@
# Generated by Django 5.1.5 on 2025-01-29 18:03
from django.db import migrations, models
from games.models import Purchase
def connect_purchase_to_game(apps, schema_editor):
for purchase in Purchase.objects.all():
game = purchase.first_edition.game
purchase.games.add(game)
purchase.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0050_session_game"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="games",
field=models.ManyToManyField(
blank=True, related_name="purchases", to="games.game"
),
),
migrations.RunPython(connect_purchase_to_game),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 5.1.5 on 2025-01-29 18:20
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('games', '0051_purchase_games'),
]
operations = [
migrations.RemoveField(
model_name='purchase',
name='editions',
),
]

View File

@ -0,0 +1,16 @@
# Generated by Django 5.1.5 on 2025-01-29 19:21
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('games', '0052_remove_purchase_editions'),
]
operations = [
migrations.DeleteModel(
name='Edition',
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 5.1.5 on 2025-01-29 19:21
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('games', '0053_delete_edition'),
]
operations = [
migrations.RemoveField(
model_name='session',
name='purchase',
),
]

View File

@ -29,6 +29,17 @@ class Game(models.Model):
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if self.platform is None:
self.platform = get_sentinel_platform()
super().save(*args, **kwargs)
def get_sentinel_platform():
return Platform.objects.get_or_create(
name="Unspecified", icon="unspecified", group="Unspecified"
)[0]
class Platform(models.Model):
name = models.CharField(max_length=255)
@ -45,35 +56,6 @@ class Platform(models.Model):
super().save(*args, **kwargs)
def get_sentinel_platform():
return Platform.objects.get_or_create(
name="Unspecified", icon="unspecified", group="Unspecified"
)[0]
class Edition(models.Model):
class Meta:
unique_together = [["name", "platform", "year_released"]]
game = models.ForeignKey(Game, on_delete=models.CASCADE, related_name="editions")
name = models.CharField(max_length=255)
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None)
platform = models.ForeignKey(
Platform, on_delete=models.SET_DEFAULT, null=True, blank=True, default=None
)
year_released = models.IntegerField(null=True, blank=True, default=None)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.sort_name
def save(self, *args, **kwargs):
if self.platform is None:
self.platform = get_sentinel_platform()
super().save(*args, **kwargs)
class PurchaseQueryset(models.QuerySet):
def refunded(self):
return self.filter(date_refunded__isnull=False)
@ -120,7 +102,8 @@ class Purchase(models.Model):
objects = PurchaseQueryset().as_manager()
editions = models.ManyToManyField(Edition, related_name="purchases", blank=True)
games = models.ManyToManyField(Game, related_name="purchases", blank=True)
platform = models.ForeignKey(
Platform, on_delete=models.CASCADE, default=None, null=True, blank=True
)
@ -150,24 +133,26 @@ class Purchase(models.Model):
@property
def standardized_name(self):
return self.name if self.name else self.first_edition.name
return self.name if self.name else self.first_game.name
@property
def first_edition(self):
return self.editions.first()
def first_game(self):
return self.games.first()
def __str__(self):
additional_info = [
self.get_type_display() if self.type != Purchase.GAME else "",
(
f"{self.first_edition.platform} version on {self.platform}"
if self.platform != self.first_edition.platform
f"{self.first_game.platform} version on {self.platform}"
if self.platform != self.first_game.platform
else self.platform
),
self.first_edition.year_released,
self.first_game.year_released,
self.get_ownership_type_display(),
]
return f"{self.first_edition} ({', '.join(filter(None, map(str, additional_info)))})"
return (
f"{self.first_game} ({', '.join(filter(None, map(str, additional_info)))})"
)
def is_game(self):
return self.type == self.GAME
@ -218,7 +203,14 @@ class Session(models.Model):
class Meta:
get_latest_by = "timestamp_start"
purchase = models.ForeignKey(Purchase, on_delete=models.CASCADE)
game = models.ForeignKey(
Game,
on_delete=models.CASCADE,
blank=True,
null=True,
default=None,
related_name="sessions",
)
timestamp_start = models.DateTimeField()
timestamp_end = models.DateTimeField(blank=True, null=True)
duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0))
@ -240,7 +232,7 @@ class Session(models.Model):
def __str__(self):
mark = ", manual" if self.is_manual() else ""
return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
return f"{str(self.game)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
def finish_now(self):
self.timestamp_end = timezone.now()

View File

@ -7,7 +7,7 @@ import {
let syncData = [
{
source: "#id_edition",
source: "#id_games",
source_value: "dataset.platform",
target: "#id_platform",
target_value: "value",
@ -36,8 +36,8 @@ getEl("#id_type").onchange = () => {
document.body.addEventListener("htmx:beforeRequest", function (event) {
// Assuming 'Purchase1' is the element that triggers the HTMX request
if (event.target.id === "id_edition") {
var idEditionValue = document.getElementById("id_edition").value;
if (event.target.id === "id_games") {
var idEditionValue = document.getElementById("id_games").value;
// Condition to check - replace this with your actual logic
if (idEditionValue != "") {

View File

@ -36,7 +36,7 @@ function addToggleButton(targetNode) {
targetNode.parentElement.appendChild(manualToggleButton);
}
const toggleableFields = ["#id_game", "#id_edition", "#id_platform"];
const toggleableFields = ["#id_games", "#id_platform"];
toggleableFields.map((selector) => {
addToggleButton(document.querySelector(selector));

View File

@ -1,12 +0,0 @@
<c-layouts.add>
<c-slot name="additional_row">
<tr>
<td></td>
<td>
<input type="submit"
name="submit_and_redirect"
value="Submit & Create Purchase" />
</td>
</tr>
</c-slot>
</c-layouts.add>

View File

@ -5,7 +5,7 @@
<td>
<input type="submit"
name="submit_and_redirect"
value="Submit & Create Edition" />
value="Submit & Create Purchase" />
</td>
</tr>
</c-slot>

View File

@ -36,8 +36,8 @@
<td class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name relative align-top w-24 h-12 group">
<span class="inline-block relative">
<a class="underline decoration-slate-500 sm:decoration-2 inline-block truncate max-w-20char group-hover:absolute group-hover:max-w-none group-hover:-top-8 group-hover:-left-6 group-hover:min-w-60 group-hover:px-6 group-hover:py-3.5 group-hover:bg-purple-600 group-hover:rounded-sm group-hover:outline-dashed group-hover:outline-purple-400 group-hover:outline-4 group-hover:decoration-purple-900 group-hover:text-purple-100"
href="{% url 'view_game' session.purchase.edition.game.id %}">
{{ session.purchase.edition.name }}
href="{% url 'view_game' session.game.id %}">
{{ session.game.name }}
</a>
</span>
</td>

View File

@ -57,10 +57,6 @@
<a href="{% url 'add_game' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Game</a>
</li>
<li>
<a href="{% url 'add_edition' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Edition</a>
</li>
<li>
<a href="{% url 'add_platform' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platform</a>
@ -102,10 +98,6 @@
<a href="{% url 'list_games' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Games</a>
</li>
<li>
<a href="{% url 'list_editions' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Editions</a>
</li>
<li>
<a href="{% url 'list_platforms' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platforms</a>

View File

@ -2,11 +2,11 @@
{% load static %}
{% partialdef purchase-name %}
{% if purchase.type != 'game' %}
<c-gamelink :game_id=purchase.first_edition.game.id>
{{ purchase.name }} ({{ purchase.first_edition.edition.name }} {{ purchase.get_type_display }})
<c-gamelink :game_id=purchase.first_game.id>
{{ purchase.name }} ({{ purchase.first_game.name }} {{ purchase.get_type_display }})
</c-gamelink>
{% else %}
<c-gamelink :game_id=purchase.first_edition.game.id :name=purchase.first_edition.name />
<c-gamelink :game_id=purchase.first_game.id :name=purchase.first_game.name />
{% endif %}
{% endpartialdef %}
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">

View File

@ -67,10 +67,6 @@
</a>
</div>
</div>
<c-h1 :badge="edition_count">Editions</c-h1>
<div class="mb-6">
<c-simple-table :rows=edition_data.rows :columns=edition_data.columns />
</div>
<div class="mb-6">
<c-h1 :badge="purchase_count">Purchases</c-h1>
<c-simple-table :rows=purchase_data.rows :columns=purchase_data.columns />

View File

@ -3,7 +3,7 @@
<div class="flex flex-col gap-5 mb-3">
<span class="text-balance max-w-[30rem] text-4xl">
<span class="font-bold font-serif">{% if purchase.name %}{{ purchase.name }}{% else %}Unnamed purchase{% endif %}</span> <span class="text-slate-500 text-2xl">({{ purchase.editions.count }} games)</span>
<span class="font-bold font-serif">{% if purchase.name %}{{ purchase.name }}{% else %}Unnamed purchase{% endif %}</span> <span class="text-slate-500 text-2xl">({{ purchase.games.count }} games)</span>
</span>
<div class="inline-flex rounded-md shadow-sm mb-3" role="group">
<a href="{% url 'edit_purchase' purchase.id %}">
@ -19,12 +19,20 @@
</button>
</a>
</div>
<div>Price: {{ purchase.converted_price | floatformat }} {{ purchase.converted_currency }} ({{ purchase.price | floatformat }} {{ purchase.price_currency }})</div>
<div>
Price:
{% if purchase.converted_price %}
{{ purchase.converted_price | floatformat }} {{ purchase.converted_currency }}
{% else %}
None
{% endif %}
({{ purchase.price | floatformat }} {{ purchase.price_currency }})
</div>
<div>
<h2 class="text-base">Items:</h2>
<ul class="list-disc list-inside">
{% for edition in purchase.editions.all %}
<li><c-gamelink :game_id=edition.game.id :name=edition.name /></li>
{% for game in purchase.games.all %}
<li><c-gamelink :game_id=game.id :name=game.name /></li>
{% endfor %}
</ul>
</div>

View File

@ -1,6 +1,6 @@
from django.urls import path
from games.views import device, edition, game, general, platform, purchase, session
from games.views import device, game, general, platform, purchase, session
urlpatterns = [
path("", general.index, name="index"),
@ -8,19 +8,6 @@ urlpatterns = [
path("device/delete/<int:device_id>", device.delete_device, name="delete_device"),
path("device/edit/<int:device_id>", device.edit_device, name="edit_device"),
path("device/list", device.list_devices, name="list_devices"),
path("edition/add", edition.add_edition, name="add_edition"),
path(
"edition/add/for-game/<int:game_id>",
edition.add_edition,
name="add_edition_for_game",
),
path("edition/<int:edition_id>/edit", edition.edit_edition, name="edit_edition"),
path("edition/list", edition.list_editions, name="list_editions"),
path(
"edition/<int:edition_id>/delete",
edition.delete_edition,
name="delete_edition",
),
path("game/add", game.add_game, name="add_game"),
path("game/<int:game_id>/edit", game.edit_game, name="edit_game"),
path("game/<int:game_id>/view", game.view_game, name="view_game"),
@ -39,6 +26,11 @@ urlpatterns = [
),
path("platform/list", platform.list_platforms, name="list_platforms"),
path("purchase/add", purchase.add_purchase, name="add_purchase"),
path(
"purchase/add/for-game/<int:game_id>",
purchase.add_purchase,
name="add_purchase_for_game",
),
path(
"purchase/<int:purchase_id>/edit",
purchase.edit_purchase,
@ -75,20 +67,15 @@ urlpatterns = [
name="refund_purchase",
),
path(
"purchase/related-purchase-by-edition",
purchase.related_purchase_by_edition,
name="related_purchase_by_edition",
),
path(
"purchase/add/for-edition/<int:edition_id>",
purchase.add_purchase,
name="add_purchase_for_edition",
"purchase/related-purchase-by-game",
purchase.related_purchase_by_game,
name="related_purchase_by_game",
),
path("session/add", session.add_session, name="add_session"),
path(
"session/add/for-purchase/<int:purchase_id>",
"session/add/for-game/<int:game_id>",
session.add_session,
name="add_session_for_purchase",
name="add_session_for_game",
),
path(
"session/add/from-game/<int:session_id>",

View File

@ -1,150 +0,0 @@
from typing import Any
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string
from django.urls import reverse
from common.components import (
A,
Button,
Icon,
NameWithIcon,
PopoverTruncated,
)
from common.time import dateformat, local_strftime
from games.forms import EditionForm
from games.models import Edition, Game
@login_required
def list_editions(request: HttpRequest) -> HttpResponse:
context: dict[Any, Any] = {}
page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10)
editions = Edition.objects.order_by("-created_at")
page_obj = None
if int(limit) != 0:
paginator = Paginator(editions, limit)
page_obj = paginator.get_page(page_number)
editions = page_obj.object_list
context = {
"title": "Manage editions",
"page_obj": page_obj or None,
"elided_page_range": (
page_obj.paginator.get_elided_page_range(
page_number, on_each_side=1, on_ends=1
)
if page_obj
else None
),
"data": {
"header_action": A([], Button([], "Add edition"), url="add_edition"),
"columns": [
"Game",
"Name",
"Sort Name",
"Year",
"Wikidata",
"Created",
"Actions",
],
"rows": [
[
NameWithIcon(edition_id=edition.pk),
PopoverTruncated(
edition.name
if edition.game.name != edition.name
else "(identical)"
),
PopoverTruncated(
edition.sort_name
if edition.sort_name is not None
and edition.game.name != edition.sort_name
else "(identical)"
),
edition.year_released,
edition.wikidata,
local_strftime(edition.created_at, dateformat),
render_to_string(
"cotton/button_group.html",
{
"buttons": [
{
"href": reverse("edit_edition", args=[edition.pk]),
"slot": Icon("edit"),
"color": "gray",
},
{
"href": reverse(
"delete_edition", args=[edition.pk]
),
"slot": Icon("delete"),
"color": "red",
},
]
},
),
]
for edition in editions
],
},
}
return render(request, "list_purchases.html", context)
@login_required
def edit_edition(request: HttpRequest, edition_id: int = 0) -> HttpResponse:
edition = get_object_or_404(Edition, id=edition_id)
form = EditionForm(request.POST or None, instance=edition)
if form.is_valid():
form.save()
return redirect("list_editions")
context: dict[str, Any] = {"form": form, "title": "Edit edition"}
return render(request, "add.html", context)
@login_required
def delete_edition(request: HttpRequest, edition_id: int) -> HttpResponse:
edition = get_object_or_404(Edition, id=edition_id)
edition.delete()
return redirect("list_editions")
@login_required
def add_edition(request: HttpRequest, game_id: int = 0) -> HttpResponse:
context: dict[str, Any] = {}
if request.method == "POST":
form = EditionForm(request.POST or None)
if form.is_valid():
edition = form.save()
if "submit_and_redirect" in request.POST:
return HttpResponseRedirect(
reverse(
"add_purchase_for_edition", kwargs={"edition_id": edition.id}
)
)
else:
return redirect("index")
else:
if game_id:
game = get_object_or_404(Game, id=game_id)
form = EditionForm(
initial={
"game": game,
"name": game.name,
"sort_name": game.sort_name,
"year_released": game.year_released,
}
)
else:
form = EditionForm()
context["form"] = form
context["title"] = "Add New Edition"
context["script_name"] = "add_edition.js"
return render(request, "add_edition.html", context)

View File

@ -29,7 +29,7 @@ from common.time import (
)
from common.utils import safe_division, truncate
from games.forms import GameForm
from games.models import Edition, Game, Purchase, Session
from games.models import Game, Purchase
from games.views.general import use_custom_redirect
@ -109,7 +109,7 @@ def add_game(request: HttpRequest) -> HttpResponse:
game = form.save()
if "submit_and_redirect" in request.POST:
return HttpResponseRedirect(
reverse("add_edition_for_game", kwargs={"game_id": game.id})
reverse("add_purchase_for_game", kwargs={"game_id": game.id})
)
else:
return redirect("list_games")
@ -158,21 +158,12 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
),
to_attr="game_purchases",
)
editions = (
Edition.objects.filter(game=game)
.prefetch_related(game_purchases_prefetch)
.order_by("year_released")
)
purchases = Purchase.objects.filter(editions__game=game).order_by("date_purchased")
purchases = game.purchases.order_by("date_purchased")
sessions = Session.objects.prefetch_related("device").filter(
purchase__editions__game=game
)
sessions = game.sessions
session_count = sessions.count()
session_count_without_manual = (
Session.objects.without_manual().filter(purchase__editions__game=game).count()
)
session_count_without_manual = game.sessions.without_manual().count()
if sessions:
playrange_start = local_strftime(sessions.earliest().timestamp_start, "%b %Y")
@ -193,38 +184,6 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
format_duration(sessions.calculated_duration_unformatted(), "%2.1H")
)
edition_data: dict[str, Any] = {
"columns": [
"Name",
"Year Released",
"Actions",
],
"rows": [
[
NameWithIcon(edition_id=edition.pk),
edition.year_released,
render_to_string(
"cotton/button_group.html",
{
"buttons": [
{
"href": reverse("edit_edition", args=[edition.pk]),
"slot": Icon("edit"),
"color": "gray",
},
{
"href": reverse("delete_edition", args=[edition.pk]),
"slot": Icon("delete"),
"color": "red",
},
]
},
),
]
for edition in editions
],
}
purchase_data: dict[str, Any] = {
"columns": ["Name", "Type", "Date", "Price", "Actions"],
"rows": [
@ -255,9 +214,8 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
],
}
sessions_all = Session.objects.filter(purchase__editions__game=game).order_by(
"-timestamp_start"
)
sessions_all = game.sessions.order_by("-timestamp_start")
last_session = None
if sessions_all.exists():
last_session = sessions_all.latest()
@ -284,7 +242,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
args=[last_session.pk],
),
children=Popover(
popover_content=last_session.purchase.first_edition.name,
popover_content=last_session.game.name,
children=[
Button(
icon=True,
@ -292,9 +250,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
size="xs",
children=[
Icon("play"),
truncate(
f"{last_session.purchase.first_edition.name}"
),
truncate(f"{last_session.game.name}"),
],
)
],
@ -304,7 +260,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
else "",
],
),
"columns": ["Edition", "Date", "Duration", "Actions"],
"columns": ["Game", "Date", "Duration", "Actions"],
"rows": [
[
NameWithIcon(
@ -354,11 +310,9 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
}
context: dict[str, Any] = {
"edition_count": editions.count(),
"editions": editions,
"game": game,
"playrange": playrange,
"purchase_count": Purchase.objects.filter(editions__game=game).count(),
"purchase_count": game.purchases.count(),
"session_average_without_manual": round(
safe_division(
total_hours_without_manual, int(session_count_without_manual)
@ -369,7 +323,6 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
"sessions": sessions,
"title": f"Game Overview - {game.name}",
"hours_sum": total_hours,
"edition_data": edition_data,
"purchase_data": purchase_data,
"session_data": session_data,
"session_page_obj": session_page_obj,

View File

@ -11,13 +11,12 @@ from django.urls import reverse
from common.time import available_stats_year_range, dateformat, format_duration
from common.utils import safe_division
from games.models import Edition, Game, Platform, Purchase, Session
from games.models import Game, Platform, Purchase, Session
def model_counts(request: HttpRequest) -> dict[str, bool]:
return {
"game_available": Game.objects.exists(),
"edition_available": Edition.objects.exists(),
"platform_available": Platform.objects.exists(),
"purchase_available": Purchase.objects.exists(),
"session_count": Session.objects.exists(),
@ -49,9 +48,7 @@ def use_custom_redirect(
@login_required
def stats_alltime(request: HttpRequest) -> HttpResponse:
year = "Alltime"
this_year_sessions = Session.objects.all().prefetch_related(
Prefetch("purchase__editions")
)
this_year_sessions = Session.objects.all().prefetch_related(Prefetch("game"))
this_year_sessions_with_durations = this_year_sessions.annotate(
duration=ExpressionWrapper(
F("timestamp_end") - F("timestamp_start"),
@ -59,11 +56,9 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
)
)
longest_session = this_year_sessions_with_durations.order_by("-duration").first()
this_year_games = Game.objects.filter(
editions__purchase__session__in=this_year_sessions
).distinct()
this_year_games = Game.objects.filter(sessions__in=this_year_sessions).distinct()
this_year_games_with_session_counts = this_year_games.annotate(
session_count=Count("editions__purchase__session"),
session_count=Count("sessions"),
)
game_highest_session_count = this_year_games_with_session_counts.order_by(
"-session_count"
@ -76,11 +71,11 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
.aggregate(dates=Count("date"))
)
this_year_played_purchases = Purchase.objects.filter(
session__in=this_year_sessions
games__sessions__in=this_year_sessions
).distinct()
this_year_purchases = Purchase.objects.all()
this_year_purchases_with_currency = this_year_purchases.select_related("editions")
this_year_purchases_with_currency = this_year_purchases.select_related("games")
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter(
date_refunded=None
)
@ -129,11 +124,10 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
total_spent = this_year_spendings["total_spent"] or 0
games_with_playtime = (
Game.objects.filter(editions__purchase__session__in=this_year_sessions)
Game.objects.filter(sessions__in=this_year_sessions)
.annotate(
total_playtime=Sum(
F("editions__purchase__session__duration_calculated")
+ F("editions__purchase__session__duration_manual")
F("sessions__duration_calculated") + F("sessions__duration_manual")
)
)
.values("id", "name", "total_playtime")
@ -148,10 +142,8 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
month["playtime"] = format_duration(month["playtime"], "%2.0H")
highest_session_average_game = (
Game.objects.filter(editions__purchase__session__in=this_year_sessions)
.annotate(
session_average=Avg("editions__purchase__session__duration_calculated")
)
Game.objects.filter(sessions__in=this_year_sessions)
.annotate(session_average=Avg("sessions__duration_calculated"))
.order_by("-session_average")
.first()
)
@ -160,9 +152,9 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H")
total_playtime_per_platform = (
this_year_sessions.values("purchase__platform__name")
this_year_sessions.values("game__platform__name")
.annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual")))
.annotate(platform_name=F("purchase__platform__name"))
.annotate(platform_name=F("game__platform__name"))
.values("platform_name", "total_playtime")
.order_by("-total_playtime")
)
@ -177,10 +169,10 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
last_play_date = "N/A"
if this_year_sessions:
first_session = this_year_sessions.earliest()
first_play_game = first_session.purchase.first_edition.game
first_play_game = first_session.game
first_play_date = first_session.timestamp_start.strftime(dateformat)
last_session = this_year_sessions.latest()
last_play_game = last_session.purchase.first_edition.game
last_play_game = last_session.game
last_play_date = last_session.timestamp_start.strftime(dateformat)
all_purchased_this_year_count = this_year_purchases_with_currency.count()
@ -228,9 +220,7 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
if longest_session
else 0
),
"longest_session_game": (
longest_session.purchase.first_edition.game if longest_session else None
),
"longest_session_game": (longest_session.game if longest_session else None),
"highest_session_count": (
game_highest_session_count.session_count
if game_highest_session_count
@ -268,7 +258,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
return HttpResponseRedirect(reverse("stats_alltime"))
this_year_sessions = Session.objects.filter(
timestamp_start__year=year
).prefetch_related("purchase__editions")
).prefetch_related("game")
this_year_sessions_with_durations = this_year_sessions.annotate(
duration=ExpressionWrapper(
F("timestamp_end") - F("timestamp_start"),
@ -276,13 +266,11 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
)
)
longest_session = this_year_sessions_with_durations.order_by("-duration").first()
this_year_games = Game.objects.filter(
edition__purchases__session__in=this_year_sessions
).distinct()
this_year_games = Game.objects.filter(sessions__in=this_year_sessions).distinct()
this_year_games_with_session_counts = this_year_games.annotate(
session_count=Count(
"edition__purchases__session",
filter=Q(edition__purchases__session__timestamp_start__year=year),
"sessions",
filter=Q(sessions__timestamp_start__year=year),
)
)
game_highest_session_count = this_year_games_with_session_counts.order_by(
@ -296,11 +284,11 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
.aggregate(dates=Count("date"))
)
this_year_played_purchases = Purchase.objects.filter(
session__in=this_year_sessions
games__sessions__in=this_year_sessions
).distinct()
this_year_purchases = Purchase.objects.filter(date_purchased__year=year)
this_year_purchases_with_currency = this_year_purchases.prefetch_related("editions")
this_year_purchases_with_currency = this_year_purchases.prefetch_related("games")
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter(
date_refunded=None
).exclude(ownership_type=Purchase.DEMO)
@ -337,7 +325,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
purchases_finished_this_year = Purchase.objects.filter(date_finished__year=year)
purchases_finished_this_year_released_this_year = (
purchases_finished_this_year.filter(editions__year_released=year).order_by(
purchases_finished_this_year.filter(games__year_released=year).order_by(
"date_finished"
)
)
@ -351,11 +339,10 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
total_spent = this_year_spendings["total_spent"] or 0
games_with_playtime = (
Game.objects.filter(edition__purchases__session__in=this_year_sessions)
Game.objects.filter(sessions__in=this_year_sessions)
.annotate(
total_playtime=Sum(
F("edition__purchases__session__duration_calculated")
+ F("edition__purchases__session__duration_manual")
F("sessions__duration_calculated") + F("sessions__duration_manual")
)
)
.values("id", "name", "total_playtime")
@ -370,10 +357,8 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
month["playtime"] = format_duration(month["playtime"], "%2.0H")
highest_session_average_game = (
Game.objects.filter(edition__purchases__session__in=this_year_sessions)
.annotate(
session_average=Avg("edition__purchases__session__duration_calculated")
)
Game.objects.filter(sessions__in=this_year_sessions)
.annotate(session_average=Avg("sessions__duration_calculated"))
.order_by("-session_average")
.first()
)
@ -382,9 +367,9 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H")
total_playtime_per_platform = (
this_year_sessions.values("purchase__platform__name")
this_year_sessions.values("game__platform__name")
.annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual")))
.annotate(platform_name=F("purchase__platform__name"))
.annotate(platform_name=F("game__platform__name"))
.values("platform_name", "total_playtime")
.order_by("-total_playtime")
)
@ -403,10 +388,10 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
last_play_game = None
if this_year_sessions:
first_session = this_year_sessions.earliest()
first_play_game = first_session.purchase.first_edition.game
first_play_game = first_session.game
first_play_date = first_session.timestamp_start.strftime(dateformat)
last_session = this_year_sessions.latest()
last_play_game = last_session.purchase.first_edition.game
last_play_game = last_session.game
last_play_date = last_session.timestamp_start.strftime(dateformat)
all_purchased_this_year_count = this_year_purchases_with_currency.count()
@ -423,7 +408,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
),
"total_games": this_year_played_purchases.count(),
"total_2023_games": this_year_played_purchases.filter(
editions__year_released=year
games__year_released=year
).count(),
"top_10_games_by_playtime": top_10_games_by_playtime,
"year": year,
@ -435,15 +420,15 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
safe_division(total_spent, this_year_purchases_without_refunded_count)
),
"all_finished_this_year": purchases_finished_this_year.prefetch_related(
"editions"
"games"
).order_by("date_finished"),
"all_finished_this_year_count": purchases_finished_this_year.count(),
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.prefetch_related(
"editions"
"games"
).order_by("date_finished"),
"this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(),
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.prefetch_related(
"editions"
"games"
).order_by("date_finished"),
"total_sessions": this_year_sessions.count(),
"unique_days": unique_days["dates"],
@ -472,9 +457,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
if longest_session
else 0
),
"longest_session_game": (
longest_session.purchase.first_edition.game if longest_session else None
),
"longest_session_game": (longest_session.game if longest_session else None),
"highest_session_count": (
game_highest_session_count.session_count
if game_highest_session_count

View File

@ -16,7 +16,7 @@ from django.utils import timezone
from common.components import A, Button, Icon, LinkedPurchase, PurchasePrice
from common.time import dateformat
from games.forms import PurchaseForm
from games.models import Edition, Purchase
from games.models import Game, Purchase
from games.views.general import use_custom_redirect
@ -138,7 +138,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
@login_required
def add_purchase(request: HttpRequest, edition_id: int = 0) -> HttpResponse:
def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
context: dict[str, Any] = {}
initial = {"date_purchased": timezone.now()}
@ -149,19 +149,20 @@ def add_purchase(request: HttpRequest, edition_id: int = 0) -> HttpResponse:
if "submit_and_redirect" in request.POST:
return HttpResponseRedirect(
reverse(
"add_session_for_purchase", kwargs={"purchase_id": purchase.id}
"add_session_for_game",
kwargs={"game_id": purchase.first_game.id},
)
)
else:
return redirect("list_purchases")
else:
if edition_id:
edition = Edition.objects.get(id=edition_id)
if game_id:
game = Game.objects.get(id=game_id)
form = PurchaseForm(
initial={
**initial,
"edition": edition,
"platform": edition.platform,
"games": [game],
"platform": game.platform,
}
)
else:
@ -226,12 +227,14 @@ def finish_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
return redirect("list_purchases")
def related_purchase_by_edition(request: HttpRequest) -> HttpResponse:
edition_id = request.GET.get("edition")
if not edition_id:
return HttpResponseBadRequest("Invalid edition_id")
def related_purchase_by_game(request: HttpRequest) -> HttpResponse:
games = request.GET.getlist("games")
if not games:
return HttpResponseBadRequest("Invalid game_id")
if isinstance(games, int) or isinstance(games, str):
games = [games]
form = PurchaseForm()
form.fields["related_purchase"].queryset = Purchase.objects.filter(
edition_id=edition_id, type=Purchase.GAME
).order_by("edition__sort_name")
games__in=games, type=Purchase.GAME
).order_by("games__sort_name")
return render(request, "partials/related_purchase_field.html", {"form": form})

View File

@ -28,7 +28,7 @@ from common.time import (
)
from common.utils import truncate
from games.forms import SessionForm
from games.models import Purchase, Session
from games.models import Game, Session
from games.views.general import use_custom_redirect
@ -37,13 +37,13 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
context: dict[Any, Any] = {}
page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10)
sessions = Session.objects.order_by("-timestamp_start")
sessions = Session.objects.order_by("-timestamp_start", "created_at")
search_string = request.GET.get("search_string", search_string)
if search_string != "":
sessions = sessions.filter(
Q(purchase__edition__name__icontains=search_string)
| Q(purchase__edition__game__name__icontains=search_string)
| Q(purchase__platform__name__icontains=search_string)
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)
)
@ -97,7 +97,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
args=[last_session.pk],
),
children=Popover(
popover_content=last_session.purchase.first_edition.name,
popover_content=last_session.game.name,
children=[
Button(
icon=True,
@ -105,9 +105,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
size="xs",
children=[
Icon("play"),
truncate(
f"{last_session.purchase.first_edition.name}"
),
truncate(f"{last_session.game.name}"),
],
)
],
@ -191,13 +189,13 @@ def search_sessions(request: HttpRequest) -> HttpResponse:
@login_required
def add_session(request: HttpRequest, purchase_id: int = 0) -> HttpResponse:
def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
context = {}
initial: dict[str, Any] = {"timestamp_start": timezone.now()}
last = Session.objects.last()
if last != None:
initial["purchase"] = last.purchase
initial["game"] = last.game
if request.method == "POST":
form = SessionForm(request.POST or None, initial=initial)
@ -205,12 +203,12 @@ def add_session(request: HttpRequest, purchase_id: int = 0) -> HttpResponse:
form.save()
return redirect("list_sessions")
else:
if purchase_id:
purchase = Purchase.objects.get(id=purchase_id)
if game_id:
game = Game.objects.get(id=game_id)
form = SessionForm(
initial={
**initial,
"purchase": purchase,
"game": game,
}
)
else:

View File

@ -10,7 +10,7 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
django.setup()
from django.conf import settings
from games.models import Edition, Game, Platform, Purchase, Session
from games.models import Game, Platform, Purchase, Session
ZONEINFO = ZoneInfo(settings.TIME_ZONE)
@ -21,10 +21,8 @@ class PathWorksTest(TestCase):
pl.save()
g = Game(name="The Test Game")
g.save()
e = Edition(game=g, name="The Test Game Edition", platform=pl)
e.save()
p = Purchase(
edition=e,
games=[e],
platform=pl,
date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO),
)
@ -53,11 +51,6 @@ class PathWorksTest(TestCase):
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_add_edition_returns_200(self):
url = reverse("add_edition")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_add_purchase_returns_200(self):
url = reverse("add_purchase")
response = self.client.get(url)

View File

@ -3,14 +3,13 @@ from datetime import datetime
from zoneinfo import ZoneInfo
import django
from django.db import models
from django.test import TestCase
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
django.setup()
from django.conf import settings
from games.models import Edition, Game, Purchase, Session
from games.models import Game, Purchase, Session
ZONEINFO = ZoneInfo(settings.TIME_ZONE)
@ -22,10 +21,8 @@ class FormatDurationTest(TestCase):
def test_duration_format(self):
g = Game(name="The Test Game")
g.save()
e = Edition(game=g, name="The Test Game Edition")
e.save()
p = Purchase(
edition=e, date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO)
game=g, date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO)
)
p.save()
s = Session(