11 Commits

Author SHA1 Message Date
a30c54ef44 initial commit
All checks were successful
Django CI/CD / test (push) Successful in 1m4s
Django CI/CD / build-and-push (push) Has been skipped
2023-12-22 12:42:40 +01:00
d9fbb4b896 Add title to stats page
Some checks failed
Django CI/CD / build-and-push (push) Blocked by required conditions
Django CI/CD / test (push) Has been cancelled
2023-12-15 10:58:15 +01:00
4ff3692606 Remove duplicate block
All checks were successful
Django CI/CD / test (push) Successful in 1m34s
Django CI/CD / build-and-push (push) Successful in 1m29s
2023-11-30 18:05:52 +01:00
8289c48896 Fix CI error
All checks were successful
Django CI/CD / test (push) Successful in 1m14s
Django CI/CD / build-and-push (push) Successful in 1m24s
2023-11-30 17:44:31 +01:00
d1b9202337 Update poetry.lock
Some checks failed
Django CI/CD / test (push) Failing after 1m15s
Django CI/CD / build-and-push (push) Has been skipped
2023-11-30 17:35:44 +01:00
fde93cb875 Organize better 2023-11-30 17:35:44 +01:00
d1c3ac6079 Revert "Move GraphQL to separata app"
This reverts commit 6ac4209492.
2023-11-30 17:35:44 +01:00
d921c2d8a6 Revert "Add UpdateGameMutation"
This reverts commit e9e61403a9.
2023-11-30 17:35:44 +01:00
52513e1ed8 Add UpdateGameMutation 2023-11-30 17:35:44 +01:00
cb380814a7 Move GraphQL to separata app 2023-11-30 17:35:44 +01:00
5ef8c07f30 Initial working API 2023-11-30 17:35:44 +01:00
25 changed files with 421 additions and 9 deletions

View File

@ -17,7 +17,7 @@ jobs:
poetry install poetry install
poetry env info poetry env info
poetry run python manage.py migrate poetry run python manage.py migrate
poetry run pytest PROD=1 poetry run pytest
build-and-push: build-and-push:
needs: test needs: test
runs-on: ubuntu-latest runs-on: ubuntu-latest

2
.gitignore vendored
View File

@ -1,7 +1,7 @@
__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

View File

@ -91,6 +91,7 @@ class PurchaseForm(forms.ModelForm):
"date_purchased", "date_purchased",
"date_refunded", "date_refunded",
"date_finished", "date_finished",
"status",
"price", "price",
"price_currency", "price_currency",
"ownership_type", "ownership_type",

View File

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

View File

@ -0,0 +1,29 @@
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

@ -0,0 +1,6 @@
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

@ -0,0 +1,11 @@
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

@ -0,0 +1,11 @@
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

@ -0,0 +1,18 @@
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

@ -0,0 +1,11 @@
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

@ -0,0 +1,11 @@
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

@ -0,0 +1,11 @@
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()

44
games/graphql/types.py Normal file
View File

@ -0,0 +1,44 @@
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

@ -0,0 +1,26 @@
# Generated by Django 4.2.7 on 2023-12-22 10:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0033_alter_edition_unique_together"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="status",
field=models.IntegerField(
choices=[
(0, "Unplayed"),
(1, "Playing"),
(2, "Dropped"),
(3, "Finished"),
],
default=0,
),
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 4.2.7 on 2023-12-22 10:09
from django.db import migrations
from games.models import Purchase
def set_default_state(apps, schema_editor):
Purchase.objects.filter(session__isnull=False).update(
status=Purchase.PurchaseState.PLAYING
)
Purchase.objects.filter(date_finished__isnull=False).update(
status=Purchase.PurchaseState.FINISHED
)
class Migration(migrations.Migration):
dependencies = [
("games", "0034_purchase_status"),
]
operations = [migrations.RunPython(set_default_state)]

View File

