feat: add final.png export

This commit is contained in:
2026-10-25 14:07:57 +01:00
parent 73b279d7fe
commit 5fc79197f6
7 changed files with 147 additions and 7 deletions
+9
View File
@@ -10,6 +10,7 @@ from ninja.security import django_auth_is_staff
from games.decorators import require_game_enabled
from .canvas import read_canvas
from .export import build_final_png
from .gif import build_evolution_gif
from .heatmap import (
DecayMode,
@@ -59,6 +60,14 @@ def evolution_gif(request: HttpRequest, event_path: str, players: str = ""):
)
@router.get(f"{PREFIX}/final.png", auth=django_auth_is_staff)
@decorate_view(require_game_enabled(path="place"))
def final_png(request: HttpRequest, event_path: str):
"""Admin-only: the real final board as a PNG, even once placement is
over and everyone else sees it wiped."""
return HttpResponse(build_final_png(request.event), content_type="image/png")
@router.get(f"{PREFIX}/heatmap.png", auth=django_auth_is_staff)
@decorate_view(require_game_enabled(path="place"))
def heatmap_png(request: HttpRequest, event_path: str, players: str = ""):
+31
View File
@@ -0,0 +1,31 @@
"""Exports one event's final board as a PNG.
Reads the live Redis canvas rather than replaying the history like gif.py -
once placement is over the canvas endpoint serves a wiped board, but Redis
still holds the real final one (see tasks.py).
"""
from io import BytesIO
from events.models import Event
from .canvas import read_canvas
from .gif import _render_frame
from .models import PlaceSettings
def build_final_png(event: Event) -> bytes:
"""Renders `event`'s current board as a PNG, upscaled like gif.py.
Redis only pads up to the last written pixel, so a short canvas is
padded with white (and a longer one, e.g. from before a shrink, cut)
to the current grid dimensions."""
place_settings = PlaceSettings.current(event)
width, height = place_settings.width, place_settings.height
size = width * height
canvas = read_canvas(event.path, width, height)[:size].ljust(size, b"\0")
buffer = BytesIO()
_render_frame(bytearray(canvas), width, height).save(buffer, format="PNG")
return buffer.getvalue()
+42
View File
@@ -1,9 +1,11 @@
from datetime import timedelta
from io import BytesIO
from unittest import mock
from django.core.cache import cache
from django.test import TestCase, override_settings
from django.utils import timezone
from PIL import Image
from accounts.models import CustomUser
from events.models import Event
@@ -15,6 +17,7 @@ from .wire import to_epoch_ms
URL = "/api/events/event/place/stats"
CANVAS_URL = "/api/events/event/place/canvas"
FINAL_URL = "/api/events/event/place/final.png"
LOCMEM_CACHE = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
@@ -94,6 +97,45 @@ class PlaceStatsTests(TestCase):
self.assertEqual(sum(row["placements"] for row in data["per_hour"]), 5)
@mock.patch("place.export.read_canvas")
class FinalPngTests(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=2,
height=1,
# Ended, the real board is still exported
placement_ends_at=timezone.now() - timedelta(hours=1),
)
self.alice = CustomUser.objects.create_user(username="alice", password="x")
self.admin = CustomUser.objects.create_user(
username="admin", password="x", is_staff=True
)
def test_requires_staff(self, _):
self.client.force_login(self.alice)
response = self.client.get(FINAL_URL)
self.assertEqual(response.status_code, 401)
def test_final_png(self, read_canvas):
# Short canvas, Redis only padded up to the last written pixel
read_canvas.return_value = bytes([5])
self.client.force_login(self.admin)
response = self.client.get(FINAL_URL)
self.assertEqual(response["Content-Type"], "image/png")
image = Image.open(BytesIO(response.content))
self.assertEqual(image.size, (16, 8))
self.assertEqual(image.getpixel((0, 0)), (229, 0, 0))
self.assertEqual(image.getpixel((15, 0)), (255, 255, 255))
@override_settings(CACHES=LOCMEM_CACHE)
@mock.patch("place.signals.rebuild_canvas_from_history")
class PlacementEndTests(TestCase):