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..9f94bdb7 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__attribute_id', ] + search_fields = ['alias', 'target__attribute_id', ] + list_filter = ['alias', 'target__attribute_id', ] + + 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..4294dba5 100644 --- a/packages/serveradmin/serveradmin/serverdb/models.py +++ b/packages/serveradmin/serveradmin/serverdb/models.py @@ -402,6 +402,70 @@ 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): + """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 [ + _apply_aliases(mapping, item) + if isinstance(item, (str, list, dict)) else item + for item in obj + ] + if isinstance(obj, dict): + # 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__}') + + class ServerTableSpecial(object): def __init__(self, field, unique=False): self.field = field 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)