feat: add admin stats
This commit is contained in:
@@ -18,6 +18,8 @@ from .heatmap import (
|
||||
build_heatmap_png,
|
||||
)
|
||||
from .models import PixelPlacement, PlaceSettings
|
||||
from .schemas import PlaceStatsOut
|
||||
from .stats import build_place_stats
|
||||
|
||||
router = Router()
|
||||
|
||||
@@ -104,6 +106,14 @@ def heatmap_decay_gif(
|
||||
)
|
||||
|
||||
|
||||
@router.get(f"{PREFIX}/stats", auth=django_auth_is_staff, response=PlaceStatsOut)
|
||||
@decorate_view(require_game_enabled(path="place"))
|
||||
def stats(request: HttpRequest, event_path: str):
|
||||
"""Admin-only: aggregated stats about the game - totals, coverage, top
|
||||
players, most contested pixels, color usage and activity per hour."""
|
||||
return build_place_stats(request.event)
|
||||
|
||||
|
||||
@router.get(f"{PREFIX}/placements.csv", auth=django_auth_is_staff)
|
||||
@decorate_view(require_game_enabled(path="place"))
|
||||
def placements_csv(request: HttpRequest, event_path: str):
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
from datetime import datetime
|
||||
|
||||
from ninja import Schema
|
||||
|
||||
|
||||
class PlayerCountOut(Schema):
|
||||
username: str
|
||||
placements: int
|
||||
|
||||
|
||||
class PixelCountOut(Schema):
|
||||
x: int
|
||||
y: int
|
||||
placements: int
|
||||
players: int
|
||||
|
||||
|
||||
class ColorCountOut(Schema):
|
||||
color_index: int
|
||||
color: str
|
||||
placements: int
|
||||
# How many pixels of the live board currently have this color
|
||||
on_canvas: int
|
||||
|
||||
|
||||
class HourCountOut(Schema):
|
||||
hour: datetime
|
||||
placements: int
|
||||
players: int
|
||||
|
||||
|
||||
class PlaceStatsOut(Schema):
|
||||
width: int
|
||||
height: int
|
||||
total_placements: int
|
||||
total_players: int
|
||||
first_placement_at: datetime | None
|
||||
last_placement_at: datetime | None
|
||||
# Distinct pixels placed at least once (within the current grid)
|
||||
pixels_touched: int
|
||||
coverage: float
|
||||
# Placements that landed on an already placed pixel
|
||||
overwrites: int
|
||||
avg_placements_per_player: float
|
||||
top_players: list[PlayerCountOut]
|
||||
most_contested_pixels: list[PixelCountOut]
|
||||
colors: list[ColorCountOut]
|
||||
per_hour: list[HourCountOut]
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Aggregated stats about one event's place game, for the admin stats
|
||||
endpoint. Everything comes from the durable PixelPlacement history, except
|
||||
the colors currently on the board, which are read off the live Redis canvas.
|
||||
"""
|
||||
|
||||
from django.db.models import Count, Max, Min
|
||||
from django.db.models.functions import TruncHour
|
||||
|
||||
from events.models import Event
|
||||
|
||||
from .canvas import read_canvas
|
||||
from .models import PALETTE, PixelPlacement, PlaceSettings
|
||||
|
||||
TOP_N = 10
|
||||
|
||||
|
||||
def build_place_stats(event: Event) -> dict:
|
||||
place_settings = PlaceSettings.current(event)
|
||||
width, height = place_settings.width, place_settings.height
|
||||
|
||||
placements = PixelPlacement.objects.filter(event=event)
|
||||
totals = placements.aggregate(
|
||||
total=Count("id"),
|
||||
players=Count("player", distinct=True),
|
||||
first=Min("placed_at"),
|
||||
last=Max("placed_at"),
|
||||
)
|
||||
total, players = totals["total"], totals["players"]
|
||||
|
||||
# Same as heatmap.py, placements outside the current grid are skipped
|
||||
in_grid = placements.filter(x__lt=width, y__lt=height)
|
||||
pixels_touched = in_grid.values("x", "y").distinct().count()
|
||||
|
||||
top_players = (
|
||||
placements.values("player__username")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count", "player__username")[:TOP_N]
|
||||
)
|
||||
contested = (
|
||||
in_grid.values("x", "y")
|
||||
.annotate(count=Count("id"), players=Count("player", distinct=True))
|
||||
.order_by("-count", "-players", "y", "x")[:TOP_N]
|
||||
)
|
||||
color_counts = dict(
|
||||
placements.values_list("color_index")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by()
|
||||
)
|
||||
# Redis only pads up to the last written pixel, and keeps old bytes
|
||||
# after a shrink, so normalize to the current grid size
|
||||
size = width * height
|
||||
canvas = read_canvas(event.path, width, height)[:size].ljust(size, b"\0")
|
||||
per_hour = (
|
||||
placements.annotate(hour=TruncHour("placed_at"))
|
||||
.values("hour")
|
||||
.annotate(count=Count("id"), players=Count("player", distinct=True))
|
||||
.order_by("hour")
|
||||
)
|
||||
|
||||
return {
|
||||
"width": width,
|
||||
"height": height,
|
||||
"total_placements": total,
|
||||
"total_players": players,
|
||||
"first_placement_at": totals["first"],
|
||||
"last_placement_at": totals["last"],
|
||||
"pixels_touched": pixels_touched,
|
||||
"coverage": pixels_touched / size if size else 0.0,
|
||||
"overwrites": in_grid.count() - pixels_touched,
|
||||
"avg_placements_per_player": total / players if players else 0.0,
|
||||
"top_players": [
|
||||
{"username": row["player__username"], "placements": row["count"]}
|
||||
for row in top_players
|
||||
],
|
||||
"most_contested_pixels": [
|
||||
{
|
||||
"x": row["x"],
|
||||
"y": row["y"],
|
||||
"placements": row["count"],
|
||||
"players": row["players"],
|
||||
}
|
||||
for row in contested
|
||||
],
|
||||
"colors": [
|
||||
{
|
||||
"color_index": index,
|
||||
"color": color,
|
||||
"placements": color_counts.get(index, 0),
|
||||
"on_canvas": canvas.count(index),
|
||||
}
|
||||
for index, color in enumerate(PALETTE)
|
||||
],
|
||||
"per_hour": [
|
||||
{"hour": row["hour"], "placements": row["count"], "players": row["players"]}
|
||||
for row in per_hour
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from accounts.models import CustomUser
|
||||
from events.models import Event
|
||||
from games.models import EventGame, Game
|
||||
|
||||
from .models import PixelPlacement, PlaceSettings
|
||||
|
||||
URL = "/api/events/event/place/stats"
|
||||
|
||||
|
||||
@mock.patch("place.stats.read_canvas")
|
||||
class PlaceStatsTests(TestCase):
|
||||
def setUp(self):
|
||||
self.event = Event.objects.create(name="Event", path="event")
|
||||
place = Game.objects.create(name="Place", path="place", custom_path=True)
|
||||
EventGame.objects.create(event=self.event, game=place)
|
||||
PlaceSettings.objects.create(event=self.event, width=4, height=2)
|
||||
self.alice = CustomUser.objects.create_user(username="alice", password="x")
|
||||
self.bob = CustomUser.objects.create_user(username="bob", password="x")
|
||||
self.admin = CustomUser.objects.create_user(
|
||||
username="admin", password="x", is_staff=True
|
||||
)
|
||||
|
||||
def place(self, player, x, y, color_index):
|
||||
PixelPlacement.objects.create(
|
||||
event=self.event, player=player, x=x, y=y, color_index=color_index
|
||||
)
|
||||
|
||||
def test_requires_staff(self, _):
|
||||
self.client.force_login(self.alice)
|
||||
|
||||
response = self.client.get(URL)
|
||||
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_empty_game(self, read_canvas):
|
||||
read_canvas.return_value = b""
|
||||
self.client.force_login(self.admin)
|
||||
|
||||
data = self.client.get(URL).json()
|
||||
|
||||
self.assertEqual(data["total_placements"], 0)
|
||||
self.assertEqual(data["total_players"], 0)
|
||||
self.assertEqual(data["coverage"], 0.0)
|
||||
self.assertEqual(data["avg_placements_per_player"], 0.0)
|
||||
self.assertEqual(data["colors"][0]["on_canvas"], 8)
|
||||
self.assertEqual(data["per_hour"], [])
|
||||
|
||||
def test_stats(self, read_canvas):
|
||||
self.place(self.alice, 0, 0, 5)
|
||||
self.place(self.bob, 0, 0, 3)
|
||||
self.place(self.alice, 0, 0, 5)
|
||||
self.place(self.alice, 1, 0, 3)
|
||||
# Outside the current grid, e.g. from before a shrink
|
||||
self.place(self.bob, 9, 9, 3)
|
||||
# Short canvas, Redis only padded up to the last written pixel
|
||||
read_canvas.return_value = bytes([5, 3])
|
||||
self.client.force_login(self.admin)
|
||||
|
||||
data = self.client.get(URL).json()
|
||||
|
||||
self.assertEqual(data["total_placements"], 5)
|
||||
self.assertEqual(data["total_players"], 2)
|
||||
self.assertEqual(data["pixels_touched"], 2)
|
||||
self.assertEqual(data["coverage"], 0.25)
|
||||
self.assertEqual(data["overwrites"], 2)
|
||||
self.assertEqual(data["avg_placements_per_player"], 2.5)
|
||||
self.assertEqual(
|
||||
data["top_players"],
|
||||
[
|
||||
{"username": "alice", "placements": 3},
|
||||
{"username": "bob", "placements": 2},
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
data["most_contested_pixels"][0],
|
||||
{"x": 0, "y": 0, "placements": 3, "players": 2},
|
||||
)
|
||||
colors = {row["color_index"]: row for row in data["colors"]}
|
||||
self.assertEqual(colors[5]["placements"], 2)
|
||||
self.assertEqual(colors[3]["placements"], 3)
|
||||
self.assertEqual(colors[5]["on_canvas"], 1)
|
||||
self.assertEqual(colors[0]["on_canvas"], 6)
|
||||
self.assertEqual(sum(row["placements"] for row in data["per_hour"]), 5)
|
||||
Reference in New Issue
Block a user