Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/planning/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ class PlanningEmpireAdmin(SummaryStripMixin, ChangeHeaderMixin[PlanningEmpire],
list_filter = [('empire_faction', ChoicesDropdownFilter), 'needs_state_sync', *CREATED_MODIFIED_FILTERS]
list_filter_submit = True
date_hierarchy = 'created_at'
readonly_fields = ['uuid', 'created_at', 'modified_at']
readonly_fields = ['uuid', 'created_at', 'modified_at', 'config_modified_at']
autocomplete_fields = ['user', 'cx']
list_sections = [EmpirePlansSection]

Expand Down
54 changes: 54 additions & 0 deletions backend/planning/api/serializers/conflict.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from datetime import datetime

from django.db import models, transaction
from rest_framework import serializers, status
from rest_framework.exceptions import APIException


class SaveConflict(APIException):
status_code = status.HTTP_409_CONFLICT
default_detail = 'Changed since it was loaded.'
default_code = 'conflict'

def __init__(self, modified_at: datetime) -> None:
super().__init__(
{
'detail': self.default_detail,
'code': self.default_code,
'modified_at': serializers.DateTimeField().to_representation(modified_at),
}
)


class PlanningSaveConflictSerializer(serializers.Serializer):
detail = serializers.CharField()
code = serializers.CharField()
modified_at = serializers.DateTimeField()


class SaveConflictMixin(serializers.Serializer):
# rejects an update with 409 when base_modified_at (the version the client edited) is no longer the stored one

base_modified_at = serializers.DateTimeField(write_only=True, required=False, allow_null=True)

# model field holding the version exposed as modified_at
version_field = 'modified_at'

def save(self, **kwargs: object) -> models.Model:
# save(), not update(): the subclasses' own create()/update() don't call super()
base: datetime | None = self.validated_data.pop('base_modified_at', None)
instance = self.instance
if instance is None or base is None:
return super().save(**kwargs)

with transaction.atomic():
# the row lock makes two saves from the same base end in one success and one conflict
current: datetime = (
type(instance)
.objects.select_for_update()
.values_list(self.version_field, flat=True)
.get(pk=instance.pk)
)
if current != base:
raise SaveConflict(current)
return super().save(**kwargs)
5 changes: 3 additions & 2 deletions backend/planning/api/serializers/cx.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,18 @@
from planning.schemas.latest_schemas import LATEST_SCHEMA
from rest_framework import serializers

from .conflict import SaveConflictMixin
from .empire import PlanningEmpireNestedSerializer


class PlanningCXDetailSerializer(serializers.ModelSerializer):
class PlanningCXDetailSerializer(SaveConflictMixin, serializers.ModelSerializer):
empires = PlanningEmpireNestedSerializer(source='cxs', many=True, read_only=True)

cx_data = PydanticJSONField(pydantic_model=LATEST_SCHEMA['CX_DATA'])

class Meta:
model = PlanningCX
exclude = ['created_at', 'modified_at', 'schema_version', 'user']
exclude = ['created_at', 'schema_version', 'user']

@transaction.atomic
def create(self, validated_data):
Expand Down
28 changes: 22 additions & 6 deletions backend/planning/api/serializers/empire.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from django.db import transaction
from django.utils import timezone
from planning.models import PlanningCOGCChoices, PlanningEmpire, PlanningFactionChoices
from rest_framework import serializers

from .conflict import SaveConflictMixin
from .minimal import PlanningCXMinimal, PlanningPlanMinimalSerializer


Expand All @@ -15,23 +17,37 @@ class Meta:

class PlanningEmpireListSerializer(serializers.ModelSerializer):
cx = PlanningCXMinimal(read_only=True)
# configuration version: state syncs and cx-junctions don't change it
modified_at = serializers.DateTimeField(source='config_modified_at', read_only=True)

class Meta:
model = PlanningEmpire
fields = ['uuid', 'empire_name', 'empire_faction', 'empire_permits_used', 'empire_permits_total', 'cx']


class PlanningEmpireDetailSerializer(serializers.ModelSerializer):
fields = [
'uuid',
'empire_name',
'empire_faction',
'empire_permits_used',
'empire_permits_total',
'cx',
'modified_at',
]


class PlanningEmpireDetailSerializer(SaveConflictMixin, serializers.ModelSerializer):
plans = PlanningPlanMinimalSerializer(many=True, read_only=True)
cx = PlanningCXMinimal(read_only=True)
# the model's modified_at moves on every state sync, so the configuration has its own version
modified_at = serializers.DateTimeField(source='config_modified_at', read_only=True)

version_field = 'config_modified_at'

class Meta:
model = PlanningEmpire
exclude = ['created_at', 'modified_at', 'user', 'empire_state']
exclude = ['created_at', 'config_modified_at', 'user', 'empire_state']

@transaction.atomic
def update(self, instance, validated_data):

instance.config_modified_at = timezone.now()
instance = super().update(instance, validated_data)

return instance
Expand Down
6 changes: 5 additions & 1 deletion backend/planning/api/serializers/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from planning.schemas.latest_schemas import LATEST_SCHEMA
from rest_framework import serializers

from .conflict import SaveConflictMixin
from .cx import PlanningCXDetailSerializer
from .empire import PlanningEmpireListSerializer

Expand All @@ -31,7 +32,7 @@
}


