4 Commits

Author SHA1 Message Date
555608d8c6 Fix syntax
All checks were successful
Django CI/CD / build-and-push (push) Successful in 1m14s
2023-11-18 09:52:17 +01:00
a7293c659d CI: Ignore README.md 2023-11-18 09:33:31 +01:00
f36e692361 Do not run for pull requests 2023-11-18 09:33:10 +01:00
fe97f540a0 Fix CI being blocked 2023-11-18 09:32:41 +01:00
40 changed files with 357 additions and 1151 deletions

View File

@ -0,0 +1,25 @@
name: Django CI/CD
on:
push:
branches: [ main ]
paths-ignore: [ 'README.md' ]
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
registry.kucharczyk.xyz/timetracker:latest
registry.kucharczyk.xyz/timetracker:${{ env.VERSION_NUMBER }}
env:
VERSION_NUMBER: 1.5.1

View File

@ -1,36 +0,0 @@
name: Django CI/CD
on:
push:
paths-ignore: [ 'README.md' ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: 3.12
- run: |
python -m pip install poetry
poetry install
poetry env info
poetry run python manage.py migrate
PROD=1 poetry run pytest
build-and-push:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
registry.kucharczyk.xyz/timetracker:latest
registry.kucharczyk.xyz/timetracker:${{ env.VERSION_NUMBER }}
env:
VERSION_NUMBER: 1.5.1

4
.gitignore vendored
View File

@ -1,9 +1,9 @@
__pycache__ __pycache__
.mypy_cache .mypy_cache
.pytest_cache .pytest_cache
.venv/ .venv
node_modules node_modules
package-lock.json package-lock.json
db.sqlite3 db.sqlite3
/static/ /static/
dist/ dist/

View File

@ -8,8 +8,3 @@ repos:
hooks: hooks:
- id: isort - id: isort
name: isort (python) name: isort (python)
- repo: https://github.com/Riverside-Healthcare/djLint
rev: v1.34.0
hooks:
- id: djlint-reformat-django
- id: djlint-django

View File

@ -1,13 +1,8 @@
## 1.5.2 / 2024-01-14 21:27+01:00 ## Unreleased
## Improved ## Improved
* game overview: * game overview: improve how editions and purchases are displayed
* improve how editions and purchases are displayed
* make it possible to end session from overview
* add purchase: only allow choosing purchases of selected edition * add purchase: only allow choosing purchases of selected edition
* session list:
* starting and ending sessions is much faster/doest not reload the page
* listing sessions is much faster
## 1.5.1 / 2023-11-14 21:10+01:00 ## 1.5.1 / 2023-11-14 21:10+01:00

View File

@ -1,6 +1,6 @@
FROM python:3.12.0-slim-bullseye FROM python:3.12.0-slim-bullseye
ENV VERSION_NUMBER=1.5.2 \ ENV VERSION_NUMBER=1.5.1 \
PROD=1 \ PROD=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PYTHONFAULTHANDLER=1 \ PYTHONFAULTHANDLER=1 \

View File

@ -23,12 +23,6 @@
font-style: normal; font-style: normal;
} }
a:hover {
text-decoration-color: #ff4400;
color: rgb(254, 185, 160);
transition: all 0.2s ease-out;
}
form label { form label {
@apply dark:text-slate-400; @apply dark:text-slate-400;
} }

View File

@ -83,7 +83,6 @@ class PurchaseForm(forms.ModelForm):
"date_purchased": custom_date_widget, "date_purchased": custom_date_widget,
"date_refunded": custom_date_widget, "date_refunded": custom_date_widget,
"date_finished": custom_date_widget, "date_finished": custom_date_widget,
"date_dropped": custom_date_widget,
} }
model = Purchase model = Purchase
fields = [ fields = [
@ -92,8 +91,6 @@ class PurchaseForm(forms.ModelForm):
"date_purchased", "date_purchased",
"date_refunded", "date_refunded",
"date_finished", "date_finished",
"date_dropped",
"infinite",
"price", "price",
"price_currency", "price_currency",
"ownership_type", "ownership_type",

View File

@ -1 +0,0 @@
from .game import Mutation as GameMutation

View File

@ -1,29 +0,0 @@
import graphene
from games.graphql.types import Game
from games.models import Game as GameModel
class UpdateGameMutation(graphene.Mutation):
class Arguments:
id = graphene.ID(required=True)
name = graphene.String()
year_released = graphene.Int()
wikidata = graphene.String()
game = graphene.Field(Game)
def mutate(self, info, id, name=None, year_released=None, wikidata=None):
game_instance = GameModel.objects.get(pk=id)
if name is not None:
game_instance.name = name
if year_released is not None:
game_instance.year_released = year_released
if wikidata is not None:
game_instance.wikidata = wikidata
game_instance.save()
return UpdateGameMutation(game=game_instance)
class Mutation(graphene.ObjectType):
update_game = UpdateGameMutation.Field()

View File

@ -1,6 +0,0 @@
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
from .session import Query as SessionQuery

View File

@ -1,11 +0,0 @@
import graphene
from games.graphql.types import Device
from games.models import Device as DeviceModel
class Query(graphene.ObjectType):
devices = graphene.List(Device)
def resolve_devices(self, info, **kwargs):
return DeviceModel.objects.all()

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

@ -1,18 +0,0 @@
import graphene
from games.graphql.types import Game
from games.models import Game as GameModel
class Query(graphene.ObjectType):
games = graphene.List(Game)
game_by_name = graphene.Field(Game, name=graphene.String(required=True))
def resolve_games(self, info, **kwargs):
return GameModel.objects.all()
def resolve_game_by_name(self, info, name):
try:
return GameModel.objects.get(name=name)
except GameModel.DoesNotExist:
return None

View File

@ -1,11 +0,0 @@
import graphene
from games.graphql.types import Platform
from games.models import Platform as PlatformModel
class Query(graphene.ObjectType):
platforms = graphene.List(Platform)
def resolve_platforms(self, info, **kwargs):
return PlatformModel.objects.all()

View File

@ -1,11 +0,0 @@
import graphene
from games.graphql.types import Purchase
from games.models import Purchase as PurchaseModel
class Query(graphene.ObjectType):
purchases = graphene.List(Purchase)
def resolve_purchases(self, info, **kwargs):
return PurchaseModel.objects.all()

View File

@ -1,11 +0,0 @@
import graphene
from games.graphql.types import Session
from games.models import Session as SessionModel
class Query(graphene.ObjectType):
sessions = graphene.List(Session)
def resolve_sessions(self, info, **kwargs):
return SessionModel.objects.all()

View File

@ -1,44 +0,0 @@
from graphene_django import DjangoObjectType
from games.models import Device as DeviceModel
from games.models import Edition as EditionModel
from games.models import Game as GameModel
from games.models import Platform as PlatformModel
from games.models import Purchase as PurchaseModel
from games.models import Session as SessionModel
class Game(DjangoObjectType):
class Meta:
model = GameModel
fields = "__all__"
class Edition(DjangoObjectType):
class Meta:
model = EditionModel
fields = "__all__"
class Purchase(DjangoObjectType):
class Meta:
model = PurchaseModel
fields = "__all__"
class Session(DjangoObjectType):
class Meta:
model = SessionModel
fields = "__all__"
class Platform(DjangoObjectType):
class Meta:
model = PlatformModel
fields = "__all__"
class Device(DjangoObjectType):
class Meta:
model = DeviceModel
fields = "__all__"

View File

@ -1,17 +0,0 @@
# Generated by Django 4.2.7 on 2023-11-28 13:43
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("games", "0032_alter_session_options_session_modified_at_and_more"),
]
operations = [
migrations.AlterUniqueTogether(
name="edition",
unique_together={("name", "platform", "year_released")},
),
]

View File

@ -1,23 +0,0 @@
# Generated by Django 4.2.7 on 2024-01-03 21:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0033_alter_edition_unique_together"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="date_dropped",
field=models.DateField(blank=True, null=True),
),
migrations.AddField(
model_name="purchase",
name="infinite",
field=models.BooleanField(default=False),
),
]

View File

