Add emulated property to sessions
This commit is contained in:
		@ -8,6 +8,7 @@
 | 
			
		||||
* Add all-time stats
 | 
			
		||||
* Manage purchases
 | 
			
		||||
* Automatically convert purchase prices
 | 
			
		||||
* Add emulated property to sessions
 | 
			
		||||
 | 
			
		||||
## Improved
 | 
			
		||||
* mark refunded purchases red on game overview
 | 
			
		||||
 | 
			
		||||
@ -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 Purchase
 | 
			
		||||
from games.models import Edition, Game, Purchase, Session
 | 
			
		||||
 | 
			
		||||
HTMLAttribute = tuple[str, str | int | bool]
 | 
			
		||||
HTMLTag = str
 | 
			
		||||
@ -32,7 +32,7 @@ def Component(
 | 
			
		||||
        attributesList = [f'{name}="{value}"' for name, value in attributes]
 | 
			
		||||
        # make attribute list into a string
 | 
			
		||||
        # and insert space between tag and attribute list
 | 
			
		||||
        attributesBlob = f" {" ".join(attributesList)}"
 | 
			
		||||
        attributesBlob = f" {' '.join(attributesList)}"
 | 
			
		||||
    tag: str = ""
 | 
			
		||||
    if tag_name != "":
 | 
			
		||||
        tag = f"<{tag_name}{attributesBlob}>{childrenBlob}</{tag_name}>"
 | 
			
		||||
@ -188,27 +188,6 @@ def Icon(
 | 
			
		||||
    return result
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def LinkedNameWithPlatformIcon(name: str, game_id: int, platform: str) -> SafeText:
 | 
			
		||||
    link = reverse("view_game", args=[int(game_id)])
 | 
			
		||||
    a_content = Div(
 | 
			
		||||
        [("class", "inline-flex gap-2 items-center")],
 | 
			
		||||
        [
 | 
			
		||||
            Icon(
 | 
			
		||||
                platform.icon,
 | 
			
		||||
                [("title", platform.name)],
 | 
			
		||||
            ),
 | 
			
		||||
            PopoverTruncated(name),
 | 
			
		||||
        ],
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    return mark_safe(
 | 
			
		||||
        A(
 | 
			
		||||
            url=link,
 | 
			
		||||
            children=[a_content],
 | 
			
		||||
        ),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def LinkedPurchase(purchase: Purchase) -> SafeText:
 | 
			
		||||
    link = reverse("view_purchase", args=[int(purchase.id)])
 | 
			
		||||
    link_content = ""
 | 
			
		||||
@ -250,19 +229,63 @@ def LinkedPurchase(purchase: Purchase) -> SafeText:
 | 
			
		||||
    return mark_safe(A(url=link, children=[a_content]))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def NameWithPlatformIcon(name: str, platform: str) -> SafeText:
 | 
			
		||||
def NameWithIcon(
 | 
			
		||||
    name: str = "",
 | 
			
		||||
    platform: str = "",
 | 
			
		||||
    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:
 | 
			
		||||
        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
 | 
			
		||||
        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
 | 
			
		||||
        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
 | 
			
		||||
        link = reverse("view_game", args=[int(game_id)])
 | 
			
		||||
    content = Div(
 | 
			
		||||
        [("class", "inline-flex gap-2 items-center")],
 | 
			
		||||
        [
 | 
			
		||||
            Icon(
 | 
			
		||||
                platform.icon,
 | 
			
		||||
                [("title", platform.name)],
 | 
			
		||||
            ),
 | 
			
		||||
            )
 | 
			
		||||
            if platform
 | 
			
		||||
            else "",
 | 
			
		||||
            Icon("emulated", [("title", "Emulated")]) if emulated else "",
 | 
			
		||||
            PopoverTruncated(name),
 | 
			
		||||
        ],
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    return mark_safe(content)
 | 
			
		||||
    return mark_safe(
 | 
			
		||||
        A(
 | 
			
		||||
            url=link,
 | 
			
		||||
            children=[content],
 | 
			
		||||
        )
 | 
			
		||||
        if create_link
 | 
			
		||||
        else content,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def PurchasePrice(purchase) -> str:
 | 
			
		||||
 | 
			
		||||
@ -33,6 +33,7 @@ class SessionForm(forms.ModelForm):
 | 
			
		||||
            "timestamp_start",
 | 
			
		||||
            "timestamp_end",
 | 
			
		||||
            "duration_manual",
 | 
			
		||||
            "emulated",
 | 
			
		||||
            "device",
 | 
			
		||||
            "note",
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										18
									
								
								games/migrations/0046_session_emulated.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								games/migrations/0046_session_emulated.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,18 @@
 | 
			
		||||
# Generated by Django 5.1.3 on 2025-01-29 11:49
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('games', '0045_alter_purchase_editions'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='session',
 | 
			
		||||
            name='emulated',
 | 
			
		||||
            field=models.BooleanField(default=False),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -141,6 +141,10 @@ class Purchase(models.Model):
 | 
			
		||||
    )
 | 
			
		||||
    created_at = models.DateTimeField(auto_now_add=True)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def standardized_name(self):
 | 
			
		||||
        return self.name if self.name else self.first_edition.name
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def first_edition(self):
 | 
			
		||||
        return self.editions.first()
 | 
			
		||||
@ -220,6 +224,8 @@ class Session(models.Model):
 | 
			
		||||
        default=None,
 | 
			
		||||
    )
 | 
			
		||||
    note = models.TextField(blank=True, null=True)
 | 
			
		||||
    emulated = models.BooleanField(default=False)
 | 
			
		||||
 | 
			
		||||
    created_at = models.DateTimeField(auto_now_add=True)
 | 
			
		||||
    modified_at = models.DateTimeField(auto_now=True)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1443,10 +1443,6 @@ input:checked + .toggle-bg {
 | 
			
		||||
  margin-top: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.ml-4 {
 | 
			
		||||
  margin-left: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.block {
 | 
			
		||||
  display: block;
 | 
			
		||||
}
 | 
			
		||||
@ -1475,10 +1471,6 @@ input:checked + .toggle-bg {
 | 
			
		||||
  display: grid;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.list-item {
 | 
			
		||||
  display: list-item;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.hidden {
 | 
			
		||||
  display: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										6
									
								
								games/templates/cotton/icon/emulated.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								games/templates/cotton/icon/emulated.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,6 @@
 | 
			
		||||
<c-vars title="Emulated" />
 | 
			
		||||
<c-svg :title=title viewbox="0 0 48 48">
 | 
			
		||||
<c-slot name="path">
 | 
			
		||||
    M 8.5 5 C 6.0324991 5 4 7.0324991 4 9.5 L 4 30.5 C 4 32.967501 6.0324991 35 8.5 35 L 17 35 L 17 40 L 13.5 40 A 1.50015 1.50015 0 1 0 13.5 43 L 18.253906 43 A 1.50015 1.50015 0 0 0 18.740234 43 L 29.253906 43 A 1.50015 1.50015 0 0 0 29.740234 43 L 34.5 43 A 1.50015 1.50015 0 1 0 34.5 40 L 31 40 L 31 35 L 39.5 35 C 41.967501 35 44 32.967501 44 30.5 L 44 9.5 C 44 7.0324991 41.967501 5 39.5 5 L 8.5 5 z M 8.5 8 L 39.5 8 C 40.346499 8 41 8.6535009 41 9.5 L 41 30.5 C 41 31.346499 40.346499 32 39.5 32 L 29.746094 32 A 1.50015 1.50015 0 0 0 29.259766 32 L 18.746094 32 A 1.50015 1.50015 0 0 0 18.259766 32 L 8.5 32 C 7.6535009 32 7 31.346499 7 30.5 L 7 9.5 C 7 8.6535009 7.6535009 8 8.5 8 z M 17.5 12 C 16.136406 12 15 13.136406 15 14.5 L 15 25.5 C 15 26.863594 16.136406 28 17.5 28 L 30.5 28 C 31.863594 28 33 26.863594 33 25.5 L 33 14.5 C 33 13.136406 31.863594 12 30.5 12 L 17.5 12 z M 18 18 L 30 18 L 30 25 L 18 25 L 18 18 z M 20 35 L 28 35 L 28 40 L 20 40 L 20 35 z
 | 
			
		||||
</c-slot>
 | 
			
		||||
</c-svg>
 | 
			
		||||
@ -11,7 +11,7 @@ from common.components import (
 | 
			
		||||
    A,
 | 
			
		||||
    Button,
 | 
			
		||||
    Icon,
 | 
			
		||||
    LinkedNameWithPlatformIcon,
 | 
			
		||||
    NameWithIcon,
 | 
			
		||||
    PopoverTruncated,
 | 
			
		||||
)
 | 
			
		||||
from common.time import dateformat, local_strftime
 | 
			
		||||
@ -54,11 +54,7 @@ def list_editions(request: HttpRequest) -> HttpResponse:
 | 
			
		||||
            ],
 | 
			
		||||
            "rows": [
 | 
			
		||||
                [
 | 
			
		||||
                    LinkedNameWithPlatformIcon(
 | 
			
		||||
                        name=edition.name,
 | 
			
		||||
                        game_id=edition.game.id,
 | 
			
		||||
                        platform=edition.platform,
 | 
			
		||||
                    ),
 | 
			
		||||
                    NameWithIcon(edition_id=edition.pk),
 | 
			
		||||
                    PopoverTruncated(
 | 
			
		||||
                        edition.name
 | 
			
		||||
                        if edition.game.name != edition.name
 | 
			
		||||
 | 
			
		||||
@ -14,7 +14,7 @@ from common.components import (
 | 
			
		||||
    Div,
 | 
			
		||||
    Icon,
 | 
			
		||||
    LinkedPurchase,
 | 
			
		||||
    NameWithPlatformIcon,
 | 
			
		||||
    NameWithIcon,
 | 
			
		||||
    Popover,
 | 
			
		||||
    PopoverTruncated,
 | 
			
		||||
    PurchasePrice,
 | 
			
		||||
@ -67,18 +67,7 @@ def list_games(request: HttpRequest) -> HttpResponse:
 | 
			
		||||
            ],
 | 
			
		||||
            "rows": [
 | 
			
		||||
                [
 | 
			
		||||
                    A(
 | 
			
		||||
                        [
 | 
			
		||||
                            (
 | 
			
		||||
                                "href",
 | 
			
		||||
                                reverse(
 | 
			
		||||
                                    "view_game",
 | 
			
		||||
                                    args=[game.pk],
 | 
			
		||||
                                ),
 | 
			
		||||
                            )
 | 
			
		||||
                        ],
 | 
			
		||||
                        PopoverTruncated(game.name),
 | 
			
		||||
                    ),
 | 
			
		||||
                    NameWithIcon(game_id=game.pk),
 | 
			
		||||
                    PopoverTruncated(
 | 
			
		||||
                        game.sort_name
 | 
			
		||||
                        if game.sort_name is not None and game.name != game.sort_name
 | 
			
		||||
@ -212,10 +201,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
 | 
			
		||||
        ],
 | 
			
		||||
        "rows": [
 | 
			
		||||
            [
 | 
			
		||||
                NameWithPlatformIcon(
 | 
			
		||||
                    name=edition.name,
 | 
			
		||||
                    platform=edition.platform,
 | 
			
		||||
                ),
 | 
			
		||||
                NameWithIcon(edition_id=edition.pk),
 | 
			
		||||
                edition.year_released,
 | 
			
		||||
                render_to_string(
 | 
			
		||||
                    "cotton/button_group.html",
 | 
			
		||||
@ -321,13 +307,10 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
 | 
			
		||||
        "columns": ["Edition", "Date", "Duration", "Actions"],
 | 
			
		||||
        "rows": [
 | 
			
		||||
            [
 | 
			
		||||
                NameWithPlatformIcon(
 | 
			
		||||
                    name=session.purchase.name
 | 
			
		||||
                    if session.purchase.name
 | 
			
		||||
                    else session.purchase.first_edition.name,
 | 
			
		||||
                    platform=session.purchase.platform,
 | 
			
		||||
                NameWithIcon(
 | 
			
		||||
                    session_id=session.pk,
 | 
			
		||||
                ),
 | 
			
		||||
                f"{local_strftime(session.timestamp_start)}{f" — {local_strftime(session.timestamp_end, timeformat)}" if session.timestamp_end else ""}",
 | 
			
		||||
                f"{local_strftime(session.timestamp_start)}{f' — {local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
 | 
			
		||||
                (
 | 
			
		||||
                    format_duration(session.duration_calculated, durationformat)
 | 
			
		||||
                    if session.duration_calculated
 | 
			
		||||
 | 
			
		||||
@ -15,7 +15,7 @@ from common.components import (
 | 
			
		||||
    Div,
 | 
			
		||||
    Form,
 | 
			
		||||
    Icon,
 | 
			
		||||
    LinkedNameWithPlatformIcon,
 | 
			
		||||
    NameWithIcon,
 | 
			
		||||
    Popover,
 | 
			
		||||
)
 | 
			
		||||
from common.time import (
 | 
			
		||||
@ -130,12 +130,8 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
 | 
			
		||||
            ],
 | 
			
		||||
            "rows": [
 | 
			
		||||
                [
 | 
			
		||||
                    LinkedNameWithPlatformIcon(
 | 
			
		||||
                        name=session.purchase.first_edition.name,
 | 
			
		||||
                        game_id=session.purchase.first_edition.game.pk,
 | 
			
		||||
                        platform=session.purchase.platform,
 | 
			
		||||
                    ),
 | 
			
		||||
                    f"{local_strftime(session.timestamp_start)}{f" — {local_strftime(session.timestamp_end, timeformat)}" if session.timestamp_end else ""}",
 | 
			
		||||
                    NameWithIcon(session_id=session.pk),
 | 
			
		||||
                    f"{local_strftime(session.timestamp_start)}{f' — {local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
 | 
			
		||||
                    (
 | 
			
		||||
                        format_duration(session.duration_calculated, durationformat)
 | 
			
		||||
                        if session.duration_calculated
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user