From 61345dfa4502679b2caf726f8e9a223bbe426d67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Kr=C3=B6ger?= Date: Fri, 25 Sep 2026 16:51:53 +0200 Subject: [PATCH 1/3] feat(serverdb): Allow to redirect attributes via alias To ease renaming attributes this feature allows to set up an alias for an attribute so that requests still using the old name continue working and allow a graceful migration. Do not resolve redirect in the Servershell as humans should pick up the new name immediately otherwise it could increase the risk to become a permanent thing because we humans are lazy --- packages/serveradmin/serveradmin/api/views.py | 6 ++- .../serveradmin/serveradmin/serverdb/admin.py | 14 ++++-- .../migrations/0026_attributeredirect.py | 21 +++++++++ .../serveradmin/serverdb/models.py | 43 +++++++++++++++++++ 4 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 packages/serveradmin/serveradmin/serverdb/migrations/0026_attributeredirect.py diff --git a/packages/serveradmin/serveradmin/api/views.py b/packages/serveradmin/serveradmin/api/views.py index 8d084427..ce1e11f9 100644 --- a/packages/serveradmin/serveradmin/api/views.py +++ b/packages/serveradmin/serveradmin/api/views.py @@ -18,7 +18,7 @@ from serveradmin.api.decorators import api_view from serveradmin.dataset import Query from serveradmin.querylog.utils import log_query -from serveradmin.serverdb.models import Attribute +from serveradmin.serverdb.models import Attribute, AttributeRedirect from serveradmin.serverdb.query_committer import commit_query from serveradmin.serverdb.query_executer import execute_query from serveradmin.serverdb.query_materializer import ( @@ -67,6 +67,10 @@ def dataset_query(request, app, data): order_by = data.get('order_by') start = monotonic() + + # Resolve alias attributes to real attributes + filters, restrict, order_by = AttributeRedirect.resolve_aliases(filters, restrict, order_by) + result = execute_query(filters, restrict, order_by) duration_seconds = monotonic() - start diff --git a/packages/serveradmin/serveradmin/serverdb/admin.py b/packages/serveradmin/serveradmin/serverdb/admin.py index 0edb6a7c..52e0a087 100644 --- a/packages/serveradmin/serveradmin/serverdb/admin.py +++ b/packages/serveradmin/serveradmin/serverdb/admin.py @@ -1,6 +1,6 @@ """Serveradmin - Django Admin Setup -Copyright (c) 2019 InnoGames GmbH +Copyright (c) 2026 InnoGames GmbH """ from django.contrib import admin @@ -14,9 +14,8 @@ Servertype, Attribute, ServertypeAttribute, - Server, ServerRelationAttribute, - ServerStringAttribute, + ServerStringAttribute, AttributeRedirect, ) @@ -106,5 +105,14 @@ def get_hovertext(self, obj): ) +class AttributeRedirectAdmin(admin.ModelAdmin): + model = AttributeRedirect + + list_display = ['alias', 'target', ] + search_fields = ['alias', 'target', ] + list_filter = ['alias', 'target', ] + + admin.site.register(Servertype, ServertypeAdmin) admin.site.register(Attribute, AttributeAdmin) +admin.site.register(AttributeRedirect, AttributeRedirectAdmin) diff --git a/packages/serveradmin/serveradmin/serverdb/migrations/0026_attributeredirect.py b/packages/serveradmin/serveradmin/serverdb/migrations/0026_attributeredirect.py new file mode 100644 index 00000000..7a03fb3a --- /dev/null +++ b/packages/serveradmin/serveradmin/serverdb/migrations/0026_attributeredirect.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.17 on 2026-09-25 14:14 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('serverdb', '0025_rename_serverbooleanattribute_attribute_server_bool_attribu_25fb6c_idx_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='AttributeRedirect', + fields=[ + ('alias', models.CharField(help_text="The 'virtual' attribute name (e.g. old attribute)", max_length=32, primary_key=True, serialize=False)), + ('target', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='serverdb.attribute')), + ], + ), + ] diff --git a/packages/serveradmin/serveradmin/serverdb/models.py b/packages/serveradmin/serveradmin/serverdb/models.py index 882e2731..08d3c275 100644 --- a/packages/serveradmin/serveradmin/serverdb/models.py +++ b/packages/serveradmin/serveradmin/serverdb/models.py @@ -402,6 +402,49 @@ def clean(self): super(Attribute, self).clean() +class AttributeRedirect(models.Model): + """Redirect alias to an existing attribute + + Purpose of this is to allow graceful renaming of attributes. + + One can delete and attribute create a new one and set up a redirect + from the old name to the new name. + """ + + alias = models.CharField( + max_length=32, + help_text="The 'virtual' attribute name (e.g. old attribute)", + primary_key=True, + ) + target = models.ForeignKey(Attribute, on_delete=models.CASCADE) + + def clean(self): + super().clean() + + if Attribute.objects.filter(attribute_id=self.alias).exists(): + raise ValidationError({ + "alias": "Creating a alias that matches an existing attribute not allowed!" + }) + + @classmethod + def resolve_aliases(cls, *objs): + """Resolve alias names in the given objects to real attribute_ids.""" + mapping = dict(cls.objects.values_list('alias', 'target_id')) + return tuple(_apply_aliases(mapping, obj) for obj in objs) + + +def _apply_aliases(mapping, obj): + if obj is None: + return None + if isinstance(obj, str): + return mapping.get(obj, obj) + if isinstance(obj, list): + return [mapping.get(item, item) for item in obj] + if isinstance(obj, dict): + return {mapping.get(key, key): value for key, value in obj.items()} + raise TypeError(f'Unsupported type {type(obj).__name__}') + + class ServerTableSpecial(object): def __init__(self, field, unique=False): self.field = field From 953015dbc81c0481aa8ba486b4b188ce0038540d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Kr=C3=B6ger?= Date: Mon, 28 Sep 2026 13:40:28 +0200 Subject: [PATCH 2/3] Fix FieldError when searching --- packages/serveradmin/serveradmin/serverdb/admin.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/serveradmin/serveradmin/serverdb/admin.py b/packages/serveradmin/serveradmin/serverdb/admin.py index 52e0a087..9f94bdb7 100644 --- a/packages/serveradmin/serveradmin/serverdb/admin.py +++ b/packages/serveradmin/serveradmin/serverdb/admin.py @@ -108,9 +108,9 @@ def get_hovertext(self, obj): class AttributeRedirectAdmin(admin.ModelAdmin): model = AttributeRedirect - list_display = ['alias', 'target', ] - search_fields = ['alias', 'target', ] - list_filter = ['alias', 'target', ] + list_display = ['alias', 'target__attribute_id', ] + search_fields = ['alias', 'target__attribute_id', ] + list_filter = ['alias', 'target__attribute_id', ] admin.site.register(Servertype, ServertypeAdmin) From 02a0f9198eda8e358facebc2e5ce8540a2630644 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Kr=C3=B6ger?= Date: Mon, 28 Sep 2026 14:07:42 +0200 Subject: [PATCH 3/3] Fix TypeError: unhashable type: 'dict' Forgot about the joined queries. Added a test with Claude --- .../serveradmin/serverdb/models.py | 25 +++++++++- .../serverdb/tests/test_attribute_redirect.py | 49 +++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 packages/serveradmin/serveradmin/serverdb/tests/test_attribute_redirect.py diff --git a/packages/serveradmin/serveradmin/serverdb/models.py b/packages/serveradmin/serveradmin/serverdb/models.py index 08d3c275..4294dba5 100644 --- a/packages/serveradmin/serveradmin/serverdb/models.py +++ b/packages/serveradmin/serveradmin/serverdb/models.py @@ -434,14 +434,35 @@ def resolve_aliases(cls, *objs): def _apply_aliases(mapping, obj): + """Recursively replace alias attribute names in a query argument + + Supported shapes are the ones passed to a query: + + * ``filters``: ``{attribute_id: filter}``, only keys are attribute names, + the filter objects are left untouched. + * ``restrict``: ``[attribute_id, {attribute_id: [restrict, ...]}, ...]``, + where a dictionary item is a join into a related object whose value is + again a restrict clause. + * ``order_by``: ``[attribute_id, ...]`` + """ if obj is None: return None if isinstance(obj, str): return mapping.get(obj, obj) if isinstance(obj, list): - return [mapping.get(item, item) for item in obj] + return [ + _apply_aliases(mapping, item) + if isinstance(item, (str, list, dict)) else item + for item in obj + ] if isinstance(obj, dict): - return {mapping.get(key, key): value for key, value in obj.items()} + # Only keys are attribute names. A value is either a nested restrict + # clause of a join (list) or a filter value, which must stay as is. + return { + mapping.get(key, key): _apply_aliases(mapping, value) + if isinstance(value, (list, dict)) else value + for key, value in obj.items() + } raise TypeError(f'Unsupported type {type(obj).__name__}') diff --git a/packages/serveradmin/serveradmin/serverdb/tests/test_attribute_redirect.py b/packages/serveradmin/serveradmin/serverdb/tests/test_attribute_redirect.py new file mode 100644 index 00000000..0cd102d2 --- /dev/null +++ b/packages/serveradmin/serveradmin/serverdb/tests/test_attribute_redirect.py @@ -0,0 +1,49 @@ +from django.test import SimpleTestCase + +from adminapi.filters import Regexp +from serveradmin.serverdb.models import _apply_aliases + + +class ApplyAliasesTestCase(SimpleTestCase): + mapping = {'old_name': 'new_name', 'old_code': 'short_code'} + + def test_none(self): + self.assertIsNone(_apply_aliases(self.mapping, None)) + + def test_string(self): + self.assertEqual(_apply_aliases(self.mapping, 'old_name'), 'new_name') + self.assertEqual(_apply_aliases(self.mapping, 'hostname'), 'hostname') + + def test_flat_restrict(self): + self.assertEqual( + _apply_aliases(self.mapping, ['hostname', 'old_name']), + ['hostname', 'new_name'], + ) + + def test_restrict_with_joins(self): + restrict = [{'old_name': ['old_code', 'object_id']}, 'hostname'] + self.assertEqual( + _apply_aliases(self.mapping, restrict), + [{'new_name': ['short_code', 'object_id']}, 'hostname'], + ) + + def test_restrict_with_nested_joins(self): + restrict = [{'project': [{'old_name': ['old_code']}, 'hostname']}] + self.assertEqual( + _apply_aliases(self.mapping, restrict), + [{'project': [{'new_name': ['short_code']}, 'hostname']}], + ) + + def test_filters_keep_filter_objects(self): + regexp = Regexp('^foo') + # A plain string value is a filter value, not an attribute name, + # so it must not be rewritten even if it matches an alias. + filters = {'old_name': regexp, 'hostname': 'old_code'} + result = _apply_aliases(self.mapping, filters) + self.assertEqual(set(result), {'new_name', 'hostname'}) + self.assertIs(result['new_name'], regexp) + self.assertEqual(result['hostname'], 'old_code') + + def test_unsupported_type(self): + with self.assertRaises(TypeError): + _apply_aliases(self.mapping, 42)