@ -34,7 +34,7 @@ class Game(models.Model):
class Edition(models.Model): class Edition(models.Model):
class Meta: class Meta:
unique_together = [["name", "platform", "year_released"]] unique_together = [["name", "platform"]]
game = models.ForeignKey("Game", on_delete=models.CASCADE) game = models.ForeignKey("Game", on_delete=models.CASCADE)
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
@ -116,8 +116,6 @@ class Purchase(models.Model):
date_purchased = models.DateField() date_purchased = models.DateField()
date_refunded = models.DateField(blank=True, null=True) date_refunded = models.DateField(blank=True, null=True)
date_finished = models.DateField(blank=True, null=True) date_finished = models.DateField(blank=True, null=True)
date_dropped = models.DateField(blank=True, null=True)
infinite = models.BooleanField(default=False)
price = models.IntegerField(default=0) price = models.IntegerField(default=0)
price_currency = models.CharField(max_length=3, default="USD") price_currency = models.CharField(max_length=3, default="USD")
ownership_type = models.CharField( ownership_type = models.CharField(

View File

@ -1,30 +0,0 @@
import graphene
from games.graphql.mutations import GameMutation
from games.graphql.queries import (
DeviceQuery,
EditionQuery,
GameQuery,
PlatformQuery,
PurchaseQuery,
SessionQuery,
)
class Query(
GameQuery,
EditionQuery,
DeviceQuery,
PlatformQuery,
PurchaseQuery,
SessionQuery,
graphene.ObjectType,
):
pass
class Mutation(GameMutation, graphene.ObjectType):
pass
schema = graphene.Schema(query=Query, mutation=Mutation)

View File

@ -1,5 +1,5 @@
/* /*
! tailwindcss v3.4.0 | MIT License | https://tailwindcss.com ! tailwindcss v3.3.3 | MIT License | https://tailwindcss.com
*/ */
/* /*
@ -32,11 +32,9 @@
4. Use the user's configured `sans` font-family by default. 4. Use the user's configured `sans` font-family by default.
5. Use the user's configured `sans` font-feature-settings by default. 5. Use the user's configured `sans` font-feature-settings by default.
6. Use the user's configured `sans` font-variation-settings by default. 6. Use the user's configured `sans` font-variation-settings by default.
7. Disable tap highlights on iOS
*/ */
html, html {
:host {
line-height: 1.5; line-height: 1.5;
/* 1 */ /* 1 */
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
@ -46,14 +44,12 @@ html,
-o-tab-size: 4; -o-tab-size: 4;
tab-size: 4; tab-size: 4;
/* 3 */ /* 3 */
font-family: IBM Plex Sans, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; font-family: IBM Plex Sans, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
/* 4 */ /* 4 */
font-feature-settings: normal; font-feature-settings: normal;
/* 5 */ /* 5 */
font-variation-settings: normal; font-variation-settings: normal;
/* 6 */ /* 6 */
-webkit-tap-highlight-color: transparent;
/* 7 */
} }
/* /*
@ -125,10 +121,8 @@ strong {
} }
/* /*
1. Use the user's configured `mono` font-family by default. 1. Use the user's configured `mono` font family by default.
2. Use the user's configured `mono` font-feature-settings by default. 2. Correct the odd `em` font sizing in all browsers.
3. Use the user's configured `mono` font-variation-settings by default.
4. Correct the odd `em` font sizing in all browsers.
*/ */
code, code,
@ -137,12 +131,8 @@ samp,
pre { pre {
font-family: IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-family: IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
/* 1 */ /* 1 */
font-feature-settings: normal;
/* 2 */
font-variation-settings: normal;
/* 3 */
font-size: 1em; font-size: 1em;
/* 4 */ /* 2 */
} }
/* /*
@ -577,26 +567,10 @@ select {
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
} }
@media (forced-colors: active) {
[type='checkbox']:checked {
-webkit-appearance: auto;
-moz-appearance: auto;
appearance: auto;
}
}
[type='radio']:checked { [type='radio']:checked {
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e"); background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e");
} }
@media (forced-colors: active) {
[type='radio']:checked {
-webkit-appearance: auto;
-moz-appearance: auto;
appearance: auto;
}
}
[type='checkbox']:checked:hover,[type='checkbox']:checked:focus,[type='radio']:checked:hover,[type='radio']:checked:focus { [type='checkbox']:checked:hover,[type='checkbox']:checked:focus,[type='radio']:checked:hover,[type='radio']:checked:focus {
border-color: transparent; border-color: transparent;
background-color: currentColor; background-color: currentColor;
@ -611,14 +585,6 @@ select {
background-repeat: no-repeat; background-repeat: no-repeat;
} }
@media (forced-colors: active) {
[type='checkbox']:indeterminate {
-webkit-appearance: auto;
-moz-appearance: auto;
appearance: auto;
}
}
[type='checkbox']:indeterminate:hover,[type='checkbox']:indeterminate:focus { [type='checkbox']:indeterminate:hover,[type='checkbox']:indeterminate:focus {
border-color: transparent; border-color: transparent;
background-color: currentColor; background-color: currentColor;
@ -834,11 +800,6 @@ select {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.mx-1 {
margin-left: 0.25rem;
margin-right: 0.25rem;
}
.mb-1 { .mb-1 {
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
@ -907,18 +868,6 @@ select {
height: 1.5rem; height: 1.5rem;
} }
.h-8 {
height: 2rem;
}
.h-2 {
height: 0.5rem;
}
.h-3 {
height: 0.75rem;
}
.min-h-screen { .min-h-screen {
min-height: 100vh; min-height: 100vh;
} }
@ -935,10 +884,6 @@ select {
width: 1.75rem; width: 1.75rem;
} }
.w-8 {
width: 2rem;
}
.w-auto { .w-auto {
width: auto; width: auto;
} }
@ -947,14 +892,6 @@ select {
width: 100%; width: 100%;
} }
.w-4 {
width: 1rem;
}
.w-3 {
width: 0.75rem;
}
.max-w-screen-lg { .max-w-screen-lg {
max-width: 1024px; max-width: 1024px;
} }
@ -967,10 +904,6 @@ select {
max-width: 20rem; max-width: 20rem;
} }
.transform {
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
@keyframes spin { @keyframes spin {
to { to {
transform: rotate(360deg); transform: rotate(360deg);
@ -1005,12 +938,6 @@ select {
gap: 0.5rem; gap: 0.5rem;
} }
.space-x-1 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0.25rem * var(--tw-space-x-reverse));
margin-left: calc(0.25rem * calc(1 - var(--tw-space-x-reverse)));
}
.self-center { .self-center {
align-self: center; align-self: center;
} }
@ -1037,10 +964,6 @@ select {
border-radius: 0.125rem; border-radius: 0.125rem;
} }
.rounded-full {
border-radius: 9999px;
}
.border-gray-200 { .border-gray-200 {
--tw-border-opacity: 1; --tw-border-opacity: 1;
border-color: rgb(229 231 235 / var(--tw-border-opacity)); border-color: rgb(229 231 235 / var(--tw-border-opacity));
@ -1071,16 +994,6 @@ select {
background-color: rgb(255 255 255 / var(--tw-bg-opacity)); background-color: rgb(255 255 255 / var(--tw-bg-opacity));
} }
.bg-green-400 {
--tw-bg-opacity: 1;
background-color: rgb(74 222 128 / var(--tw-bg-opacity));
}
.bg-green-500 {
--tw-bg-opacity: 1;
background-color: rgb(34 197 94 / var(--tw-bg-opacity));
}
.p-4 { .p-4 {
padding: 1rem; padding: 1rem;
} }
@ -1105,16 +1018,6 @@ select {
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
} }
.py-4 {
padding-top: 1rem;
padding-bottom: 1rem;
}
.px-1 {
padding-left: 0.25rem;
padding-right: 0.25rem;
}
.pl-3 { .pl-3 {
padding-left: 0.75rem; padding-left: 0.75rem;
} }
@ -1270,13 +1173,7 @@ select {
font-style: normal; font-style: normal;
} }
a:hover { :is(.dark form label) {
text-decoration-color: #ff4400;
color: rgb(254, 185, 160);
transition: all 0.2s ease-out;
}
:is(:where(.dark) form label) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(148 163 184 / var(--tw-text-opacity)); color: rgb(148 163 184 / var(--tw-text-opacity));
} }
@ -1286,7 +1183,7 @@ a:hover {
margin-right: auto; margin-right: auto;
} }
:is(:where(.dark) .responsive-table) { :is(.dark .responsive-table) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity)); color: rgb(255 255 255 / var(--tw-text-opacity));
} }
@ -1317,8 +1214,8 @@ a:hover {
border-left-color: rgb(100 116 139 / var(--tw-border-opacity)); border-left-color: rgb(100 116 139 / var(--tw-border-opacity));
} }
:is(:where(.dark) form input),:is(:where(.dark) :is(.dark form input),:is(.dark
select),:is(:where(.dark) select),:is(.dark
textarea) { textarea) {
border-width: 1px; border-width: 1px;
--tw-border-opacity: 1; --tw-border-opacity: 1;
@ -1329,8 +1226,8 @@ textarea) {
color: rgb(241 245 249 / var(--tw-text-opacity)); color: rgb(241 245 249 / var(--tw-text-opacity));
} }
:is(:where(.dark) form input:disabled),:is(:where(.dark) :is(.dark form input:disabled),:is(.dark
select:disabled),:is(:where(.dark) select:disabled),:is(.dark
textarea:disabled) { textarea:disabled) {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(51 65 85 / var(--tw-bg-opacity)); background-color: rgb(51 65 85 / var(--tw-bg-opacity));
@ -1502,6 +1399,36 @@ th label {
display: block; display: block;
} }
:is(.dark .dark\:bg-gray-800) {
--tw-bg-opacity: 1;
background-color: rgb(31 41 55 / var(--tw-bg-opacity));
}
:is(.dark .dark\:bg-gray-900) {
--tw-bg-opacity: 1;
background-color: rgb(17 24 39 / var(--tw-bg-opacity));
}
:is(.dark .dark\:text-slate-400) {
--tw-text-opacity: 1;
color: rgb(148 163 184 / var(--tw-text-opacity));
}
:is(.dark .dark\:text-slate-500) {
--tw-text-opacity: 1;
color: rgb(100 116 139 / var(--tw-text-opacity));
}
:is(.dark .dark\:text-slate-600) {
--tw-text-opacity: 1;
color: rgb(71 85 105 / var(--tw-text-opacity));
}
:is(.dark .dark\:text-white) {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
}
@media (min-width: 640px) { @media (min-width: 640px) {
.sm\:inline { .sm\:inline {
display: inline; display: inline;
@ -1550,6 +1477,10 @@ th label {
display: block; display: block;
} }
.md\:w-1\/2 {
width: 50%;
}
.md\:w-auto { .md\:w-auto {
width: auto; width: auto;
} }
@ -1586,33 +1517,3 @@ th label {
max-width: 32rem; max-width: 32rem;
} }
} }
:is(:where(.dark) .dark\:bg-gray-800) {
--tw-bg-opacity: 1;
background-color: rgb(31 41 55 / var(--tw-bg-opacity));
}
:is(:where(.dark) .dark\:bg-gray-900) {
--tw-bg-opacity: 1;
background-color: rgb(17 24 39 / var(--tw-bg-opacity));
}
:is(:where(.dark) .dark\:text-slate-400) {
--tw-text-opacity: 1;
color: rgb(148 163 184 / var(--tw-text-opacity));
}
:is(:where(.dark) .dark\:text-slate-500) {
--tw-text-opacity: 1;
color: rgb(100 116 139 / var(--tw-text-opacity));
}
:is(:where(.dark) .dark\:text-slate-600) {
--tw-text-opacity: 1;
color: rgb(71 85 105 / var(--tw-text-opacity));
}
:is(:where(.dark) .dark\:text-white) {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
}

View File

@ -1,24 +1,24 @@
import { syncSelectInputUntilChanged } from "./utils.js"; import { syncSelectInputUntilChanged } from './utils.js';
let syncData = [ let syncData = [
{ {
source: "#id_game", "source": "#id_game",
source_value: "dataset.name", "source_value": "dataset.name",
target: "#id_name", "target": "#id_name",
target_value: "value", "target_value": "value"
}, },
{ {
source: "#id_game", "source": "#id_game",
source_value: "textContent", "source_value": "textContent",
target: "#id_sort_name", "target": "#id_sort_name",
target_value: "value", "target_value": "value"
}, },
{ {
source: "#id_game", "source": "#id_game",
source_value: "dataset.year", "source_value": "dataset.year",
target: "#id_year_released", "target": "#id_year_released",
target_value: "value", "target_value": "value"
}, },
]; ]
syncSelectInputUntilChanged(syncData, "form"); syncSelectInputUntilChanged(syncData, "form");

View File

@ -1,12 +1,12 @@
import { syncSelectInputUntilChanged } from "./utils.js"; import { syncSelectInputUntilChanged } from './utils.js'
let syncData = [ let syncData = [
{ {
source: "#id_name", "source": "#id_name",
source_value: "value", "source_value": "value",
target: "#id_sort_name", "target": "#id_sort_name",
target_value: "value", "target_value": "value"
}, }
]; ]
syncSelectInputUntilChanged(syncData, "form"); syncSelectInputUntilChanged(syncData, "form")

View File

@ -2,7 +2,7 @@ import {
syncSelectInputUntilChanged, syncSelectInputUntilChanged,
getEl, getEl,
disableElementsWhenTrue, disableElementsWhenTrue,
disableElementsWhenValueNotEqual, disableElementsWhenFalse,
} from "./utils.js"; } from "./utils.js";
let syncData = [ let syncData = [
@ -21,11 +21,7 @@ function setupElementHandlers() {
"#id_name", "#id_name",
"#id_related_purchase", "#id_related_purchase",
]); ]);
disableElementsWhenValueNotEqual( disableElementsWhenFalse("#id_type", "game", ["#id_date_finished"]);
"#id_type",
["game", "dlc"],
["#id_date_finished"]
);
} }
document.addEventListener("DOMContentLoaded", setupElementHandlers); document.addEventListener("DOMContentLoaded", setupElementHandlers);
@ -34,14 +30,14 @@ getEl("#id_type").onchange = () => {
setupElementHandlers(); setupElementHandlers();
}; };
document.body.addEventListener("htmx:beforeRequest", function (event) { document.body.addEventListener('htmx:beforeRequest', function(event) {
// Assuming 'Purchase1' is the element that triggers the HTMX request // Assuming 'Purchase1' is the element that triggers the HTMX request
if (event.target.id === "id_edition") { if (event.target.id === 'id_edition') {
var idEditionValue = document.getElementById("id_edition").value; var idEditionValue = document.getElementById('id_edition').value;
// Condition to check - replace this with your actual logic // Condition to check - replace this with your actual logic
if (idEditionValue != "") { if (idEditionValue != '') {
event.preventDefault(); // This cancels the HTMX request event.preventDefault(); // This cancels the HTMX request
} }
} }
}); });

View File

@ -7,14 +7,10 @@ for (let button of document.querySelectorAll("[data-target]")) {
button.addEventListener("click", (event) => { button.addEventListener("click", (event) => {
event.preventDefault(); event.preventDefault();
if (type == "now") { if (type == "now") {
targetElement.value = toISOUTCString(new Date()); targetElement.value = toISOUTCString(new Date);
} else if (type == "copy") { } else if (type == "copy") {
const oppositeName = const oppositeName = targetElement.name == "timestamp_start" ? "timestamp_end" : "timestamp_start";
targetElement.name == "timestamp_start" document.querySelector(`[name='${oppositeName}']`).value = targetElement.value;
? "timestamp_end"
: "timestamp_start";
document.querySelector(`[name='${oppositeName}']`).value =
targetElement.value;
} else if (type == "toggle") { } else if (type == "toggle") {
if (targetElement.type == "datetime-local") targetElement.type = "text"; if (targetElement.type == "datetime-local") targetElement.type = "text";
else targetElement.type = "datetime-local"; else targetElement.type = "datetime-local";

View File

@ -75,10 +75,7 @@ function syncSelectInputUntilChanged(syncData, parentSelector = document) {
* @param {string} property - The property to retrieve the value from. * @param {string} property - The property to retrieve the value from.
*/ */
function getValueFromProperty(sourceElement, property) { function getValueFromProperty(sourceElement, property) {
let source = let source = (sourceElement instanceof HTMLSelectElement) ? sourceElement.selectedOptions[0] : sourceElement
sourceElement instanceof HTMLSelectElement
? sourceElement.selectedOptions[0]
: sourceElement;
if (property.startsWith("dataset.")) { if (property.startsWith("dataset.")) {
let datasetKey = property.slice(8); // Remove 'dataset.' part let datasetKey = property.slice(8); // Remove 'dataset.' part
return source.dataset[datasetKey]; return source.dataset[datasetKey];
@ -96,11 +93,13 @@ function getValueFromProperty(sourceElement, property) {
*/ */
function getEl(selector) { function getEl(selector) {
if (selector.startsWith("#")) { if (selector.startsWith("#")) {
return document.getElementById(selector.slice(1)); return document.getElementById(selector.slice(1))
} else if (selector.startsWith(".")) { }
return document.getElementsByClassName(selector); else if (selector.startsWith(".")) {
} else { return document.getElementsByClassName(selector)
return document.getElementsByTagName(selector); }
else {
return document.getElementsByTagName(selector)
} }
} }
@ -117,7 +116,7 @@ function getEl(selector) {
function conditionalElementHandler(...configs) { function conditionalElementHandler(...configs) {
configs.forEach(([condition, targetElements, callbackfn1, callbackfn2]) => { configs.forEach(([condition, targetElements, callbackfn1, callbackfn2]) => {
if (condition()) { if (condition()) {
targetElements.forEach((elementName) => { targetElements.forEach(elementName => {
let el = getEl(elementName); let el = getEl(elementName);
if (el === null) { if (el === null) {
console.error(`Element ${elementName} doesn't exist.`); console.error(`Element ${elementName} doesn't exist.`);
@ -126,7 +125,7 @@ function conditionalElementHandler(...configs) {
} }
}); });
} else { } else {
targetElements.forEach((elementName) => { targetElements.forEach(elementName => {
let el = getEl(elementName); let el = getEl(elementName);
if (el === null) { if (el === null) {
console.error(`Element ${elementName} doesn't exist.`); console.error(`Element ${elementName} doesn't exist.`);
@ -138,44 +137,16 @@ function conditionalElementHandler(...configs) {
}); });
} }
function disableElementsWhenValueNotEqual( function disableElementsWhenFalse(targetSelect, targetValue, elementList) {
targetSelect,
targetValue,
elementList
) {
return conditionalElementHandler([ return conditionalElementHandler([
() => { () => {
let target = getEl(targetSelect); return getEl(targetSelect).value != targetValue;
console.debug(
`${disableElementsWhenTrue.name}: triggered on ${target.id}`
);
console.debug(`
${disableElementsWhenTrue.name}: matching against value(s): ${targetValue}`);
if (targetValue instanceof Array) {
if (targetValue.every((value) => target.value != value)) {
console.debug(
`${disableElementsWhenTrue.name}: none of the values is equal to ${target.value}, returning true.`
);
return true;
}
} else {
console.debug(
`${disableElementsWhenTrue.name}: none of the values is equal to ${target.value}, returning true.`
);
return target.value != targetValue;
}
}, },
elementList, elementList,
(el) => { (el) => {
console.debug(
`${disableElementsWhenTrue.name}: evaluated true, disabling ${el.id}.`
);
el.disabled = "disabled"; el.disabled = "disabled";
}, },
(el) => { (el) => {
console.debug(
`${disableElementsWhenTrue.name}: evaluated false, NOT disabling ${el.id}.`
);
el.disabled = ""; el.disabled = "";
}, },
]); ]);
@ -196,12 +167,4 @@ function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
]); ]);
} }
export { export { toISOUTCString, syncSelectInputUntilChanged, getEl, conditionalElementHandler, disableElementsWhenFalse, disableElementsWhenTrue, getValueFromProperty };
toISOUTCString,
syncSelectInputUntilChanged,
getEl,
conditionalElementHandler,
disableElementsWhenValueNotEqual,
disableElementsWhenTrue,
getValueFromProperty,
};

View File

@ -16,7 +16,7 @@
{% endif %} {% endif %}
{% if field.name == "timestamp_start" or field.name == "timestamp_end" %} {% if field.name == "timestamp_start" or field.name == "timestamp_end" %}
<td> <td>
<div class="basic-button-container" hx-boost="false"> <div class="basic-button-container">
<button class="basic-button" data-target="{{ field.name }}" data-type="now">Set to now</button> <button class="basic-button" data-target="{{ field.name }}" data-type="now">Set to now</button>
<button class="basic-button" <button class="basic-button"
data-target="{{ field.name }}" data-target="{{ field.name }}"

View File

@ -1,4 +1,3 @@
{% load django_htmx %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
{% load static %} {% load static %}
@ -13,26 +12,18 @@
{% endblock title %} {% endblock title %}
</title> </title>
<script src="{% static 'js/htmx.min.js' %}"></script> <script src="{% static 'js/htmx.min.js' %}"></script>
{% django_htmx_script %}
<link rel="stylesheet" href="{% static 'base.css' %}" /> <link rel="stylesheet" href="{% static 'base.css' %}" />
</head> </head>
<body class="dark" hx-indicator="#indicator"> <body class="dark" hx-indicator="#indicator" hx-boost="true">
<img id="indicator" <img id="indicator"
src="{% static 'icons/loading.png' %}" src="{% static 'icons/loading.png' %}"
class="absolute right-3 top-3 animate-spin htmx-indicator" class="absolute right-3 top-3 animate-spin htmx-indicator" />
height="24"
width="24"
alt="loading indicator" />
<div class="dark:bg-gray-800 min-h-screen"> <div class="dark:bg-gray-800 min-h-screen">
<nav class="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded"> <nav class="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded">
<div class="container flex flex-wrap items-center justify-between mx-auto"> <div class="container flex flex-wrap items-center justify-between mx-auto">
<a href="{% url 'list_sessions_recent' %}" class="flex items-center"> <a href="{% url 'list_sessions_recent' %}" class="flex items-center">
<span class="text-4xl"> <span class="text-4xl">
<img src="{% static 'icons/schedule.png' %}" <img src="{% static 'icons/schedule.png' %}" width="48" class="mr-4" />
height="48"
width="48"
alt="Timetracker Logo"
class="mr-4" />
</span> </span>
<span class="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span> <span class="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span>
</a> </a>

View File

@ -4,21 +4,21 @@
{{ title }} {{ title }}
{% endblock title %} {% endblock title %}
{% block content %} {% block content %}
{% if dataset_count >= 1 %} {% if dataset.count >= 1 %}
{% url 'list_sessions_start_session_from_session' last.id as start_session_url %}
<div class="mx-auto text-center my-4"> <div class="mx-auto text-center my-4">
<a id="last-session-start" <a id="last-session-start"
href="{{ start_session_url }}" href="{% url 'start_session_same_as_last' last.id %}"
hx-get="{{ start_session_url }}" hx-get="{% url 'start_session_same_as_last' last.id %}"
hx-swap="afterbegin" hx-swap="afterbegin"
hx-target=".responsive-table tbody" hx-target=".responsive-table tbody"
onClick="document.querySelector('#last-session-start').classList.add('invisible')" hx-select=".responsive-table tbody tr:first-child"
class="{% if last.timestamp_end == null %}invisible{% endif %}"> onClick="document.querySelector('#last-session-start').classList.add('invisible')"
class="{% if last.timestamp_end == null %}invisible{% endif %}">
{% include 'components/button_start.html' with text=last.purchase title="Start session of last played game" only %} {% include 'components/button_start.html' with text=last.purchase title="Start session of last played game" only %}
</a> </a>
</div> </div>
{% endif %} {% endif %}
{% if dataset_count != 0 %} {% if dataset.count != 0 %}
<table class="responsive-table"> <table class="responsive-table">
<thead> <thead>
<tr> <tr>
@ -29,38 +29,36 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for session in dataset %} {% for data in dataset %}
{% partialdef session-row inline=True %} <tr>
<tr> <td class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char md:max-w-40char">
<td class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char md:max-w-40char"> <a class="underline decoration-slate-500 sm:decoration-2"
<a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'view_game' data.purchase.edition.game.id %}">
href="{% url 'view_game' session.purchase.edition.game.id %}"> {{ data.purchase.edition }}
{{ session.purchase.edition }} </a>
</a> </td>
</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell">
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell"> {{ data.timestamp_start | date:"d/m/Y H:i" }}
{{ session.timestamp_start | date:"d/m/Y H:i" }} </td>
</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell">
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell"> {% if data.unfinished %}
{% if not session.timestamp_end %} <a href="{% url 'update_session' data.id %}"
{% url 'list_sessions_end_session' session.id as end_session_url %} hx-get="{% url 'update_session' data.id %}"
<a href="{{ end_session_url }}" hx-swap="outerHTML"
hx-get="{{ end_session_url }}" hx-target=".responsive-table tbody tr:first-child"
hx-target="closest tr" hx-select=".responsive-table tbody tr:first-child"
hx-swap="outerHTML" hx-indicator="#indicator"
hx-indicator="#indicator" onClick="document.querySelector('#last-session-start').classList.remove('invisible')">
onClick="document.querySelector('#last-session-start').classList.remove('invisible')"> <span class="text-yellow-300">Finish now?</span>
<span class="text-yellow-300">Finish now?</span> </a>
</a> {% elif data.duration_manual %}
{% elif session.duration_manual %} --
-- {% else %}
{% else %} {{ data.timestamp_end | date:"d/m/Y H:i" }}
{{ session.timestamp_end | date:"d/m/Y H:i" }} {% endif %}
{% endif %} </td>
</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ data.duration_formatted }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ session.duration_formatted }}</td> </tr>
</tr>
{% endpartialdef %}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View File

@ -18,90 +18,74 @@
</select> </select>
</form> </form>
</div> </div>
<h1 class="text-5xl text-center my-6">Playtime</h1> <div class="flex flex-column flex-wrap justify-center">
<table class="responsive-table"> <div class="md:w-1/2">
<tbody> <h1 class="text-5xl text-center my-6">Playtime</h1>
<tr> <table class="responsive-table">
<td class="px-2 sm:px-4 md:px-6 md:py-2">Hours</td> <tbody>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_hours }}</td> <tr>
</tr> <td class="px-2 sm:px-4 md:px-6 md:py-2">Hours</td>
<tr> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_hours }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Sessions</td> </tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_sessions }}</td> <tr>
</tr> <td class="px-2 sm:px-4 md:px-6 md:py-2">Sessions</td>
<tr> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_sessions }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Days</td> </tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ unique_days }} ({{ unique_days_percent }}%)</td> <tr>
</tr> <td class="px-2 sm:px-4 md:px-6 md:py-2">Days</td>
<tr> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ unique_days }} ({{ unique_days_percent }}%)</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Games</td> </tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_games }}</td> <tr>
</tr> <td class="px-2 sm:px-4 md:px-6 md:py-2">Games</td>
<tr> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_games }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Games ({{ year }})</td> </tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_2023_games }}</td> <tr>
</tr> <td class="px-2 sm:px-4 md:px-6 md:py-2">Games ({{ year }})</td>
<tr> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_2023_games }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Finished</td> </tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_finished_this_year_count }}</td> <tr>
</tr> <td class="px-2 sm:px-4 md:px-6 md:py-2">Finished</td>
<tr> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_finished_this_year.count }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Finished ({{ year }})</td> </tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ this_year_finished_this_year_count }}</td> <tr>
</tr> <td class="px-2 sm:px-4 md:px-6 md:py-2">Finished ({{ year }})</td>
<tr> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ this_year_finished_this_year.count }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Longest session</td> </tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ longest_session_time }} ({{ longest_session_game }})</td> </tbody>
</tr> </table>
<tr> </div>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Most sessions</td> <div class="md:w-1/2">
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ highest_session_count }} ({{ highest_session_count_game }})</td> <h1 class="text-5xl text-center my-6">Purchases</h1>
</tr> <table class="responsive-table">
<tr> <tbody>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Highest session average</td> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> <td class="px-2 sm:px-4 md:px-6 md:py-2">Total</td>
{{ highest_session_average }} ({{ highest_session_average_game }}) <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_purchased_this_year.count }}</td>
</td> </tr>
</tr> <tr>
<tr> <td class="px-2 sm:px-4 md:px-6 md:py-2">Refunded</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2">First play</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ first_play_name }} ({{ first_play_date }})</td> {{ all_purchased_refunded_this_year.count }} ({{ refunded_percent }}%)
</tr> </td>
<tr> </tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Last play</td> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ last_play_name }} ({{ last_play_date }})</td> <td class="px-2 sm:px-4 md:px-6 md:py-2">Unfinished</td>
</tr> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
</tbody> {{ purchased_unfinished.count }} ({{ unfinished_purchases_percent }}%)
</table> </td>
<h1 class="text-5xl text-center my-6">Purchases</h1> </tr>
<table class="responsive-table"> <tr>
<tbody> <td class="px-2 sm:px-4 md:px-6 md:py-2">Backlog Decrease</td>
<tr> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ backlog_decrease_count }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Total</td> </tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_purchased_this_year_count }}</td> <tr>
</tr> <td class="px-2 sm:px-4 md:px-6 md:py-2">Spendings ({{ total_spent_currency }})</td>
<tr> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_spent }} ({{ spent_per_game }}/game)</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Refunded</td> </tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> </tbody>
{{ all_purchased_refunded_this_year_count }} ({{ refunded_percent }}%) </table>
</td> </div>
</tr> </div>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Unfinished</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ purchased_unfinished_count }} ({{ unfinished_purchases_percent }}%)
</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Backlog Decrease</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ backlog_decrease_count }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Spendings ({{ total_spent_currency }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_spent }} ({{ spent_per_game }}/game)</td>
</tr>
</tbody>
</table>
<h1 class="text-5xl text-center my-6">Top games by playtime</h1> <h1 class="text-5xl text-center my-6">Top games by playtime</h1>
<table class="responsive-table"> <table class="responsive-table">
<thead> <thead>
@ -152,13 +136,7 @@
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<a class="underline decoration-slate-500 sm:decoration-2" <a class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'edit_purchase' purchase.id %}"> href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a>
{% if purchase.type == 'dlc' %}
{{ purchase.name }} ({{ purchase.edition.name }} DLC)
{% else %}
{{ purchase.edition.name }}
{% endif %}
</a>
</td> </td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
</tr> </tr>
@ -205,33 +183,6 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<h1 class="text-5xl text-center my-6">Unfinished Purchases</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Price ({{ total_spent_currency }})</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in purchased_unfinished %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<a class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'edit_purchase' purchase.id %}">
{{ purchase.edition.name }}
{% if purchase.type == "dlc" %}({{ purchase.name }}, {{ purchase.get_type_display }}){% endif %}
</a>
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.price }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h1 class="text-5xl text-center my-6">All Purchases</h1> <h1 class="text-5xl text-center my-6">All Purchases</h1>
<table class="responsive-table"> <table class="responsive-table">
<thead> <thead>