@ -133,6 +133,25 @@ class Purchase(models.Model):
) )
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
class PurchaseState(models.IntegerChoices):
UNPLAYED = (
0,
"Unplayed",
)
PLAYING = (1, "Playing")
DROPPED = (
2,
"Dropped",
)
FINISHED = (
3,
"Finished",
)
status = models.IntegerField(
choices=PurchaseState.choices, default=PurchaseState.UNPLAYED
)
def __str__(self): def __str__(self):
additional_info = [ additional_info = [
self.get_type_display() if self.type != Purchase.GAME else "", self.get_type_display() if self.type != Purchase.GAME else "",

30
games/schema.py Normal file
View File

@ -0,0 +1,30 @@
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

@ -75,10 +75,6 @@ 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 =
sourceElement instanceof HTMLSelectElement
? sourceElement.selectedOptions[0]
: sourceElement;
let source = let source =
sourceElement instanceof HTMLSelectElement sourceElement instanceof HTMLSelectElement
? sourceElement.selectedOptions[0] ? sourceElement.selectedOptions[0]

View File

@ -137,6 +137,7 @@
<tr> <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 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th> <th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -153,6 +154,7 @@
</a> </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>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.formatted_playtime }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@ -371,6 +371,16 @@ def stats(request, year: int = 0):
) )
purchases_finished_this_year = Purchase.objects.filter(date_finished__year=year) purchases_finished_this_year = Purchase.objects.filter(date_finished__year=year)
purchases_finished_this_year_with_playtime = purchases_finished_this_year.annotate(
total_playtime=Sum(
F("session__duration_calculated") + F("session__duration_manual")
)
)
for purchase in purchases_finished_this_year_with_playtime:
formatted_playtime = format_duration(purchase.total_playtime, "%2.0H")
setattr(purchase, "formatted_playtime", formatted_playtime)
purchases_finished_this_year_released_this_year = ( purchases_finished_this_year_released_this_year = (
purchases_finished_this_year.filter(edition__year_released=year).order_by( purchases_finished_this_year.filter(edition__year_released=year).order_by(
"date_finished" "date_finished"
@ -442,7 +452,7 @@ def stats(request, year: int = 0):
"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.order_by( "all_finished_this_year": purchases_finished_this_year_with_playtime.order_by(
"date_finished" "date_finished"
), ),
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.order_by( "this_year_finished_this_year": purchases_finished_this_year_released_this_year.order_by(
@ -479,6 +489,7 @@ def stats(request, year: int = 0):
highest_session_average_game.session_average, "%2.0Hh %2.0mm" highest_session_average_game.session_average, "%2.0Hh %2.0mm"
), ),
"highest_session_average_game": highest_session_average_game, "highest_session_average_game": highest_session_average_game,
"title": f"{year} Stats",
} }
request.session["return_path"] = request.path request.session["return_path"] = request.path

112
poetry.lock generated
View File

@ -1,5 +1,19 @@
# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. # This file is automatically @generated by Poetry 1.7.1 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"
@ -222,6 +236,75 @@ 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"
@ -558,6 +641,22 @@ 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"
@ -777,6 +876,17 @@ 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"
@ -877,4 +987,4 @@ watchdog = ["watchdog (>=2.3)"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.12" python-versions = "^3.12"
content-hash = "498b3358998a9f3bbfb74fae7d6a90de7b55b9bdc76845bce52f65785afd0c1e" content-hash = "e864dc8abf6c84e5bb16ac2aa937c2a70561d15f3e8a1459866b9d6507e8773e"

View File

@ -12,6 +12,7 @@ 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"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
black = "^22.12.0" black = "^22.12.0"

35
tests/test_graphql.py Normal file
View File

@ -0,0 +1,35 @@
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,8 +38,11 @@ INSTALLED_APPS = [
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"graphene_django",
] ]
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")

View File

@ -16,11 +16,14 @@ 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: