From 457211b2db8a116237f0dfbec7e6bb70077926ad Mon Sep 17 00:00:00 2001 From: Julien Marcon Date: Thu, 27 Aug 2026 08:59:23 +0200 Subject: [PATCH 1/2] feat: fallback female sprites to their default counterparts for female-only pokemon For pokemon whose species has gender_rate=8 (exclusively female), fill the null female sprite keys with their default counterparts across all sprite sections. Co-Authored-By: Claude Opus 5 --- pokemon_v2/serializers.py | 32 ++++++++++++++++++++++++++++++-- pokemon_v2/tests.py | 20 ++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/pokemon_v2/serializers.py b/pokemon_v2/serializers.py index fb85c66cb..8e050b3a3 100644 --- a/pokemon_v2/serializers.py +++ b/pokemon_v2/serializers.py @@ -3,6 +3,7 @@ from __future__ import annotations import itertools +import json from typing import TYPE_CHECKING, Any, ClassVar, Protocol, cast from django.db.models import Q @@ -2933,10 +2934,37 @@ class Meta: "past_types", ) + _FEMALE_FALLBACKS: ClassVar[dict[str, str]] = { + "front_female": "front_default", + "back_female": "back_default", + "front_shiny_female": "front_shiny", + "back_shiny_female": "back_shiny", + } + @extend_schema_field(PokemonSpritesSerializer) - def get_pokemon_sprites(self, obj: Pokemon) -> dict[str, str | None]: + def get_pokemon_sprites(self, obj: Pokemon) -> dict[str, str | None] | str: sprites_list = list(cast("PokemonWithRelations", obj).pokemonsprites.all()) - return sprites_list[0].sprites if sprites_list else {} + if not sprites_list: + return {} + sprites: Any = sprites_list[0].sprites + if obj.pokemon_species is None or obj.pokemon_species.gender_rate != 8: + return sprites + if isinstance(sprites, str): + parsed = json.loads(sprites) + self._fill_female_sprites(parsed) + return json.dumps(parsed) + self._fill_female_sprites(sprites) + return sprites + + def _fill_female_sprites(self, node: object) -> None: + if not isinstance(node, dict): + return + sprites = cast("dict[str, Any]", node) + for female_key, default_key in self._FEMALE_FALLBACKS.items(): + if female_key in sprites and sprites[female_key] is None and sprites.get(default_key) is not None: + sprites[female_key] = sprites[default_key] + for value in sprites.values(): + self._fill_female_sprites(value) @extend_schema_field(PokemonCriesSerializer) def get_pokemon_cries(self, obj: Pokemon) -> dict[str, str | None]: diff --git a/pokemon_v2/tests.py b/pokemon_v2/tests.py index 80e4c4903..35d1dbdec 100644 --- a/pokemon_v2/tests.py +++ b/pokemon_v2/tests.py @@ -4649,6 +4649,26 @@ def test_pokemon_moves_version_group_details_are_deterministically_ordered(self) [1, 2, 3], ) + def test_pokemon_api_female_only_sprites_fallback(self): + pokemon_species = self.setup_pokemon_species_data(name="female only pkmn spcs", gender_rate=8) + pokemon = self.setup_pokemon_data(pokemon_species=pokemon_species, name="female only pkmn") + self.setup_pokemon_form_data(pokemon=pokemon, name="female only pkmn form") + self.setup_pokemon_sprites_data(pokemon=pokemon, front_default=True, front_female=False) + self.setup_pokemon_cries_data(pokemon, latest=True, legacy=True) + + response = self.client.get("{}/pokemon/{}/".format(API_V2, pokemon.pk), headers={"host": "testserver"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response_sprites = json.loads(response.data["sprites"]) + + self.assertIsNotNone(response_sprites["front_default"]) + self.assertEqual(response_sprites["front_female"], response_sprites["front_default"]) + self.assertEqual( + response_sprites["other"]["showdown"]["front_female"], + response_sprites["other"]["showdown"]["front_default"], + ) + def test_pokemon_form_api(self): pokemon_species = self.setup_pokemon_species_data() pokemon = self.setup_pokemon_data(pokemon_species=pokemon_species) From ae2616e37758ac86e34ea4c04148a94292fb0ecb Mon Sep 17 00:00:00 2001 From: Julien Marcon Date: Sat, 29 Aug 2026 10:57:31 +0200 Subject: [PATCH 2/2] @ refactor: simplify female sprite fallback per review The sprites model field is a JSONField, so it is always decoded to a python dict on read: drop the string/json.loads/json.dumps branch which would have returned an escaped JSON string in the response. Simplify get_pokemon_sprites to a positive guard and narrow its return type to dict[str, Any]. In _fill_female_sprites, type the parameter as dict[str, Any] and move the isinstance check into the recursion loop instead of guarding at the function entry, which also removes a cast. Align the test fixture with data/v2/build.py: it double-encoded the sprites dict with json.dumps before storing it in the JSONField. Co-Authored-By: Claude Opus 5 @ --- pokemon_v2/serializers.py | 22 +++++++--------------- pokemon_v2/tests.py | 8 ++++---- 2 files changed, 11 insertions(+), 19 deletions(-) diff --git a/pokemon_v2/serializers.py b/pokemon_v2/serializers.py index 8e050b3a3..b59679826 100644 --- a/pokemon_v2/serializers.py +++ b/pokemon_v2/serializers.py @@ -3,7 +3,6 @@ from __future__ import annotations import itertools -import json from typing import TYPE_CHECKING, Any, ClassVar, Protocol, cast from django.db.models import Q @@ -2942,29 +2941,22 @@ class Meta: } @extend_schema_field(PokemonSpritesSerializer) - def get_pokemon_sprites(self, obj: Pokemon) -> dict[str, str | None] | str: + def get_pokemon_sprites(self, obj: Pokemon) -> dict[str, Any]: sprites_list = list(cast("PokemonWithRelations", obj).pokemonsprites.all()) if not sprites_list: return {} - sprites: Any = sprites_list[0].sprites - if obj.pokemon_species is None or obj.pokemon_species.gender_rate != 8: - return sprites - if isinstance(sprites, str): - parsed = json.loads(sprites) - self._fill_female_sprites(parsed) - return json.dumps(parsed) - self._fill_female_sprites(sprites) + sprites = sprites_list[0].sprites + if obj.pokemon_species and obj.pokemon_species.gender_rate == 8: + self._fill_female_sprites(sprites) return sprites - def _fill_female_sprites(self, node: object) -> None: - if not isinstance(node, dict): - return - sprites = cast("dict[str, Any]", node) + def _fill_female_sprites(self, sprites: dict[str, Any]) -> None: for female_key, default_key in self._FEMALE_FALLBACKS.items(): if female_key in sprites and sprites[female_key] is None and sprites.get(default_key) is not None: sprites[female_key] = sprites[default_key] for value in sprites.values(): - self._fill_female_sprites(value) + if isinstance(value, dict): + self._fill_female_sprites(value) @extend_schema_field(PokemonCriesSerializer) def get_pokemon_cries(self, obj: Pokemon) -> dict[str, str | None]: diff --git a/pokemon_v2/tests.py b/pokemon_v2/tests.py index 35d1dbdec..49cc7545b 100644 --- a/pokemon_v2/tests.py +++ b/pokemon_v2/tests.py @@ -1631,7 +1631,7 @@ def setup_pokemon_sprites_data( pokemon_sprites = PokemonSprites.objects.create( pokemon=pokemon, - sprites=json.dumps(sprites | {"other": {"showdown": showdown}}), + sprites=sprites | {"other": {"showdown": showdown}}, ) pokemon_sprites.save() @@ -4523,9 +4523,9 @@ def test_pokemon_api(self): "{}{}/pokemon-form/{}/".format(TEST_HOST, API_V2, pokemon_form.pk), ) - sprites_data = json.loads(pokemon_sprites.sprites) + sprites_data = pokemon_sprites.sprites cries_data = json.loads(pokemon_cries.cries) - response_sprites_data = json.loads(response.data["sprites"]) + response_sprites_data = response.data["sprites"] json.loads(response.data["cries"]) # sprite params @@ -4660,7 +4660,7 @@ def test_pokemon_api_female_only_sprites_fallback(self): self.assertEqual(response.status_code, status.HTTP_200_OK) - response_sprites = json.loads(response.data["sprites"]) + response_sprites = response.data["sprites"] self.assertIsNotNone(response_sprites["front_default"]) self.assertEqual(response_sprites["front_female"], response_sprites["front_default"])