View File

@ -57,46 +57,21 @@
</ul> </ul>
<h1 class="text-3xl mt-4 mb-1 flex gap-2 items-center"> <h1 class="text-3xl mt-4 mb-1 flex gap-2 items-center">
Sessions Sessions
<span class="dark:text-slate-500">({{ session_count }})</span> <span class="dark:text-slate-500">({{ sessions.count }})</span>
{% url 'view_game_start_session_from_session' latest_session_id as add_session_link %} {% url 'start_game_session' game.id as add_session_link %}
<a {% include 'components/button.html' with title="Start new session" text="New" link=add_session_link %}
class="truncate max-w-xs py-1 px-2 text-xs bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-sm"
title="Start new session"
href="{{ add_session_link }}"
hx-get="{{ add_session_link }}"
hx-target="#session-list"
hx-swap="afterbegin"
>New</a>
and Notes <span class="dark:text-slate-500">({{ sessions_with_notes_count }})</span> and Notes <span class="dark:text-slate-500">({{ sessions_with_notes_count }})</span>
</h1> </h1>
<ul id="session-list"> <ul>
{% for session in sessions %} {% for session in sessions %}
{% partialdef session-info inline=True %} <li class="sm:pl-2 mt-4 mb-2 dark:text-slate-400 flex items-center">
<li class="sm:pl-2 mt-4 mb-2 dark:text-slate-400 flex items-center space-x-1"> {{ session.timestamp_start | date:"d/m/Y H:m" }}
{{ session.timestamp_start | date:"d/m/Y H:m" }} ({{ session.device.get_type_display | default:"Unknown" }}, {{ session.duration_formatted }})
({{ session.device.get_type_display | default:"Unknown" }}, {{ session.duration_formatted }}) {% url 'edit_session' session.id as edit_url %}
{% url 'edit_session' session.id as edit_url %} {% include 'components/edit_button.html' with edit_url=edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %} </li>
{% if not session.timestamp_end %} <li class="sm:pl-4 italic">{{ session.note|linebreaks }}</li>
{% url 'view_game_end_session' session.id as end_session_url %}
<a
class="flex bg-green-600 rounded-full px-2 w-7 h-4 text-white justify-center items-center"
href="{{ end_session_url }}"
hx-get="{{ end_session_url }}"
hx-vals='{"partial":"view_game.html#session-info"}'
hx-target="closest li"
hx-swap="outerHTML"
hx-indicator="#indicator"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="#ffffff" class="h-3" x="0px" y="0px" viewBox="0 0 24 24">
<path d="M 12 2 C 6.486 2 2 6.486 2 12 C 2 17.514 6.486 22 12 22 C 17.514 22 22 17.514 22 12 C 22 10.874 21.803984 9.7942031 21.458984 8.7832031 L 19.839844 10.402344 C 19.944844 10.918344 20 11.453 20 12 C 20 16.411 16.411 20 12 20 C 7.589 20 4 16.411 4 12 C 4 7.589 7.589 4 12 4 C 13.633 4 15.151922 4.4938906 16.419922 5.3378906 L 17.851562 3.90625 C 16.203562 2.71225 14.185 2 12 2 z M 21.292969 3.2929688 L 11 13.585938 L 7.7070312 10.292969 L 6.2929688 11.707031 L 11 16.414062 L 22.707031 4.7070312 L 21.292969 3.2929688 z"></path>
</svg>
</a>
{% endif %}
</li>
<li class="sm:pl-4 italic">{{ session.note|linebreaks }}</li>
{% endpartialdef %}
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>

