"""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 ], }