diff --git a/backend/analytics/api/serializer.py b/backend/analytics/api/serializer.py index 2ae8289..bd27a30 100644 --- a/backend/analytics/api/serializer.py +++ b/backend/analytics/api/serializer.py @@ -11,3 +11,14 @@ class Meta: def get_status(self, obj) -> str: return 'success' + + +class AnalyticsMarketInsightSerializer(serializers.Serializer): + data = serializers.ListField( + child=serializers.ListField( + child=serializers.CharField(), + min_length=1, + max_length=3, + help_text='Indices: 0: ticker, 1: production, 2: consumption, 3: delta', + ) + ) diff --git a/backend/analytics/api/viewsets.py b/backend/analytics/api/viewsets.py index 6a64d6e..aa8ffb2 100644 --- a/backend/analytics/api/viewsets.py +++ b/backend/analytics/api/viewsets.py @@ -1,12 +1,12 @@ from datetime import timedelta -from analytics.api.serializer import AnalyticsPlanAggregateSerializer +from analytics.api.serializer import AnalyticsMarketInsightSerializer, AnalyticsPlanAggregateSerializer from analytics.models import AnalyticsEmpireMaterialSnapshot, AnalyticsPlanAggregate from analytics.services.analytics_cache_manager import AnalyticsCacheManager from django.db.models import Sum from django.http import Http404 from django.utils import timezone -from drf_spectacular.utils import extend_schema +from drf_spectacular.utils import OpenApiExample, extend_schema from gamedata.models.game_planet import GamePlanet from rest_framework import viewsets from rest_framework.decorators import action @@ -45,7 +45,19 @@ def fetch_data(planet_natural_id: str): class AnalyticsMarketInsightViewSet(viewsets.ViewSet): - @extend_schema(auth=[], summary='Fetch planning insights for materials') + @extend_schema( + auth=[], + summary='Fetch planning insights for materials', + responses={200: AnalyticsMarketInsightSerializer}, + examples=[ + OpenApiExample( + 'Material Insights Example', + summary='Example for positional array response', + value=[['AAR', 30.3584, 12.8593, 17.4991], ['ABH', 120.7919, 0.0, 120.7919]], + response_only=True, + ) + ], + ) @action(detail=False, methods=['get'], url_path='get-global-tracker') def get_global_materials(self, request): diff --git a/backend/gamedata/api/serializer.py b/backend/gamedata/api/serializer.py index cf918db..93ab9f4 100644 --- a/backend/gamedata/api/serializer.py +++ b/backend/gamedata/api/serializer.py @@ -3,12 +3,14 @@ from gamedata.models import ( GameBuilding, GameBuildingCost, + GameBuildingExpertiseChoices, GameExchangeAnalytics, GameExchangeCXPC, GameMaterial, GamePlanet, GamePlanetCOGCProgram, GamePlanetCOGCProgramChoices, + GamePlanetCurrencyCodeChoices, GamePlanetInfrastructureReport, GamePlanetResource, GameRecipe, @@ -17,6 +19,9 @@ ) from rest_framework import serializers +WORKFORCE_ORDER = ['PIONEER', 'SETTLER', 'TECHNICIAN', 'ENGINEER', 'SCIENTIST'] +WF_INDEX_MAP = {level: i for i, level in enumerate(WORKFORCE_ORDER)} + class GameMaterialSerializer(serializers.ModelSerializer): class Meta: @@ -46,6 +51,11 @@ class Meta: model = GameRecipe exclude = ['standard_recipe_name'] + @extend_schema_field( + serializers.CharField( + help_text="Composite ID formatted as 'BUILDING_TICKER#RECIPE_NAME'. Example: 'BMP#100xPE 25xPG=>20xOVE'" + ) + ) def get_recipe_id(self, obj: GameRecipe): return f'{obj.building_ticker}#{obj.recipe_name}' @@ -100,6 +110,8 @@ class GamePlanetSerializer(serializers.ModelSerializer): active_cogc_program_type = serializers.CharField(read_only=True) + production_fees = serializers.SerializerMethodField() + class Meta: model = GamePlanet fields = [ @@ -123,8 +135,53 @@ class Meta: 'resources', 'cogc_programs', 'active_cogc_program_type', + 'production_fees', ] + @extend_schema_field( + inline_serializer( + name='PlanetProductionFeeSchema', + fields={ + 'currency': serializers.ChoiceField(choices=GamePlanetCurrencyCodeChoices.choices), + 'fees': serializers.DictField( + child=serializers.ListField( + child=serializers.FloatField(), + min_length=5, + max_length=5, + help_text='[PIONEER, SETTLER, TECHNICIAN, ENGINEER, SCIENTIST]', + ), + help_text=( + 'Dictionary keys are Building Categories (GameBuildingExpertiseChoices). ' + 'Valid keys include: ' + + ', '.join([choice[0] for choice in GameBuildingExpertiseChoices.choices]) + ), + ), + }, + ) + ) + def get_production_fees(self, obj: GamePlanet) -> dict[str, object] | None: + + # prefetched + fees = obj.production_fees.all() + if not fees: + return None + + fee_map: dict[str, list[float]] = {} + for fee in fees: + cat = fee.category + if cat not in fee_map: + # init with 0.0 for all workforces + fee_map[cat] = [0.0] * len(WORKFORCE_ORDER) + + try: + # overwrite with the values from data + idx = WF_INDEX_MAP[fee.workforce_level] + fee_map[cat][idx] = fee.fee_amount + except KeyError: + continue + + return {'currency': obj.currency_code, 'fees': fee_map} + class PlanetIdsSerializer(serializers.ListSerializer): child = serializers.CharField(min_length=7, max_length=7) diff --git a/backend/gamedata/models/__init__.py b/backend/gamedata/models/__init__.py index 3f53247..f291773 100644 --- a/backend/gamedata/models/__init__.py +++ b/backend/gamedata/models/__init__.py @@ -1,4 +1,4 @@ -from .game_building import GameBuilding, GameBuildingCost +from .game_building import GameBuilding, GameBuildingCost, GameBuildingExpertiseChoices from .game_exchange import GameExchange, GameExchangeAnalytics, GameExchangeCXPC from .game_material import GameMaterial from .game_planet import ( @@ -11,6 +11,7 @@ GamePlanetProductionFee, GamePlanetResource, GamePlanetResourceTypeChoices, + GamePlanetCurrencyCodeChoices, queryset_gameplanet, ) from .game_playerdata import GameFIOPlayerData diff --git a/backend/gamedata/models/game_planet.py b/backend/gamedata/models/game_planet.py index 0d0949f..c9d70b6 100644 --- a/backend/gamedata/models/game_planet.py +++ b/backend/gamedata/models/game_planet.py @@ -27,7 +27,7 @@ def queryset_gameplanet() -> QuerySet: .values('program_type')[:1] ) - return GamePlanet.objects.prefetch_related('cogc_programs', 'resources').annotate( + return GamePlanet.objects.prefetch_related('cogc_programs', 'resources', 'production_fees').annotate( active_cogc_program_type=Subquery(active_program_sub) ) diff --git a/backend/tests/gamedata/conftest.py b/backend/tests/gamedata/conftest.py index df271d3..6dfdc6f 100644 --- a/backend/tests/gamedata/conftest.py +++ b/backend/tests/gamedata/conftest.py @@ -58,6 +58,11 @@ def popr_factory(**kwargs): return lambda **kwargs: baker.make('gamedata.GamePlanetInfrastructureReport', **kwargs) +@pytest.fixture() +def production_fee_factory(**kwargs): + return lambda **kwargs: baker.make('gamedata.GamePlanetProductionFee', **kwargs) + + @pytest.fixture() def exchange_analytics_factory(): diff --git a/backend/tests/gamedata/test_gamedata_api.py b/backend/tests/gamedata/test_gamedata_api.py index 2f78657..a5104ea 100644 --- a/backend/tests/gamedata/test_gamedata_api.py +++ b/backend/tests/gamedata/test_gamedata_api.py @@ -58,6 +58,42 @@ def test_retrieve(self, api_client, planet_factory): assert response.status_code == 200 assert response.data['planet_natural_id'] == planet_natural_id + def test_retrieve_no_production_fees(self, api_client, planet_factory): + planet_natural_id = 'OT-580b' + planet_factory(planet_natural_id=planet_natural_id) + + url = reverse('data:planet-detail', kwargs={'planet_natural_id': planet_natural_id}) + response = api_client.get(url) + + assert response.status_code == 200 + assert response.data['production_fees'] is None + + def test_retrieve_production_fees(self, api_client, planet_factory, production_fee_factory): + planet_natural_id = 'OT-580b' + planet = planet_factory(planet_natural_id=planet_natural_id, currency_code='NCC') + + production_fee_factory( + planet=planet, category='AGRICULTURE', workforce_level='PIONEER', fee_amount=1.5, fee_currency='NCC' + ) + production_fee_factory( + planet=planet, category='AGRICULTURE', workforce_level='SCIENTIST', fee_amount=3.0, fee_currency='NCC' + ) + production_fee_factory( + planet=planet, category='CHEMISTRY', workforce_level='SETTLER', fee_amount=2.25, fee_currency='NCC' + ) + + url = reverse('data:planet-detail', kwargs={'planet_natural_id': planet_natural_id}) + response = api_client.get(url) + + assert response.status_code == 200 + assert response.data['production_fees'] == { + 'currency': 'NCC', + 'fees': { + 'AGRICULTURE': [1.5, 0.0, 0.0, 0.0, 3.0], + 'CHEMISTRY': [0.0, 2.25, 0.0, 0.0, 0.0], + }, + } + def test_multiple(self, api_client, planet_factory): planet_natural_ids = ['OT-580b', 'ZV-759b', 'EW-688c']