View File

@ -19,28 +19,19 @@ urlpatterns = [
name="add_session_for_purchase", name="add_session_for_purchase",
), ),
path( path(
"session/clone/from-game/<int:session_id>", "update-session/by-session/<int:session_id>",
views.new_session_from_existing_session, views.update_session,
{"template": "view_game.html#session-info"}, name="update_session",
name="view_game_start_session_from_session",
), ),
path( path(
"session/clone/from-list/<int:session_id>", "start-session-same-as-last/<int:last_session_id>",
views.new_session_from_existing_session, views.start_session_same_as_last,
{"template": "list_sessions.html#session-row"}, name="start_session_same_as_last",
name="list_sessions_start_session_from_session",
), ),
path( path(
"session/end/from-game/<int:session_id>", "start-session/<int:game_id>",
views.end_session, views.start_game_session,
{"template": "view_game.html#session-info"}, name="start_game_session",
name="view_game_end_session",
),
path(
"session/end/from-list/<int:session_id>",
views.end_session,
{"template": "list_sessions.html#session-row"},
name="list_sessions_end_session",
), ),
# path( # path(
# "delete_session/by-id/<int:session_id>", # "delete_session/by-id/<int:session_id>",

View File

@ -1,16 +1,8 @@
from datetime import datetime from datetime import datetime, timedelta
from typing import Any, Callable from typing import Any, Callable
from django.db.models import ( from django.core.exceptions import ObjectDoesNotExist
Avg, from django.db.models import Count, F, Prefetch, Sum
Count,
ExpressionWrapper,
F,
Prefetch,
Q,
Sum,
fields,
)
from django.db.models.functions import TruncDate from django.db.models.functions import TruncDate
from django.http import ( from django.http import (
HttpRequest, HttpRequest,
@ -21,7 +13,6 @@ from django.http import (
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.shortcuts import get_object_or_404
from common.time import format_duration from common.time import format_duration
from common.utils import safe_division from common.utils import safe_division
@ -39,11 +30,11 @@ from .models import Edition, Game, Platform, Purchase, Session
def model_counts(request): def model_counts(request):
return { return {
"game_available": Game.objects.exists(), "game_available": Game.objects.count() != 0,
"edition_available": Edition.objects.exists(), "edition_available": Edition.objects.count() != 0,
"platform_available": Platform.objects.exists(), "platform_available": Platform.objects.count() != 0,
"purchase_available": Purchase.objects.exists(), "purchase_available": Purchase.objects.count() != 0,
"session_count": Session.objects.exists(), "session_count": Session.objects.count(),
} }
@ -82,6 +73,13 @@ def add_session(request, purchase_id=None):
return render(request, "add_session.html", context) return render(request, "add_session.html", context)
def update_session(request, session_id=None):
session = Session.objects.get(id=session_id)
session.finish_now()
session.save()
return redirect("list_sessions")
def use_custom_redirect( def use_custom_redirect(
func: Callable[..., HttpResponse] func: Callable[..., HttpResponse]
) -> Callable[..., HttpResponse]: ) -> Callable[..., HttpResponse]:
@ -160,14 +158,11 @@ def view_game(request, game_id=None):
.order_by("year_released") .order_by("year_released")
) )
sessions = Session.objects.prefetch_related("device").filter( sessions = Session.objects.filter(purchase__edition__game=game)
purchase__edition__game=game
)
session_count = sessions.count() session_count = sessions.count()
playrange_start = sessions.earliest().timestamp_start.strftime("%b %Y") playrange_start = sessions.earliest().timestamp_start.strftime("%b %Y")
latest_session = sessions.latest() playrange_end = sessions.latest().timestamp_start.strftime("%b %Y")
playrange_end = latest_session.timestamp_start.strftime("%b %Y")
playrange = ( playrange = (
playrange_start playrange_start
@ -188,7 +183,6 @@ def view_game(request, game_id=None):
"sessions": sessions.order_by("-timestamp_start"), "sessions": sessions.order_by("-timestamp_start"),
"title": f"Game Overview - {game.name}", "title": f"Game Overview - {game.name}",
"hours_sum": total_hours, "hours_sum": total_hours,
"latest_session_id": latest_session.pk,
} }
request.session["return_path"] = request.path request.session["return_path"] = request.path
@ -232,34 +226,30 @@ def related_purchase_by_edition(request):
return render(request, "partials/related_purchase_field.html", {"form": form}) return render(request, "partials/related_purchase_field.html", {"form": form})
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
@use_custom_redirect @use_custom_redirect
def new_session_from_existing_session(request, session_id: int, template: str = ""): def start_game_session(request, game_id: int):
session = clone_session_by_id(session_id) last_session = Session.objects.filter(purchase__edition__game_id=game_id).latest()
if request.htmx: session = SessionForm(
context = {"session": session} {
return render(request, template, context) "purchase": last_session.purchase.id,
"timestamp_start": timezone.now(),
"device": last_session.device,
}
)
session.save()
return redirect("list_sessions") return redirect("list_sessions")
@use_custom_redirect def start_session_same_as_last(request, last_session_id: int):
def end_session(request, session_id: int, template: str = ""): last_session = Session.objects.get(id=last_session_id)
session = get_object_or_404(Session, id=session_id) session = SessionForm(
session.timestamp_end = timezone.now() {
"purchase": last_session.purchase.id,
"timestamp_start": timezone.now(),
"device": last_session.device,
}
)
session.save() session.save()
if request.htmx:
context = {"session": session}
return render(request, template, context)
return redirect("list_sessions") return redirect("list_sessions")
@ -281,40 +271,45 @@ def list_sessions(
context = {} context = {}
context["title"] = "Sessions" context["title"] = "Sessions"
all_sessions = Session.objects.prefetch_related(
"purchase", "purchase__edition", "purchase__edition__game"
).order_by("-timestamp_start")
if filter == "purchase": if filter == "purchase":
dataset = all_sessions.filter(purchase=purchase_id) dataset = Session.objects.filter(purchase=purchase_id)
context["purchase"] = Purchase.objects.get(id=purchase_id) context["purchase"] = Purchase.objects.get(id=purchase_id)
elif filter == "platform": elif filter == "platform":
dataset = all_sessions.filter(purchase__platform=platform_id) dataset = Session.objects.filter(purchase__platform=platform_id)
context["platform"] = Platform.objects.get(id=platform_id) context["platform"] = Platform.objects.get(id=platform_id)
elif filter == "edition": elif filter == "edition":
dataset = all_sessions.filter(purchase__edition=edition_id) dataset = Session.objects.filter(purchase__edition=edition_id)
context["edition"] = Edition.objects.get(id=edition_id) context["edition"] = Edition.objects.get(id=edition_id)
elif filter == "game": elif filter == "game":
dataset = all_sessions.filter(purchase__edition__game=game_id) dataset = Session.objects.filter(purchase__edition__game=game_id)
context["game"] = Game.objects.get(id=game_id) context["game"] = Game.objects.get(id=game_id)
elif filter == "ownership_type": elif filter == "ownership_type":
dataset = all_sessions.filter(purchase__ownership_type=ownership_type) dataset = Session.objects.filter(purchase__ownership_type=ownership_type)
context["ownership_type"] = dict(Purchase.OWNERSHIP_TYPES)[ownership_type] context["ownership_type"] = dict(Purchase.OWNERSHIP_TYPES)[ownership_type]
elif filter == "recent": elif filter == "recent":
current_year = timezone.now().year current_year = timezone.now().year
first_day_of_year = timezone.make_aware(datetime(current_year, 1, 1)) first_day_of_year = timezone.make_aware(datetime(current_year, 1, 1))
dataset = all_sessions.filter(timestamp_start__gte=first_day_of_year).order_by( dataset = Session.objects.filter(
"-timestamp_start" timestamp_start__gte=first_day_of_year
) ).order_by("-timestamp_start")
context["title"] = "This year" context["title"] = "This year"
else: else:
dataset = all_sessions # by default, sort from newest to oldest
dataset = Session.objects.order_by("-timestamp_start")
context = { for session in dataset:
"dataset": dataset, if session.timestamp_end == None and session.duration_manual == timedelta(
"dataset_count": dataset.count(), seconds=0
"last": Session.objects.prefetch_related("purchase__platform").latest(), ):
} session.timestamp_end = timezone.now()
session.unfinished = True
context["total_duration"] = dataset.total_duration_formatted()
context["dataset"] = dataset
try:
context["last"] = Session.objects.latest()
except ObjectDoesNotExist:
context["last"] = None
return render(request, "list_sessions.html", context) return render(request, "list_sessions.html", context)
@ -325,28 +320,7 @@ def stats(request, year: int = 0):
return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year])) return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year]))
if year == 0: if year == 0:
year = timezone.now().year year = timezone.now().year
this_year_sessions = Session.objects.filter( this_year_sessions = Session.objects.filter(timestamp_start__year=year)
timestamp_start__year=year
).select_related("purchase__edition")
this_year_sessions_with_durations = this_year_sessions.annotate(
duration=ExpressionWrapper(
F("timestamp_end") - F("timestamp_start"),
output_field=fields.DurationField(),
)
)
longest_session = this_year_sessions_with_durations.order_by("-duration").first()
this_year_games = Game.objects.filter(
edition__purchase__session__in=this_year_sessions
).distinct()
this_year_games_with_session_counts = this_year_games.annotate(
session_count=Count(
"edition__purchase__session",
filter=Q(edition__purchase__session__timestamp_start__year=year),
)
)
game_highest_session_count = this_year_games_with_session_counts.order_by(
"-session_count"
).first()
selected_currency = "CZK" selected_currency = "CZK"
unique_days = ( unique_days = (
this_year_sessions.annotate(date=TruncDate("timestamp_start")) this_year_sessions.annotate(date=TruncDate("timestamp_start"))
@ -359,29 +333,23 @@ def stats(request, year: int = 0):
).distinct() ).distinct()
this_year_purchases = Purchase.objects.filter(date_purchased__year=year) this_year_purchases = Purchase.objects.filter(date_purchased__year=year)
this_year_purchases_with_currency = this_year_purchases.select_related( this_year_purchases_with_currency = this_year_purchases.filter(
"edition" price_currency__exact=selected_currency
).filter(price_currency__exact=selected_currency) )
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter( this_year_purchases_without_refunded = this_year_purchases_with_currency.filter(
date_refunded=None date_refunded=None
) )
this_year_purchases_refunded = this_year_purchases_with_currency.refunded() this_year_purchases_refunded = this_year_purchases_with_currency.refunded()
this_year_purchases_unfinished = ( this_year_purchases_unfinished = this_year_purchases_without_refunded.filter(
this_year_purchases_without_refunded.filter(date_finished__isnull=True) date_finished__isnull=True
.filter(date_dropped__isnull=True) ).filter(
.filter(infinite=False) type=Purchase.GAME
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC)) ) # do not count DLC etc.
) # do not count battle passes etc.
this_year_purchases_without_refunded_count = (
this_year_purchases_without_refunded.count()
)
this_year_purchases_unfinished_count = this_year_purchases_unfinished.count()
this_year_purchases_unfinished_percent = int( this_year_purchases_unfinished_percent = int(
safe_division( safe_division(
this_year_purchases_unfinished_count, this_year_purchases_unfinished.count(), this_year_purchases_refunded.count()
this_year_purchases_without_refunded_count,
) )
* 100 * 100
) )
@ -393,8 +361,10 @@ def stats(request, year: int = 0):
) )
) )
purchased_this_year_finished_this_year = ( purchased_this_year_finished_this_year = (
this_year_purchases_without_refunded.filter(date_finished__year=year) this_year_purchases_without_refunded.intersection(
).order_by("date_finished") purchases_finished_this_year
).order_by("date_finished")
)
this_year_spendings = this_year_purchases_without_refunded.aggregate( this_year_spendings = this_year_purchases_without_refunded.aggregate(
total_spent=Sum(F("price")) total_spent=Sum(F("price"))
@ -411,14 +381,6 @@ def stats(request, year: int = 0):
) )
.values("id", "name", "total_playtime") .values("id", "name", "total_playtime")
) )
highest_session_average_game = (
Game.objects.filter(edition__purchase__session__in=this_year_sessions)
.annotate(
session_average=Avg("edition__purchase__session__duration_calculated")
)
.order_by("-session_average")
.first()
)
top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")[:10] top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")[:10]
for game in top_10_games_by_playtime: for game in top_10_games_by_playtime:
game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H") game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H")
@ -439,20 +401,6 @@ def stats(request, year: int = 0):
.count() .count()
) )
first_play_name = "N/A"
first_play_date = "N/A"
last_play_name = "N/A"
last_play_date = "N/A"
if this_year_sessions:
first_session = this_year_sessions.earliest()
first_play_name = first_session.purchase.edition.name
first_play_date = first_session.timestamp_start.strftime("%x")
last_session = this_year_sessions.latest()
last_play_name = last_session.purchase.edition.name
last_play_date = last_session.timestamp_start.strftime("%x")
all_purchased_this_year_count = this_year_purchases_with_currency.count()
all_purchased_refunded_this_year_count = this_year_purchases_refunded.count()
context = { context = {
"total_hours": format_duration( "total_hours": format_duration(
this_year_sessions.total_duration_unformatted(), "%2.0H" this_year_sessions.total_duration_unformatted(), "%2.0H"
@ -468,68 +416,34 @@ def stats(request, year: int = 0):
"total_spent_currency": selected_currency, "total_spent_currency": selected_currency,
"all_purchased_this_year": this_year_purchases_without_refunded, "all_purchased_this_year": this_year_purchases_without_refunded,
"spent_per_game": int( "spent_per_game": int(
safe_division(total_spent, this_year_purchases_without_refunded_count) safe_division(total_spent, this_year_purchases_without_refunded.count())
), ),
"all_finished_this_year": purchases_finished_this_year.select_related( "all_finished_this_year": purchases_finished_this_year.order_by(
"edition"
).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.select_related(
"edition"
).order_by(
"date_finished" "date_finished"
), ),
"this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(), "this_year_finished_this_year": purchases_finished_this_year_released_this_year.order_by(
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.select_related( "date_finished"
"edition" ),
).order_by( "purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.order_by(
"date_finished" "date_finished"
), ),
"total_sessions": this_year_sessions.count(), "total_sessions": this_year_sessions.count(),
"unique_days": unique_days["dates"], "unique_days": unique_days["dates"],
"unique_days_percent": int(unique_days["dates"] / 365 * 100), "unique_days_percent": int(unique_days["dates"] / 365 * 100),
"purchased_unfinished": this_year_purchases_unfinished, "purchased_unfinished": this_year_purchases_unfinished,
"purchased_unfinished_count": this_year_purchases_unfinished_count,
"unfinished_purchases_percent": this_year_purchases_unfinished_percent, "unfinished_purchases_percent": this_year_purchases_unfinished_percent,
"refunded_percent": int( "refunded_percent": int(
safe_division( safe_division(
all_purchased_refunded_this_year_count, this_year_purchases_refunded.count(),
all_purchased_this_year_count, this_year_purchases_with_currency.count(),
) )
* 100 * 100
), ),
"all_purchased_refunded_this_year": this_year_purchases_refunded, "all_purchased_refunded_this_year": this_year_purchases_refunded,
"all_purchased_refunded_this_year_count": all_purchased_refunded_this_year_count,
"all_purchased_this_year": this_year_purchases_with_currency.order_by( "all_purchased_this_year": this_year_purchases_with_currency.order_by(
"date_purchased" "date_purchased"
), ),
"all_purchased_this_year_count": all_purchased_this_year_count,
"backlog_decrease_count": backlog_decrease_count, "backlog_decrease_count": backlog_decrease_count,
"longest_session_time": format_duration(
longest_session.duration, "%2.0Hh %2.0mm"
)
if longest_session
else 0,
"longest_session_game": longest_session.purchase.edition.name
if longest_session
else "N/A",
"highest_session_count": game_highest_session_count.session_count
if game_highest_session_count
else 0,
"highest_session_count_game": game_highest_session_count.name
if game_highest_session_count
else "N/A",
"highest_session_average": format_duration(
highest_session_average_game.session_average, "%2.0Hh %2.0mm"
)
if highest_session_average_game
else 0,
"highest_session_average_game": highest_session_average_game,
"first_play_name": first_play_name,
"first_play_date": first_play_date,
"last_play_name": last_play_name,
"last_play_date": last_play_date,
"title": f"{year} Stats",
} }
request.session["return_path"] = request.path request.session["return_path"] = request.path

