From 9cf7f177125a74daa6e323db77470093075e72a0 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 28 Apr 2026 11:18:50 +0200 Subject: [PATCH 1/2] fix(fio): proper `ge` instead of `min` for pydantic --- backend/gamedata/fio/schemas/fio_webhook.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/gamedata/fio/schemas/fio_webhook.py b/backend/gamedata/fio/schemas/fio_webhook.py index 0950eb3..77297b4 100644 --- a/backend/gamedata/fio/schemas/fio_webhook.py +++ b/backend/gamedata/fio/schemas/fio_webhook.py @@ -12,8 +12,8 @@ class FIOExchangeOrderSchema(BaseModel): company_id: str = Field(..., min_length=32, max_length=32, alias='CompanyId') company_name: str = Field(..., max_length=200, alias='CompanyName') company_code: str = Field(..., min_length=1, max_length=10, alias='CompanyCode') - item_count: int | None = Field(default=None, min=1, alias='ItemCount') - item_cost: float = Field(min=0.0, alias='ItemCost') + item_count: int | None = Field(default=None, ge=1, alias='ItemCount') + item_cost: float = Field(ge=0.0, alias='ItemCost') class FIOExchangeBuyOrderSchema(FIOExchangeOrderSchema): cx_buy_order_id: str = Field(..., min_length=32, max_length=32, alias='CXBuyOrderId') From 541ee93361c9e40b07baadc38357636cc3667cd4 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 28 Apr 2026 11:32:35 +0200 Subject: [PATCH 2/2] improve(empire): decouple state-sync to analytics from user --- backend/analytics/tasks.py | 32 +++++++++++++++++++ backend/planning/admin.py | 3 +- .../planning/api/viewsets/empire_viewset.py | 2 +- .../0007_planningempire_needs_state_sync.py | 18 +++++++++++ backend/planning/models.py | 2 ++ .../planning/services/empire_state_service.py | 26 +++++++++------ 6 files changed, 72 insertions(+), 11 deletions(-) create mode 100644 backend/planning/migrations/0007_planningempire_needs_state_sync.py diff --git a/backend/analytics/tasks.py b/backend/analytics/tasks.py index 05c9284..b453f74 100644 --- a/backend/analytics/tasks.py +++ b/backend/analytics/tasks.py @@ -18,6 +18,7 @@ GameRecipeOutput, ) from planning.models import PlanningCX, PlanningEmpire, PlanningEmpirePlan, PlanningPlan +from planning.services.empire_state_service import EmpireStateService from user.models import User from analytics.models import AppStatistic @@ -113,3 +114,34 @@ def analytics_update_plan_insight_aggregates(): except Exception as exc: log.error('exception', exc_info=exc) + + +@shared_task(name='analytics_bulk_materialize_empire_snapshots') +def analytics_bulk_materialize_empire_snapshots(): + structlog.contextvars.bind_contextvars( + task_category='analytics_bulk_materialize_empire_snapshots', + ) + + log = logger.bind(name='analytics_bulk_materialize_empire_snapshots') + + # find all dirty PlanningEmpire and process in chunks + dirty_empires = PlanningEmpire.objects.filter(needs_state_sync=True).iterator(chunk_size=100) + + processed_count = 0 + error_count = 0 + + for empire in dirty_empires: + try: + EmpireStateService.sync_snapshot(empire) + + # clear and update flag only + empire.needs_state_sync = False + empire.save(update_fields=['needs_state_sync']) + + processed_count += 1 + + except Exception as exc: + error_count += 1 + log.error('exception', exc_info=exc) + + log.info('completed', processed=processed_count, errors=error_count) diff --git a/backend/planning/admin.py b/backend/planning/admin.py index f7bdeec..958c2c9 100644 --- a/backend/planning/admin.py +++ b/backend/planning/admin.py @@ -39,9 +39,10 @@ class PlanningPlanAdmin(ModelAdmin): @admin.register(PlanningEmpire) class PlanningEmpireAdmin(ModelAdmin): - list_display = ['uuid', 'user', 'empire_name', 'created_at', 'modified_at'] + list_display = ['uuid', 'user', 'empire_name', 'created_at', 'modified_at', 'needs_state_sync'] search_fields = ['uuid', 'empire_name', 'user__username'] ordering = ['-modified_at'] + list_filter = ['needs_state_sync'] inlines = [PlanningEmpirePlanInline] diff --git a/backend/planning/api/viewsets/empire_viewset.py b/backend/planning/api/viewsets/empire_viewset.py index db37c71..4c88a12 100644 --- a/backend/planning/api/viewsets/empire_viewset.py +++ b/backend/planning/api/viewsets/empire_viewset.py @@ -177,7 +177,7 @@ def sync_state(self, request, pk=None): serializer.is_valid(raise_exception=True) # handle empire + relational snapshot refresh - EmpireStateService.sync_empire_state(instance, serializer.validated_data) + EmpireStateService.update_state(instance, serializer.validated_data) # clear caches PlanningCacheManager.delete_pattern(f'*PLANNING:{request.user.id}:*') diff --git a/backend/planning/migrations/0007_planningempire_needs_state_sync.py b/backend/planning/migrations/0007_planningempire_needs_state_sync.py new file mode 100644 index 0000000..9652318 --- /dev/null +++ b/backend/planning/migrations/0007_planningempire_needs_state_sync.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.4 on 2026-04-28 09:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('planning', '0006_planningempire_empire_state'), + ] + + operations = [ + migrations.AddField( + model_name='planningempire', + name='needs_state_sync', + field=models.BooleanField(db_index=True, default=False), + ), + ] diff --git a/backend/planning/models.py b/backend/planning/models.py index 1af890b..7f90deb 100644 --- a/backend/planning/models.py +++ b/backend/planning/models.py @@ -79,7 +79,9 @@ class PlanningEmpire(UUIDModel, ChangeTrackedModel): empire_permits_used = models.PositiveIntegerField(validators=[MinValueValidator(1)]) empire_permits_total = models.PositiveIntegerField(validators=[MinValueValidator(2)]) + # state empire_state = models.JSONField(default=dict, blank=True) + needs_state_sync = models.BooleanField(default=False, db_index=True) def __str__(self) -> str: return f'{self.empire_name} ({self.uuid})' diff --git a/backend/planning/services/empire_state_service.py b/backend/planning/services/empire_state_service.py index cfdd1a8..9140f0a 100644 --- a/backend/planning/services/empire_state_service.py +++ b/backend/planning/services/empire_state_service.py @@ -7,23 +7,31 @@ class EmpireStateService: @staticmethod - def sync_empire_state(empire: PlanningEmpire, state_data: dict) -> None: + def update_state(empire: PlanningEmpire, state_data: dict) -> None: + """Updates the empires state JSON dict only and sets the needs_state_sync flag to True""" - # update the json field empire.empire_state = state_data - empire.save(update_fields=['empire_state', 'modified_at']) + empire.needs_state_sync = True + empire.save(update_fields=['empire_state', 'modified_at', 'needs_state_sync']) - # upsert / delete stale from EmpireMaterialSnapshot + @staticmethod + def sync_snapshot(empire: PlanningEmpire) -> None: + """Performs snapshot sync into AnalyticsEmpireMaterialSnapshot object""" + + state_data = empire.empire_state or {} empire_total = state_data.get('empire_total', {}) - active_tickers = [] + # upsert / delete stale from EmpireMaterialSnapshot + active_tickers = [] snapshot_objs = [] - for material_ticker, stats in empire_total.items(): - p_raw, c_raw = stats.get('p', 0), stats.get('c', 0) + # pre-defined quantizer + quantizer = Decimal('0.000001') + + for material_ticker, stats in empire_total.items(): # decimal conversion - p = Decimal(str(p_raw)).quantize(Decimal('0.000001'), rounding=ROUND_HALF_UP) - c = Decimal(str(c_raw)).quantize(Decimal('0.000001'), rounding=ROUND_HALF_UP) + p = Decimal(str(stats.get('p', 0))).quantize(quantizer, rounding=ROUND_HALF_UP) + c = Decimal(str(stats.get('c', 0))).quantize(quantizer, rounding=ROUND_HALF_UP) d = p - c