class PlanningPlanDetailSerializer(serializers.ModelSerializer):
class PlanningPlanDetailSerializer(SaveConflictMixin, serializers.ModelSerializer):
empires = PlanningEmpireListSerializer(many=True, read_only=True)
cx = PlanningCXDetailSerializer(read_only=True)

Expand All @@ -56,6 +57,8 @@ class Meta:
'empires',
'cx',
'empire_uuid',
'modified_at',
'base_modified_at',
]

def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
Expand Down Expand Up @@ -146,6 +149,7 @@ class Meta(PlanningPlanDetailSerializer.Meta):
'plan_corphq',
'plan_data',
'empire_uuid',
'modified_at',
]


Expand Down
6 changes: 5 additions & 1 deletion backend/planning/api/viewsets/cx_viewset.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
PlanningCXJunctionsSyncErrorSerializer,
PlanningCXJunctionUpdateSerializer,
)
from planning.api.serializers.conflict import PlanningSaveConflictSerializer
from planning.models import PlanningCX, PlanningEmpire
from planning.planning_cache_manager import PLANNING
from rest_framework import mixins, status, viewsets
Expand Down Expand Up @@ -64,7 +65,10 @@ def fetch_data():
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)

@extend_schema(summary='Updates an existing cx preference')
@extend_schema(
summary='Updates an existing cx preference',
responses={200: PlanningCXDetailSerializer, 409: PlanningSaveConflictSerializer},
)
def update(self, request, *args, **kwargs):
return super().update(request, *args, **kwargs)

Expand Down
6 changes: 5 additions & 1 deletion backend/planning/api/viewsets/empire_viewset.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
PlanningEmpirePlanSyncErrorSerializer,
PlanningPlanListSerializer,
)
from planning.api.serializers.conflict import PlanningSaveConflictSerializer
from planning.api.serializers.empire import PlanningEmpireStateUpdateSerializer
from planning.models import PlanningEmpire, PlanningEmpirePlan, PlanningPlan
from planning.planning_cache_manager import PLANNING
Expand Down Expand Up @@ -78,7 +79,10 @@ def fetch_data():

return CacheManager.respond(request, PLANNING, 'empire-plans', pk, build=fetch_data, scope=user_id)

@extend_schema(summary='Updates an existing exmpire')
@extend_schema(
summary='Updates an existing exmpire',
responses={200: PlanningEmpireDetailSerializer, 409: PlanningSaveConflictSerializer},
)
def update(self, request, *args, **kwargs):
return super().update(request, *args, **kwargs)