190
poetry.lock generated
View File

@ -1,19 +1,5 @@
# This file is automatically @generated by Poetry 1.7.0 and should not be changed by hand. # This file is automatically @generated by Poetry 1.7.0 and should not be changed by hand.
[[package]]
name = "aniso8601"
version = "9.0.1"
description = "A library for parsing ISO 8601 strings."
optional = false
python-versions = "*"
files = [
{file = "aniso8601-9.0.1-py2.py3-none-any.whl", hash = "sha256:1d2b7ef82963909e93c4f24ce48d4de9e66009a21bf1c1e1c85bdd0812fe412f"},
{file = "aniso8601-9.0.1.tar.gz", hash = "sha256:72e3117667eedf66951bb2d93f4296a56b94b078a8a95905a052611fb3f1b973"},
]
[package.extras]
dev = ["black", "coverage", "isort", "pre-commit", "pyenchant", "pylint"]
[[package]] [[package]]
name = "asgiref" name = "asgiref"
version = "3.7.2" version = "3.7.2"
@ -143,21 +129,6 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""}
argon2 = ["argon2-cffi (>=19.1.0)"] argon2 = ["argon2-cffi (>=19.1.0)"]
bcrypt = ["bcrypt"] bcrypt = ["bcrypt"]
[[package]]
name = "django-debug-toolbar"
version = "4.2.0"
description = "A configurable set of panels that display various debug information about the current request/response."
optional = false
python-versions = ">=3.8"
files = [
{file = "django_debug_toolbar-4.2.0-py3-none-any.whl", hash = "sha256:af99128c06e8e794479e65ab62cc6c7d1e74e1c19beb44dcbf9bad7a9c017327"},
{file = "django_debug_toolbar-4.2.0.tar.gz", hash = "sha256:bc7fdaafafcdedefcc67a4a5ad9dac96efd6e41db15bc74d402a54a2ba4854dc"},
]
[package.dependencies]
django = ">=3.2.4"
sqlparse = ">=0.2"
[[package]] [[package]]
name = "django-extensions" name = "django-extensions"
version = "3.2.3" version = "3.2.3"
@ -172,39 +143,6 @@ files = [
[package.dependencies] [package.dependencies]
Django = ">=3.2" Django = ">=3.2"
[[package]]
name = "django-htmx"
version = "1.17.2"
description = "Extensions for using Django with htmx."
optional = false
python-versions = ">=3.8"
files = [
{file = "django-htmx-1.17.2.tar.gz", hash = "sha256:4089f2ed38727e9846c2f4cd1daddf6b010c7be8d834cfbcffc8c5ecf445c04e"},
{file = "django_htmx-1.17.2-py3-none-any.whl", hash = "sha256:f4971432d2ca45dbb31d9b58add1c50ae54354afe4bf59cafd591b1711b502c0"},
]
[package.dependencies]
asgiref = ">=3.6"
Django = ">=3.2"
[[package]]
name = "django-template-partials"
version = "23.4"
description = "django-template-partials"
optional = false
python-versions = "*"
files = [
{file = "django-template-partials-23.4.tar.gz", hash = "sha256:f762b0b7b2222462df0845f0556792640b769eb832eae218a0e7dadd4e5606cc"},
{file = "django_template_partials-23.4-py2.py3-none-any.whl", hash = "sha256:d83d9c2d2836be769919e9aaf394d5feb1ac86e1187083030398308070122fca"},
]
[package.dependencies]
Django = "*"
[package.extras]
docs = ["Sphinx"]
tests = ["coverage", "django_coverage_plugin"]
[[package]] [[package]]
name = "djhtml" name = "djhtml"
version = "1.5.2" version = "1.5.2"
@ -269,75 +207,6 @@ docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1
testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"]
typing = ["typing-extensions (>=4.8)"] typing = ["typing-extensions (>=4.8)"]
[[package]]
name = "graphene"
version = "3.3"
description = "GraphQL Framework for Python"
optional = false
python-versions = "*"
files = [
{file = "graphene-3.3-py2.py3-none-any.whl", hash = "sha256:bb3810be33b54cb3e6969506671eb72319e8d7ba0d5ca9c8066472f75bf35a38"},
{file = "graphene-3.3.tar.gz", hash = "sha256:529bf40c2a698954217d3713c6041d69d3f719ad0080857d7ee31327112446b0"},
]
[package.dependencies]
aniso8601 = ">=8,<10"
graphql-core = ">=3.1,<3.3"
graphql-relay = ">=3.1,<3.3"
[package.extras]
dev = ["black (==22.3.0)", "coveralls (>=3.3,<4)", "flake8 (>=4,<5)", "iso8601 (>=1,<2)", "mock (>=4,<5)", "pytest (>=6,<7)", "pytest-asyncio (>=0.16,<2)", "pytest-benchmark (>=3.4,<4)", "pytest-cov (>=3,<4)", "pytest-mock (>=3,<4)", "pytz (==2022.1)", "snapshottest (>=0.6,<1)"]
test = ["coveralls (>=3.3,<4)", "iso8601 (>=1,<2)", "mock (>=4,<5)", "pytest (>=6,<7)", "pytest-asyncio (>=0.16,<2)", "pytest-benchmark (>=3.4,<4)", "pytest-cov (>=3,<4)", "pytest-mock (>=3,<4)", "pytz (==2022.1)", "snapshottest (>=0.6,<1)"]
[[package]]
name = "graphene-django"
version = "3.1.5"
description = "Graphene Django integration"
optional = false
python-versions = "*"
files = [
{file = "graphene-django-3.1.5.tar.gz", hash = "sha256:abe42f820b9731d94bebff6d73088d0dc2ffb8c8863a6d7bf3d378412d866a3b"},
{file = "graphene_django-3.1.5-py2.py3-none-any.whl", hash = "sha256:2e42742fae21fa50e514f3acae26a9bc6cb5e51c179a97b3db5390ff258ca816"},
]
[package.dependencies]
Django = ">=3.2"
graphene = ">=3.0,<4"
graphql-core = ">=3.1.0,<4"
graphql-relay = ">=3.1.1,<4"
promise = ">=2.1"
text-unidecode = "*"
[package.extras]
dev = ["black (==23.7.0)", "coveralls", "django-filter (>=22.1)", "djangorestframework (>=3.6.3)", "mock", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-django (>=4.5.2)", "pytest-random-order", "pytz", "ruff (==0.0.283)"]
rest-framework = ["djangorestframework (>=3.6.3)"]
test = ["coveralls", "django-filter (>=22.1)", "djangorestframework (>=3.6.3)", "mock", "pytest (>=7.3.1)", "pytest-cov", "pytest-django (>=4.5.2)", "pytest-random-order", "pytz"]
[[package]]
name = "graphql-core"
version = "3.2.3"
description = "GraphQL implementation for Python, a port of GraphQL.js, the JavaScript reference implementation for GraphQL."
optional = false
python-versions = ">=3.6,<4"
files = [
{file = "graphql-core-3.2.3.tar.gz", hash = "sha256:06d2aad0ac723e35b1cb47885d3e5c45e956a53bc1b209a9fc5369007fe46676"},
{file = "graphql_core-3.2.3-py3-none-any.whl", hash = "sha256:5766780452bd5ec8ba133f8bf287dc92713e3868ddd83aee4faab9fc3e303dc3"},
]
[[package]]
name = "graphql-relay"
version = "3.2.0"
description = "Relay library for graphql-core"
optional = false
python-versions = ">=3.6,<4"
files = [
{file = "graphql-relay-3.2.0.tar.gz", hash = "sha256:1ff1c51298356e481a0be009ccdff249832ce53f30559c1338f22a0e0d17250c"},
{file = "graphql_relay-3.2.0-py3-none-any.whl", hash = "sha256:c9b22bd28b170ba1fe674c74384a8ff30a76c8e26f88ac3aa1584dd3179953e5"},
]
[package.dependencies]
graphql-core = ">=3.2,<3.3"
[[package]] [[package]]
name = "gunicorn" name = "gunicorn"
version = "20.1.0" version = "20.1.0"
@ -393,13 +262,13 @@ files = [
[[package]] [[package]]
name = "identify" name = "identify"
version = "2.5.32" version = "2.5.31"
description = "File identification library for Python" description = "File identification library for Python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "identify-2.5.32-py2.py3-none-any.whl", hash = "sha256:0b7656ef6cba81664b783352c73f8c24b39cf82f926f78f4550eda928e5e0545"}, {file = "identify-2.5.31-py2.py3-none-any.whl", hash = "sha256:90199cb9e7bd3c5407a9b7e81b4abec4bb9d249991c79439ec8af740afc6293d"},
{file = "identify-2.5.32.tar.gz", hash = "sha256:5d9979348ec1a21c768ae07e0a652924538e8bce67313a73cb0f681cf08ba407"}, {file = "identify-2.5.31.tar.gz", hash = "sha256:7736b3c7a28233637e3c36550646fc6389bedd74ae84cb788200cc8e2dd60b75"},
] ]
[package.extras] [package.extras]
@ -628,13 +497,13 @@ files = [
[[package]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "4.0.0" version = "3.11.0"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "platformdirs-4.0.0-py3-none-any.whl", hash = "sha256:118c954d7e949b35437270383a3f2531e99dd93cf7ce4dc8340d3356d30f173b"}, {file = "platformdirs-3.11.0-py3-none-any.whl", hash = "sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e"},
{file = "platformdirs-4.0.0.tar.gz", hash = "sha256:cb633b2bcf10c51af60beb0ab06d2f1d69064b43abf4c185ca6b28865f3f9731"}, {file = "platformdirs-3.11.0.tar.gz", hash = "sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3"},
] ]
[package.extras] [package.extras]
@ -674,22 +543,6 @@ nodeenv = ">=0.11.1"
pyyaml = ">=5.1" pyyaml = ">=5.1"
virtualenv = ">=20.10.0" virtualenv = ">=20.10.0"
[[package]]
name = "promise"
version = "2.3"
description = "Promises/A+ implementation for Python"
optional = false
python-versions = "*"
files = [
{file = "promise-2.3.tar.gz", hash = "sha256:dfd18337c523ba4b6a58801c164c1904a9d4d1b1747c7d5dbf45b693a49d93d0"},
]
[package.dependencies]
six = "*"
[package.extras]
test = ["coveralls", "futures", "mock", "pytest (>=2.7.3)", "pytest-benchmark", "pytest-cov"]
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "7.4.3" version = "7.4.3"
@ -868,17 +721,17 @@ files = [
[[package]] [[package]]
name = "setuptools" name = "setuptools"
version = "69.0.2" version = "68.2.2"
description = "Easily download, build, install, upgrade, and uninstall Python packages" description = "Easily download, build, install, upgrade, and uninstall Python packages"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "setuptools-69.0.2-py3-none-any.whl", hash = "sha256:1e8fdff6797d3865f37397be788a4e3cba233608e9b509382a2777d25ebde7f2"}, {file = "setuptools-68.2.2-py3-none-any.whl", hash = "sha256:b454a35605876da60632df1a60f736524eb73cc47bbc9f3f1ef1b644de74fd2a"},
{file = "setuptools-69.0.2.tar.gz", hash = "sha256:735896e78a4742605974de002ac60562d286fa8051a7e2299445e8e8fbb01aa6"}, {file = "setuptools-68.2.2.tar.gz", hash = "sha256:4ac1475276d2f1c48684874089fefcd83bd7162ddaafb81fac866ba0db282a87"},
] ]
[package.extras] [package.extras]
docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
@ -909,17 +762,6 @@ dev = ["build", "flake8"]
doc = ["sphinx"] doc = ["sphinx"]
test = ["pytest", "pytest-cov"] test = ["pytest", "pytest-cov"]
[[package]]
name = "text-unidecode"
version = "1.3"
description = "The most basic Text::Unidecode port"
optional = false
python-versions = "*"
files = [
{file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"},
{file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"},
]
[[package]] [[package]]
name = "tqdm" name = "tqdm"
version = "4.66.1" version = "4.66.1"
@ -982,19 +824,19 @@ standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)",
[[package]] [[package]]
name = "virtualenv" name = "virtualenv"
version = "20.24.7" version = "20.24.6"
description = "Virtual Python Environment builder" description = "Virtual Python Environment builder"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "virtualenv-20.24.7-py3-none-any.whl", hash = "sha256:a18b3fd0314ca59a2e9f4b556819ed07183b3e9a3702ecfe213f593d44f7b3fd"}, {file = "virtualenv-20.24.6-py3-none-any.whl", hash = "sha256:520d056652454c5098a00c0f073611ccbea4c79089331f60bf9d7ba247bb7381"},
{file = "virtualenv-20.24.7.tar.gz", hash = "sha256:69050ffb42419c91f6c1284a7b24e0475d793447e35929b488bf6a0aade39353"}, {file = "virtualenv-20.24.6.tar.gz", hash = "sha256:02ece4f56fbf939dbbc33c0715159951d6bf14aaf5457b092e4548e1382455af"},
] ]
[package.dependencies] [package.dependencies]
distlib = ">=0.3.7,<1" distlib = ">=0.3.7,<1"
filelock = ">=3.12.2,<4" filelock = ">=3.12.2,<4"
platformdirs = ">=3.9.1,<5" platformdirs = ">=3.9.1,<4"
[package.extras] [package.extras]
docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
@ -1019,5 +861,5 @@ watchdog = ["watchdog (>=2.3)"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.11" python-versions = "^3.12"
content-hash = "4662e73ad621b11cbe5b517ca08aae4cbeb350bfcc855a6c067861942e232d2a" content-hash = "32e7c40e7148530effb10ebd5d67a4f1c8fe30794a4d3b5d213d4f30048c79ea"

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "timetracker" name = "timetracker"
version = "1.5.2" version = "1.5.1"
description = "A simple time tracker." description = "A simple time tracker."
authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"] authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"]
license = "GPL" license = "GPL"
@ -8,13 +8,10 @@ readme = "README.md"
packages = [{include = "timetracker"}] packages = [{include = "timetracker"}]
[tool.poetry.group.main.dependencies] [tool.poetry.group.main.dependencies]
python = "^3.11" python = "^3.12"
django = "^4.2.0" django = "^4.2.0"
gunicorn = "^20.1.0" gunicorn = "^20.1.0"
uvicorn = "^0.20.0" uvicorn = "^0.20.0"
graphene-django = "^3.1.5"
django-htmx = "^1.17.2"
django-template-partials = "^23.4"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
black = "^22.12.0" black = "^22.12.0"
@ -27,8 +24,6 @@ djhtml = "^1.5.2"
djlint = "^1.19.11" djlint = "^1.19.11"
isort = "^5.11.4" isort = "^5.11.4"
pre-commit = "^3.5.0" pre-commit = "^3.5.0"
django-debug-toolbar = "^4.2.0"
[tool.isort] [tool.isort]
profile = "black" profile = "black"

