Compare commits

..

No commits in common. "6ad86af56815f47a7005b9cb474905d210b258f1" and "54259d64e2caf049de3d6fb4b090fa2e28e27cbc" have entirely different histories.

8 changed files with 14 additions and 139 deletions

View File

@ -1,8 +0,0 @@
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,12 +3,11 @@ 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 paginate from ninja.pagination import PageNumberPagination, 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()
@ -42,4 +41,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).best_per_username().with_rank() return Score.objects.filter(version=version)

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 Lower, Rank, RowNumber from django.db.models.functions import Rank
class Version(models.Model): class Version(models.Model):
@ -12,37 +12,19 @@ class Version(models.Model):
return self.name return self.name
class ScoreQuerySet(models.QuerySet): class ScoreManager(models.Manager):
def best_per_username(self) -> "ScoreQuerySet": def get_queryset(self) -> models.QuerySet["Score"]:
"""Keep only each user's highest score, matching usernames case-insensitively. return (
super()
Lower scores are excluded from the result but remain in the database. .get_queryset()
""" .select_related("version")
best_ids = ( .annotate(
self.alias( rank=Window(
_row=Window( expression=Rank(),
expression=RowNumber(), order_by=F("points").desc(),
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

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

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

View File

@ -28,8 +28,6 @@ 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",
] ]

View File

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

50
uv.lock
View File

@ -365,15 +365,6 @@ 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"
@ -519,15 +510,6 @@ 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"
@ -719,34 +701,6 @@ 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"
@ -799,8 +753,6 @@ 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" },
] ]
@ -819,8 +771,6 @@ 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" },
] ]