feat: add final.png export
This commit is contained in:
@@ -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 = ""):
|
||||
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user