Expand Down
6 changes: 5 additions & 1 deletion backend/planning/api/viewsets/plan_viewset.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from planning.api.serializers import (
PlanningPlanDetailSerializer,
)
from planning.api.serializers.conflict import PlanningSaveConflictSerializer
from planning.models import PlanningEmpire, PlanningPlan
from planning.planning_cache_manager import PLANNING
from rest_framework import mixins, status, viewsets
Expand Down Expand Up @@ -60,7 +61,10 @@ def fetch_data() -> dict[str, Any]:
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)

@extend_schema(summary='Updates an existing plan')
@extend_schema(
summary='Updates an existing plan',
responses={200: PlanningPlanDetailSerializer, 409: PlanningSaveConflictSerializer},
)
def update(self, request, *args, **kwargs):
return super().update(request, *args, **kwargs)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Generated by Django 6.0.8 on 2026-10-03 08:22

import django.utils.timezone
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('planning', '0007_planningempire_needs_state_sync'),
]

operations = [
migrations.AddField(
model_name='planningempire',
name='config_modified_at',
field=models.DateTimeField(default=django.utils.timezone.now),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Generated by Django 6.0.8 on 2026-10-03 08:22

from django.db import migrations
from django.db.models import F


def backfill(apps, schema_editor):
"""Existing empires: last configuration change unknown, start from the last change of any kind."""
PlanningEmpire = apps.get_model('planning', 'PlanningEmpire')
PlanningEmpire.objects.update(config_modified_at=F('modified_at'))


class Migration(migrations.Migration):

dependencies = [
('planning', '0008_planningempire_config_modified_at'),
]

operations = [
migrations.RunPython(backfill, reverse_code=migrations.RunPython.noop),
]
4 changes: 4 additions & 0 deletions backend/planning/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from core.models import ChangeTrackedModel, UUIDModel
from django.core.validators import MaxValueValidator, MinLengthValidator, MinValueValidator
from django.db import models
from django.utils import timezone


class PlanningFactionChoices(models.TextChoices):
Expand Down Expand Up @@ -83,6 +84,9 @@ class PlanningEmpire(UUIDModel, ChangeTrackedModel):
empire_state = models.JSONField(default=dict, blank=True)
needs_state_sync = models.BooleanField(default=False, db_index=True)

# version of the configuration (name, faction, permits) for save conflicts; modified_at also moves on state syncs
config_modified_at = models.DateTimeField(default=timezone.now)

def __str__(self) -> str:
return f'{self.empire_name} ({self.uuid})'

Expand Down
17 changes: 17 additions & 0 deletions backend/planning/signals.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from core.services.cache_manager import CacheManager
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from user.models import User, UserPreference

from planning.models import PlanningCX, PlanningEmpire, PlanningEmpirePlan, PlanningPlan
from planning.planning_cache_manager import PLANNING
Expand All @@ -20,3 +21,19 @@ def invalidate_user_planning_caches(
user_id: int = instance.user_id # type: ignore

CacheManager.invalidate_on_commit(PLANNING, user_id)


@receiver(post_delete, sender=PlanningPlan, dispatch_uid='planning_drop_plan_overrides')
def drop_plan_overrides(
sender: type[PlanningPlan], instance: PlanningPlan, origin: object = None, **kwargs: object
) -> None:
# account deletion removes the preferences anyway
if isinstance(origin, User):
return

# replaces the frontend's cleanPlanPreferences(), which also dropped plans created in another tab
preference = UserPreference.objects.select_for_update().filter(user_id=instance.user_id).first() # type: ignore
overrides = preference.preferences.get('plan_overrides') if preference else None
if preference and overrides and str(instance.uuid) in overrides:
del overrides[str(instance.uuid)]
preference.save(update_fields=['preferences'])
Loading
Loading