Compare commits
3 Commits
54259d64e2
...
6ad86af568
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ad86af568 | |||
| fcb995dbd4 | |||
| ec58b74db5 |
8
highscore/scores/api/pagination.py
Normal file
8
highscore/scores/api/pagination.py
Normal 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
|
||||
@ -3,11 +3,12 @@ from typing import Annotated
|
||||
from django.db.models import QuerySet
|
||||
from django.http import HttpRequest
|
||||
from ninja import Header
|
||||
from ninja.pagination import PageNumberPagination, paginate
|
||||
from ninja.pagination import paginate
|
||||
|
||||
from app.api_base import CamelCaseRouter
|
||||
from scores.models import Score, Version
|
||||
|
||||
from .pagination import PageNumberPagination
|
||||
from .schemas import ScoreIn, ScoreOut
|
||||
|
||||
router = CamelCaseRouter()
|
||||
@ -41,4 +42,4 @@ def list_scores(
|
||||
x_game_version: GameVersionHeader = None,
|
||||
) -> QuerySet[Score]:
|
||||
version = resolve_version(x_game_version)
|
||||
return Score.objects.filter(version=version)
|
||||
return Score.objects.filter(version=version).best_per_username().with_rank()
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
from django.db import models
|
||||
from django.db.models import F
|
||||
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):
|
||||
@ -12,19 +12,37 @@ class Version(models.Model):
|
||||
return self.name
|
||||
|
||||
|
||||
class ScoreManager(models.Manager):
|
||||
def get_queryset(self) -> models.QuerySet["Score"]:
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.select_related("version")
|
||||
.annotate(
|
||||
rank=Window(
|
||||
expression=Rank(),
|
||||
order_by=F("points").desc(),
|
||||
class ScoreQuerySet(models.QuerySet):
|
||||
def best_per_username(self) -> "ScoreQuerySet":
|
||||
"""Keep only each user's highest score, matching usernames case-insensitively.
|
||||
|
||||
Lower scores are excluded from the result but remain in the database.
|
||||
"""
|
||||
best_ids = (
|
||||
self.alias(
|
||||
_row=Window(
|
||||
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):
|
||||
|
||||
42
highscore/scores/test_models.py
Normal file
42
highscore/scores/test_models.py
Normal 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
|
||||
@ -1 +0,0 @@
|
||||
# Create your tests here.
|
||||
@ -28,6 +28,8 @@ dev = [
|
||||
"django-extensions>=4.1",
|
||||
"django-stubs>=6.0.5",
|
||||
"ipython>=9.14.1",
|
||||
"pytest>=9.1.1",
|
||||
"pytest-django>=4.12.0",
|
||||
"ruff>=0.15.17",
|
||||
]
|
||||
|
||||
|
||||
5
pytest.ini
Normal file
5
pytest.ini
Normal file
@ -0,0 +1,5 @@
|
||||
[pytest]
|
||||
DJANGO_SETTINGS_MODULE = app.settings
|
||||
pythonpath = highscore
|
||||
python_files = test_*.py tests.py
|
||||
testpaths = highscore
|
||||
50
uv.lock
50
uv.lock
@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "ipython"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "prompt-toolkit"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.2"
|
||||
@ -753,6 +799,8 @@ dev = [
|
||||
{ name = "django-extensions" },
|
||||
{ name = "django-stubs" },
|
||||
{ name = "ipython" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-django" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
@ -771,6 +819,8 @@ dev = [
|
||||
{ name = "django-extensions", specifier = ">=4.1" },
|
||||
{ name = "django-stubs", specifier = ">=6.0.5" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user