Skip to content
Draft
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
6 changes: 5 additions & 1 deletion packages/serveradmin/serveradmin/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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

Expand Down
14 changes: 11 additions & 3 deletions packages/serveradmin/serveradmin/serverdb/admin.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Serveradmin - Django Admin Setup

Copyright (c) 2019 InnoGames GmbH
Copyright (c) 2026 InnoGames GmbH
"""

from django.contrib import admin
Expand All @@ -14,9 +14,8 @@
Servertype,
Attribute,
ServertypeAttribute,
Server,
ServerRelationAttribute,
ServerStringAttribute,
ServerStringAttribute, AttributeRedirect,
)


Expand Down Expand Up @@ -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)
Original file line number Diff line number Diff line change
@@ -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')),
],
),
]
64 changes: 64 additions & 0 deletions packages/serveradmin/serveradmin/serverdb/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Loading