wip: planning-slot test
This commit is contained in:
@@ -7,7 +7,7 @@ from django_object_actions import (
|
||||
|
||||
from submissions.tasks import schedule_scope_sync, stop_scope_sync
|
||||
|
||||
from .models import EventGame, Game
|
||||
from .models import EventGame, Game, PlanningSlot
|
||||
from .tasks import refresh_steam_games_task
|
||||
|
||||
|
||||
@@ -90,3 +90,10 @@ class EventGameAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||
stopped = sum(stop_scope_sync(event_game.id) for event_game in queryset)
|
||||
|
||||
self.message_user(request, f"Stopped scope sync for {stopped} EventGame(s).")
|
||||
|
||||
|
||||
@admin.register(PlanningSlot)
|
||||
class PlanningSlotAdmin(admin.ModelAdmin):
|
||||
list_display = ["title", "event", "begin", "end", "column", "is_multi_anims"]
|
||||
list_filter = ["event", "is_multi_anims"]
|
||||
search_fields = ["title"]
|
||||
|
||||
@@ -3,7 +3,8 @@ from typing import List
|
||||
from ninja import Router
|
||||
|
||||
from .models import Game
|
||||
from .schemas import GameOut, SteamGameOut
|
||||
from .schemas import GameOut, PlanningSlotOut, SteamGameOut
|
||||
from .services.planning import planning_with_games
|
||||
from .tasks import schedule_steam_refresh
|
||||
|
||||
router = Router()
|
||||
@@ -28,3 +29,9 @@ def list_event_steam_games(request, event_path: str):
|
||||
return Game.objects.enabled_for_event(request.event).filter(
|
||||
steam_app_id__isnull=False
|
||||
)
|
||||
|
||||
|
||||
@event_router.get("/events/{event_path}/planning", response=List[PlanningSlotOut])
|
||||
def list_event_planning(request, event_path: str):
|
||||
"""The event planning, with the covers of the games we know."""
|
||||
return planning_with_games(request.event.planning_slots.all())
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import json
|
||||
import sys
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
|
||||
from events.models import Event
|
||||
from games.services.planning import import_planning
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = (
|
||||
"Import an event planning from the legacy PolyLAN planning JSON "
|
||||
"(slots missing from the file are deleted)"
|
||||
)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("event_path", help="Path of the event, e.g. polylan-43")
|
||||
parser.add_argument("json_file", help="Planning JSON file, '-' for stdin")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
try:
|
||||
event = Event.objects.get(path=options["event_path"])
|
||||
except Event.DoesNotExist:
|
||||
raise CommandError(f"No event with path {options['event_path']!r}")
|
||||
|
||||
if options["json_file"] == "-":
|
||||
data = json.load(sys.stdin)
|
||||
else:
|
||||
with open(options["json_file"], encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
entries = data["list"] if isinstance(data, dict) else data
|
||||
with transaction.atomic():
|
||||
created, updated, deleted = import_planning(event, entries)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Planning of {event}: {created} created, {updated} updated, {deleted} deleted"
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
# Generated by Django 6.0.5 on 2026-10-16 03:14
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("events", "0003_event_is_available"),
|
||||
("games", "0011_game_library_image"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="PlanningSlot",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("source_pk", models.PositiveIntegerField()),
|
||||
("title", models.CharField(max_length=255)),
|
||||
("begin", models.DateTimeField()),
|
||||
("end", models.DateTimeField()),
|
||||
("column", models.PositiveSmallIntegerField(default=0)),
|
||||
("color", models.CharField(blank=True, default="", max_length=7)),
|
||||
("is_multi_anims", models.BooleanField(default=False)),
|
||||
("game_names", models.JSONField(blank=True, default=list)),
|
||||
(
|
||||
"event",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="planning_slots",
|
||||
to="events.event",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["begin", "column"],
|
||||
"constraints": [
|
||||
models.UniqueConstraint(
|
||||
fields=("event", "source_pk"), name="unique_event_planning_slot"
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -42,6 +42,19 @@ class Game(models.Model):
|
||||
return f"{self.name} ({self.steam_app_id})"
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def store_url(self) -> str:
|
||||
if not self.steam_app_id:
|
||||
return ""
|
||||
return f"https://store.steampowered.com/app/{self.steam_app_id}"
|
||||
|
||||
@property
|
||||
def library_image_url(self) -> str:
|
||||
"""Vertical cover, like in the Steam library. Empty if there's none."""
|
||||
if self.library_image or not self.steam_app_id:
|
||||
return self.library_image
|
||||
return f"https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/{self.steam_app_id}/library_600x900.jpg"
|
||||
|
||||
|
||||
class EventGame(models.Model):
|
||||
event = models.ForeignKey(
|
||||
@@ -76,3 +89,37 @@ class EventGame(models.Model):
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.game.name} @ {self.event.name} ({'enabled' if self.enabled else 'disabled'})"
|
||||
|
||||
|
||||
class PlanningSlot(models.Model):
|
||||
"""One slot of the event planning, imported from the legacy PolyLAN planning
|
||||
(see the import_planning command)."""
|
||||
|
||||
event = models.ForeignKey(
|
||||
Event,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="planning_slots",
|
||||
)
|
||||
# pk of the slot in the legacy planning, to update it on re-import
|
||||
source_pk = models.PositiveIntegerField()
|
||||
title = models.CharField(max_length=255)
|
||||
begin = models.DateTimeField()
|
||||
end = models.DateTimeField()
|
||||
column = models.PositiveSmallIntegerField(default=0)
|
||||
color = models.CharField(max_length=7, blank=True, default="")
|
||||
# Multi-anims (e.g. "Fils rouges") run several games over the whole event
|
||||
is_multi_anims = models.BooleanField(default=False)
|
||||
# Names of the games run in this slot (the legacy "aandts")
|
||||
game_names = models.JSONField(default=list, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["begin", "column"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["event", "source_pk"],
|
||||
name="unique_event_planning_slot",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.title} @ {self.event.name} ({self.begin:%a %H:%M})"
|
||||
|
||||
@@ -36,7 +36,7 @@ class SteamGameOut(Schema):
|
||||
|
||||
@staticmethod
|
||||
def resolve_store_url(obj) -> str:
|
||||
return f"https://store.steampowered.com/app/{obj.steam_app_id}"
|
||||
return obj.store_url
|
||||
|
||||
@staticmethod
|
||||
def resolve_header_image(obj) -> str:
|
||||
@@ -47,11 +47,7 @@ class SteamGameOut(Schema):
|
||||
|
||||
@staticmethod
|
||||
def resolve_library_image(obj) -> str:
|
||||
# Vertical capsule, like in the Steam library
|
||||
return (
|
||||
obj.library_image
|
||||
or f"https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/{obj.steam_app_id}/library_600x900.jpg"
|
||||
)
|
||||
return obj.library_image_url
|
||||
|
||||
@staticmethod
|
||||
def resolve_short_description(obj) -> str:
|
||||
@@ -80,3 +76,23 @@ class SteamGameOut(Schema):
|
||||
@staticmethod
|
||||
def resolve_updated_at(obj) -> Optional[datetime]:
|
||||
return obj.steam_info_updated_at
|
||||
|
||||
|
||||
class PlanningGameOut(Schema):
|
||||
name: str
|
||||
# Empty when the name didn't match any of our games
|
||||
library_image: str = ""
|
||||
store_url: str = ""
|
||||
steam_app_id: Optional[int] = None
|
||||
|
||||
|
||||
class PlanningSlotOut(Schema):
|
||||
id: int
|
||||
title: str
|
||||
begin: datetime
|
||||
end: datetime
|
||||
column: int
|
||||
color: str
|
||||
is_multi_anims: bool
|
||||
# Single slot: the slot's game. Multi-anims: each of their games.
|
||||
games: list[PlanningGameOut]
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
from ..models import Game, PlanningSlot
|
||||
|
||||
|
||||
def normalize_name(name: str) -> str:
|
||||
"""'Towerfall: Ascension' and 'TowerFall Ascension' are the same game:
|
||||
no accents, no case, letters and digits only."""
|
||||
name = unicodedata.normalize("NFKD", name)
|
||||
name = "".join(c for c in name if not unicodedata.combining(c))
|
||||
return re.sub(r"[^0-9a-z]", "", name.casefold())
|
||||
|
||||
|
||||
def _game_out(name: str, game: Game | None) -> dict:
|
||||
if game is None:
|
||||
return {"name": name}
|
||||
return {
|
||||
"name": game.name,
|
||||
"library_image": game.library_image_url,
|
||||
"store_url": game.store_url,
|
||||
"steam_app_id": game.steam_app_id,
|
||||
}
|
||||
|
||||
|
||||
def planning_with_games(slots) -> list[dict]:
|
||||
"""Slots as PlanningSlotOut dicts, with their games matched by name to ours."""
|
||||
games_by_name = {normalize_name(game.name): game for game in Game.objects.all()}
|
||||
|
||||
def match(name: str) -> Game | None:
|
||||
return games_by_name.get(normalize_name(name))
|
||||
|
||||
out = []
|
||||
for slot in slots:
|
||||
if slot.is_multi_anims:
|
||||
games = [_game_out(name, match(name)) for name in slot.game_names]
|
||||
else:
|
||||
# The title is the usual match, the legacy game names are a fallback
|
||||
# ("Golf it!" slot running the "Golf It!" game)
|
||||
game = next(
|
||||
filter(None, map(match, [slot.title, *slot.game_names])), None
|
||||
)
|
||||
games = [_game_out(slot.title, game)]
|
||||
|
||||
out.append(
|
||||
{
|
||||
"id": slot.id,
|
||||
"title": slot.title,
|
||||
"begin": slot.begin,
|
||||
"end": slot.end,
|
||||
"column": slot.column,
|
||||
"color": slot.color,
|
||||
"is_multi_anims": slot.is_multi_anims,
|
||||
"games": games,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def import_planning(event, entries: list[dict]) -> tuple[int, int, int]:
|
||||
"""Sync the event planning with the legacy planning entries (its "list").
|
||||
Returns (created, updated, deleted)."""
|
||||
created = updated = 0
|
||||
for entry in entries:
|
||||
_, was_created = PlanningSlot.objects.update_or_create(
|
||||
event=event,
|
||||
source_pk=entry["pk"],
|
||||
defaults={
|
||||
"title": entry["title"],
|
||||
"begin": entry["begin"],
|
||||
"end": entry["end"],
|
||||
"column": entry.get("colonneS") or 0,
|
||||
"color": entry.get("color_code") or "",
|
||||
"is_multi_anims": bool(entry.get("is_multi_anims")),
|
||||
"game_names": [a["name"] for a in entry.get("aandts") or []],
|
||||
},
|
||||
)
|
||||
if was_created:
|
||||
created += 1
|
||||
else:
|
||||
updated += 1
|
||||
|
||||
deleted, _ = (
|
||||
PlanningSlot.objects.filter(event=event)
|
||||
.exclude(source_pk__in=[entry["pk"] for entry in entries])
|
||||
.delete()
|
||||
)
|
||||
return created, updated, deleted
|
||||
Reference in New Issue
Block a user