Compare commits

..

3 Commits

8 changed files with 139 additions and 14 deletions

View File

@ -0,0 +1,8 @@
from ninja.pagination import PageNumberPagination as _PageNumberPagination
from app.api_base import BaseSchema
class PageNumberPagination(_PageNumberPagination):
class Input(_PageNumberPagination.Input, BaseSchema):
pass

View File

@ -3,11 +3,12 @@ from typing import Annotated
from django.db.models import QuerySet from django.db.models import QuerySet
from django.http import HttpRequest from django.http import HttpRequest
from ninja import Header from ninja import Header
from ninja.pagination import PageNumberPagination, paginate from ninja.pagination import paginate
from app.api_base import CamelCaseRouter from app.api_base import CamelCaseRouter
from scores.models import Score, Version from scores.models import Score, Version
from .pagination import PageNumberPagination
from .schemas import ScoreIn, ScoreOut from .schemas import ScoreIn, ScoreOut
router = CamelCaseRouter() router = CamelCaseRouter()
@ -41,4 +42,4 @@ def list_scores(
x_game_version: GameVersionHeader = None, x_game_version: GameVersionHeader = None,
) -> QuerySet[Score]: ) -> QuerySet[Score]:
version = resolve_version(x_game_version) version = resolve_version(x_game_version)
return Score.objects.filter(version=version) return Score.objects.filter(version=version).best_per_username().with_rank()

View File

@ -1,7 +1,7 @@
from django.db import models from django.db import models
from django.db.models import F from django.db.models import F
from django.db.models.expressions import Window from django.db.models.expressions import Window
from django.db.models.functions import Rank from django.db.models.functions import Lower, Rank, RowNumber
class Version(models.Model): class Version(models.Model):
@ -12,19 +12,37 @@ class Version(models.Model):
return self.name return self.name
class ScoreManager(models.Manager): class ScoreQuerySet(models.QuerySet):
def get_queryset(self) -> models.QuerySet["Score"]: def best_per_username(self) -> "ScoreQuerySet":
return ( """Keep only each user's highest score, matching usernames case-insensitively.
super()
.get_queryset() Lower scores are excluded from the result but remain in the database.
.select_related("version") """
.annotate( best_ids = (
rank=Window( self.alias(
expression=Rank(), _row=Window(
order_by=F("points").desc(), expression=RowNumber(),
partition_by=Lower("username"),
order_by=[F("points").desc(), F("created_at").asc()],
) )
) )
.filter(_row=1)
.values_list("pk", flat=True)
) )
return self.filter(pk__in=best_ids)
def with_rank(self) -> "ScoreQuerySet":
return self.annotate(
rank=Window(
expression=Rank(),
order_by=F("points").desc(),
)
)
class ScoreManager(models.Manager.from_queryset(ScoreQuerySet)):
def get_queryset(self) -> ScoreQuerySet:
return super().get_queryset().select_related("version")
class Score(models.Model): class Score(models.Model):

View File

@ -0,0 +1,42 @@
import pytest
from scores.models import Score, Version
@pytest.fixture
def version(db) -> Version:
version = Version.objects.create(name="default")
other = Version.objects.create(name="other")
Score.objects.create(username="legrems", points=120, version=version)
Score.objects.create(username="Legrems", points=119, version=version)
Score.objects.create(username="legrems", points=50, version=version)
Score.objects.create(username="alice", points=200, version=version)
# Same user, different version: must not collide across versions.
Score.objects.create(username="legrems", points=999, version=other)
return version
def best(version: Version) -> list[Score]:
return list(Score.objects.filter(version=version).best_per_username().with_rank())
def test_keeps_only_highest_per_case_insensitive_username(version: Version) -> None:
results = best(version)
assert len(results) == 2
by_name = {s.username.lower(): s for s in results}
assert by_name["legrems"].points == 120
assert by_name["alice"].points == 200
def test_ranks_recomputed_over_deduplicated_set(version: Version) -> None:
ranks = {s.username.lower(): s.rank for s in best(version)}
assert ranks["alice"] == 1
assert ranks["legrems"] == 2
def test_lower_scores_remain_in_db(version: Version) -> None:
assert Score.objects.filter(version=version).count() == 4