View File

@ -1,35 +0,0 @@
import json
import os
import django
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
django.setup()
from django.test import TestCase
from graphene_django.utils.testing import GraphQLTestCase
from games import schema
from games.models import Game
class GameAPITestCase(GraphQLTestCase):
GRAPHENE_SCHEMA = schema.schema
def test_query_all_games(self):
response = self.query(
"""
query {
games {
id
name
}
}
"""
)
self.assertResponseNoErrors(response)
self.assertEqual(
len(json.loads(response.content)["data"]["games"]),
Game.objects.count(),
)

View File

@ -38,17 +38,11 @@ INSTALLED_APPS = [
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"template_partials",
"graphene_django",
"django_htmx",
] ]
GRAPHENE = {"SCHEMA": "games.schema.schema"}
if DEBUG: if DEBUG:
INSTALLED_APPS.append("django_extensions") INSTALLED_APPS.append("django_extensions")
INSTALLED_APPS.append("django.contrib.admin") INSTALLED_APPS.append("django.contrib.admin")
INSTALLED_APPS.append("debug_toolbar")
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
@ -58,14 +52,8 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"django_htmx.middleware.HtmxMiddleware",
] ]
if DEBUG:
MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware")
INTERNAL_IPS = ["127.0.0.1"]
DEBUG_TOOLBAR_CONFIG = {"ROOT_TAG_EXTRA_ATTRS": "hx-preserve"}
ROOT_URLCONF = "timetracker.urls" ROOT_URLCONF = "timetracker.urls"
TEMPLATES = [ TEMPLATES = [
@ -82,7 +70,6 @@ TEMPLATES = [
"games.views.model_counts", "games.views.model_counts",
"games.views.stats_dropdown_year_range", "games.views.stats_dropdown_year_range",
], ],
"builtins": ["template_partials.templatetags.partials"],
}, },
}, },
] ]

View File

@ -16,16 +16,12 @@ Including another URLconf
from django.conf import settings from django.conf import settings
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import include, path
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import RedirectView from django.views.generic import RedirectView
from graphene_django.views import GraphQLView
urlpatterns = [ urlpatterns = [
path("", RedirectView.as_view(url="/tracker")), path("", RedirectView.as_view(url="/tracker")),
path("tracker/", include("games.urls")), path("tracker/", include("games.urls")),
path("graphql", csrf_exempt(GraphQLView.as_view(graphiql=True))),
] ]
if settings.DEBUG: if settings.DEBUG:
urlpatterns.append(path("admin/", admin.site.urls)) urlpatterns.append(path("admin/", admin.site.urls))
urlpatterns.append(path("__debug__/", include("debug_toolbar.urls")))