View File

@ -1 +0,0 @@
# Create your tests here.

View File

@ -28,6 +28,8 @@ dev = [
"django-extensions>=4.1", "django-extensions>=4.1",
"django-stubs>=6.0.5", "django-stubs>=6.0.5",
"ipython>=9.14.1", "ipython>=9.14.1",
"pytest>=9.1.1",
"pytest-django>=4.12.0",
"ruff>=0.15.17", "ruff>=0.15.17",
] ]

5
pytest.ini Normal file
View File

@ -0,0 +1,5 @@
[pytest]
DJANGO_SETTINGS_MODULE = app.settings
pythonpath = highscore
python_files = test_*.py tests.py
testpaths = highscore

50
uv.lock
View File

@ -365,6 +365,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/55/0f4df2a44053867ea9cbea73fc588b03c55605cd695cee0a3d86f0029cb2/incremental-24.11.0-py3-none-any.whl", hash = "sha256:a34450716b1c4341fe6676a0598e88a39e04189f4dce5dc96f656e040baa10b3", size = 21109, upload-time = "2025-11-28T02:30:16.442Z" }, { url = "https://files.pythonhosted.org/packages/1d/55/0f4df2a44053867ea9cbea73fc588b03c55605cd695cee0a3d86f0029cb2/incremental-24.11.0-py3-none-any.whl", hash = "sha256:a34450716b1c4341fe6676a0598e88a39e04189f4dce5dc96f656e040baa10b3", size = 21109, upload-time = "2025-11-28T02:30:16.442Z" },
] ]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]] [[package]]
name = "ipython" name = "ipython"
version = "9.14.1" version = "9.14.1"
@ -510,6 +519,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" },
] ]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]] [[package]]
name = "prompt-toolkit" name = "prompt-toolkit"
version = "3.0.52" version = "3.0.52"
@ -701,6 +719,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" }, { url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" },
] ]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "pytest-django"
version = "4.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/13/2b/db9a193df89e5660137f5428063bcc2ced7ad790003b26974adf5c5ceb3b/pytest_django-4.12.0.tar.gz", hash = "sha256:df94ec819a83c8979c8f6de13d9cdfbe76e8c21d39473cfe2b40c9fc9be3c758", size = 91156, upload-time = "2026-02-14T18:40:49.235Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/a5/41d091f697c09609e7ef1d5d61925494e0454ebf51de7de05f0f0a728f1d/pytest_django-4.12.0-py3-none-any.whl", hash = "sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85", size = 26123, upload-time = "2026-02-14T18:40:47.381Z" },
]
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.2" version = "1.2.2"
@ -753,6 +799,8 @@ dev = [
{ name = "django-extensions" }, { name = "django-extensions" },
{ name = "django-stubs" }, { name = "django-stubs" },
{ name = "ipython" }, { name = "ipython" },
{ name = "pytest" },
{ name = "pytest-django" },
{ name = "ruff" }, { name = "ruff" },
] ]
@ -771,6 +819,8 @@ dev = [
{ name = "django-extensions", specifier = ">=4.1" }, { name = "django-extensions", specifier = ">=4.1" },
{ name = "django-stubs", specifier = ">=6.0.5" }, { name = "django-stubs", specifier = ">=6.0.5" },
{ name = "ipython", specifier = ">=9.14.1" }, { name = "ipython", specifier = ">=9.14.1" },
{ name = "pytest", specifier = ">=9.1.1" },
{ name = "pytest-django", specifier = ">=4.12.0" },
{ name = "ruff", specifier = ">=0.15.17" }, { name = "ruff", specifier = ">=0.15.17" },
] ]