mirror of
https://github.com/netbox-community/netbox.git
synced 2026-04-02 15:37:18 +02:00
Compare commits
1 Commits
21770-embe
...
21025-pre-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb99199340 |
@@ -12,7 +12,7 @@ from dcim import filtersets
|
|||||||
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
|
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
|
||||||
from dcim.models import *
|
from dcim.models import *
|
||||||
from dcim.svg import CableTraceSVG
|
from dcim.svg import CableTraceSVG
|
||||||
from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin
|
from extras.api.mixins import RenderConfigMixin
|
||||||
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||||
from netbox.api.metadata import ContentTypeMetadata
|
from netbox.api.metadata import ContentTypeMetadata
|
||||||
from netbox.api.pagination import StripCountAnnotationsPaginator
|
from netbox.api.pagination import StripCountAnnotationsPaginator
|
||||||
@@ -398,12 +398,7 @@ class PlatformViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
|||||||
# Devices/modules
|
# Devices/modules
|
||||||
#
|
#
|
||||||
|
|
||||||
class DeviceViewSet(
|
class DeviceViewSet(SequentialBulkCreatesMixin, RenderConfigMixin, NetBoxModelViewSet):
|
||||||
SequentialBulkCreatesMixin,
|
|
||||||
ConfigContextQuerySetMixin,
|
|
||||||
RenderConfigMixin,
|
|
||||||
NetBoxModelViewSet
|
|
||||||
):
|
|
||||||
queryset = Device.objects.prefetch_related(
|
queryset = Device.objects.prefetch_related(
|
||||||
'parent_bay', # Referenced by DeviceSerializer.get_parent_device()
|
'parent_bay', # Referenced by DeviceSerializer.get_parent_device()
|
||||||
)
|
)
|
||||||
|
|||||||
21
netbox/dcim/migrations/0227_device_config_context_data.py
Normal file
21
netbox/dcim/migrations/0227_device_config_context_data.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dcim', '0226_modulebay_rebuild_tree'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='device',
|
||||||
|
name='config_context_data',
|
||||||
|
field=models.JSONField(blank=True, editable=False, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='module',
|
||||||
|
name='config_context_data',
|
||||||
|
field=models.JSONField(blank=True, editable=False, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -2683,7 +2683,7 @@ class DeviceInventoryView(DeviceComponentsView):
|
|||||||
|
|
||||||
@register_model_view(Device, 'configcontext', path='config-context')
|
@register_model_view(Device, 'configcontext', path='config-context')
|
||||||
class DeviceConfigContextView(ObjectConfigContextView):
|
class DeviceConfigContextView(ObjectConfigContextView):
|
||||||
queryset = Device.objects.annotate_config_context_data()
|
queryset = Device.objects.all()
|
||||||
base_template = 'dcim/device/base.html'
|
base_template = 'dcim/device/base.html'
|
||||||
tab = ViewTab(
|
tab = ViewTab(
|
||||||
label=_('Config Context'),
|
label=_('Config Context'),
|
||||||
|
|||||||
@@ -10,34 +10,11 @@ from netbox.api.renderers import TextRenderer
|
|||||||
from .serializers import ConfigTemplateSerializer
|
from .serializers import ConfigTemplateSerializer
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'ConfigContextQuerySetMixin',
|
|
||||||
'ConfigTemplateRenderMixin',
|
'ConfigTemplateRenderMixin',
|
||||||
'RenderConfigMixin',
|
'RenderConfigMixin',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ConfigContextQuerySetMixin:
|
|
||||||
"""
|
|
||||||
Used by views that work with config context models (device and virtual machine).
|
|
||||||
Provides a get_queryset() method which deals with adding the config context
|
|
||||||
data annotation or not.
|
|
||||||
"""
|
|
||||||
def get_queryset(self):
|
|
||||||
"""
|
|
||||||
Build the proper queryset based on the request context
|
|
||||||
|
|
||||||
If the `brief` query param equates to True or the `exclude` query param
|
|
||||||
includes `config_context` as a value, return the base queryset.
|
|
||||||
|
|
||||||
Else, return the queryset annotated with config context data
|
|
||||||
"""
|
|
||||||
queryset = super().get_queryset()
|
|
||||||
request = self.get_serializer_context()['request']
|
|
||||||
if self.brief or 'config_context' in request.query_params.get('exclude', []):
|
|
||||||
return queryset
|
|
||||||
return queryset.annotate_config_context_data()
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigTemplateRenderMixin:
|
class ConfigTemplateRenderMixin:
|
||||||
"""
|
"""
|
||||||
Provides a method to return a rendered ConfigTemplate as REST API data.
|
Provides a method to return a rendered ConfigTemplate as REST API data.
|
||||||
|
|||||||
@@ -22,19 +22,7 @@ if TYPE_CHECKING:
|
|||||||
@strawberry.type
|
@strawberry.type
|
||||||
class ConfigContextMixin:
|
class ConfigContextMixin:
|
||||||
|
|
||||||
@classmethod
|
@strawberry_django.field(only=['config_context_data', 'local_context_data'])
|
||||||
def get_queryset(cls, queryset, info: Info, **kwargs):
|
|
||||||
queryset = super().get_queryset(queryset, info, **kwargs)
|
|
||||||
|
|
||||||
# If `config_context` is requested, call annotate_config_context_data() on the queryset
|
|
||||||
selected = {f.name for f in info.selected_fields[0].selections}
|
|
||||||
if 'config_context' in selected and hasattr(queryset, 'annotate_config_context_data'):
|
|
||||||
return queryset.annotate_config_context_data()
|
|
||||||
|
|
||||||
return queryset
|
|
||||||
|
|
||||||
# Ensure `local_context_data` is fetched when `config_context` is requested
|
|
||||||
@strawberry_django.field(only=['local_context_data'])
|
|
||||||
def config_context(self) -> strawberry.scalars.JSON:
|
def config_context(self) -> strawberry.scalars.JSON:
|
||||||
return self.get_config_context()
|
return self.get_config_context()
|
||||||
|
|
||||||
|
|||||||
40
netbox/extras/management/commands/rebuild_config_context.py
Normal file
40
netbox/extras/management/commands/rebuild_config_context.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import connection
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Rebuild pre-rendered config context data for all devices and/or virtual machines'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--devices-only',
|
||||||
|
action='store_true',
|
||||||
|
help='Only rebuild config context data for devices',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--vms-only',
|
||||||
|
action='store_true',
|
||||||
|
help='Only rebuild config context data for virtual machines',
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
devices_only = options['devices_only']
|
||||||
|
vms_only = options['vms_only']
|
||||||
|
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
if not vms_only:
|
||||||
|
self.stdout.write('Rebuilding config context data for devices...')
|
||||||
|
cursor.execute(
|
||||||
|
'UPDATE dcim_device SET config_context_data = compute_config_context_for_device(id)'
|
||||||
|
)
|
||||||
|
self.stdout.write(self.style.SUCCESS(f' Updated {cursor.rowcount} devices'))
|
||||||
|
|
||||||
|
if not devices_only:
|
||||||
|
self.stdout.write('Rebuilding config context data for virtual machines...')
|
||||||
|
cursor.execute(
|
||||||
|
'UPDATE virtualization_virtualmachine '
|
||||||
|
'SET config_context_data = compute_config_context_for_vm(id)'
|
||||||
|
)
|
||||||
|
self.stdout.write(self.style.SUCCESS(f' Updated {cursor.rowcount} virtual machines'))
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS('Done.'))
|
||||||
1112
netbox/extras/migrations/0135_config_context_triggers.py
Normal file
1112
netbox/extras/migrations/0135_config_context_triggers.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -225,6 +225,11 @@ class ConfigContextModel(models.Model):
|
|||||||
"Local config context data takes precedence over source contexts in the final rendered config context"
|
"Local config context data takes precedence over source contexts in the final rendered config context"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
config_context_data = models.JSONField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
editable=False,
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
@@ -234,19 +239,21 @@ class ConfigContextModel(models.Model):
|
|||||||
Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs.
|
Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs.
|
||||||
Return the rendered configuration context for a device or VM.
|
Return the rendered configuration context for a device or VM.
|
||||||
"""
|
"""
|
||||||
data = {}
|
# Use pre-rendered cached field if available
|
||||||
|
if self.config_context_data is not None:
|
||||||
|
return self.config_context_data
|
||||||
|
|
||||||
if not hasattr(self, 'config_context_data'):
|
# Fall back to annotation if queryset was annotated
|
||||||
# The annotation is not available, so we fall back to manually querying for the config context objects
|
data = {}
|
||||||
config_context_data = ConfigContext.objects.get_for_object(self, aggregate_data=True) or []
|
if hasattr(self, '_annotated_config_context_data'):
|
||||||
|
config_context_data = self._annotated_config_context_data or []
|
||||||
else:
|
else:
|
||||||
# The attribute may exist, but the annotated value could be None if there is no config context data
|
# Last resort: compute on-the-fly
|
||||||
config_context_data = self.config_context_data or []
|
config_context_data = ConfigContext.objects.get_for_object(self, aggregate_data=True) or []
|
||||||
|
|
||||||
for context in config_context_data:
|
for context in config_context_data:
|
||||||
data = deepmerge(data, context)
|
data = deepmerge(data, context)
|
||||||
|
|
||||||
# If the object has local config context data defined, merge it last
|
|
||||||
if self.local_context_data:
|
if self.local_context_data:
|
||||||
data = deepmerge(data, self.local_context_data)
|
data = deepmerge(data, self.local_context_data)
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
|
|||||||
"""
|
"""
|
||||||
from extras.models import ConfigContext
|
from extras.models import ConfigContext
|
||||||
return self.annotate(
|
return self.annotate(
|
||||||
config_context_data=Subquery(
|
_annotated_config_context_data=Subquery(
|
||||||
ConfigContext.objects.filter(
|
ConfigContext.objects.filter(
|
||||||
self._get_config_context_filters()
|
self._get_config_context_filters()
|
||||||
).annotate(
|
).annotate(
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ class ConfigContextTest(TestCase):
|
|||||||
"b": 456,
|
"b": 456,
|
||||||
"c": 777
|
"c": 777
|
||||||
}
|
}
|
||||||
|
device.refresh_from_db()
|
||||||
self.assertEqual(device.get_config_context(), expected_data)
|
self.assertEqual(device.get_config_context(), expected_data)
|
||||||
|
|
||||||
def test_name_ordering_after_weight(self):
|
def test_name_ordering_after_weight(self):
|
||||||
@@ -235,6 +236,7 @@ class ConfigContextTest(TestCase):
|
|||||||
"b": 456,
|
"b": 456,
|
||||||
"c": 789
|
"c": 789
|
||||||
}
|
}
|
||||||
|
device.refresh_from_db()
|
||||||
self.assertEqual(device.get_config_context(), expected_data)
|
self.assertEqual(device.get_config_context(), expected_data)
|
||||||
|
|
||||||
def test_schema_validation(self):
|
def test_schema_validation(self):
|
||||||
@@ -303,6 +305,7 @@ class ConfigContextTest(TestCase):
|
|||||||
)
|
)
|
||||||
ConfigContext.objects.bulk_create([context1, context2, context3, context4])
|
ConfigContext.objects.bulk_create([context1, context2, context3, context4])
|
||||||
|
|
||||||
|
device.refresh_from_db()
|
||||||
annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
|
annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
|
||||||
self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
|
self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
|
||||||
|
|
||||||
@@ -666,7 +669,7 @@ class ConfigContextTest(TestCase):
|
|||||||
self.assertFalse(queryset.query.distinct)
|
self.assertFalse(queryset.query.distinct)
|
||||||
|
|
||||||
# Check that tag subqueries DO use DISTINCT by inspecting the annotation
|
# Check that tag subqueries DO use DISTINCT by inspecting the annotation
|
||||||
config_annotation = queryset.query.annotations.get('config_context_data')
|
config_annotation = queryset.query.annotations.get('_annotated_config_context_data')
|
||||||
self.assertIsNotNone(config_annotation)
|
self.assertIsNotNone(config_annotation)
|
||||||
|
|
||||||
def find_tag_subqueries(where_node):
|
def find_tag_subqueries(where_node):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from django.db.models import Sum
|
from django.db.models import Sum
|
||||||
from rest_framework.routers import APIRootView
|
from rest_framework.routers import APIRootView
|
||||||
|
|
||||||
from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin
|
from extras.api.mixins import RenderConfigMixin
|
||||||
from netbox.api.viewsets import NetBoxModelViewSet
|
from netbox.api.viewsets import NetBoxModelViewSet
|
||||||
from utilities.query_functions import CollateAsChar
|
from utilities.query_functions import CollateAsChar
|
||||||
from virtualization import filtersets
|
from virtualization import filtersets
|
||||||
@@ -48,7 +48,7 @@ class ClusterViewSet(NetBoxModelViewSet):
|
|||||||
# Virtual machines
|
# Virtual machines
|
||||||
#
|
#
|
||||||
|
|
||||||
class VirtualMachineViewSet(ConfigContextQuerySetMixin, RenderConfigMixin, NetBoxModelViewSet):
|
class VirtualMachineViewSet(RenderConfigMixin, NetBoxModelViewSet):
|
||||||
queryset = VirtualMachine.objects.all()
|
queryset = VirtualMachine.objects.all()
|
||||||
filterset_class = filtersets.VirtualMachineFilterSet
|
filterset_class = filtersets.VirtualMachineFilterSet
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('virtualization', '0052_gfk_indexes'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='virtualmachine',
|
||||||
|
name='config_context_data',
|
||||||
|
field=models.JSONField(blank=True, editable=False, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -487,7 +487,7 @@ class VirtualMachineVirtualDisksView(generic.ObjectChildrenView):
|
|||||||
|
|
||||||
@register_model_view(VirtualMachine, 'configcontext', path='config-context')
|
@register_model_view(VirtualMachine, 'configcontext', path='config-context')
|
||||||
class VirtualMachineConfigContextView(ObjectConfigContextView):
|
class VirtualMachineConfigContextView(ObjectConfigContextView):
|
||||||
queryset = VirtualMachine.objects.annotate_config_context_data()
|
queryset = VirtualMachine.objects.all()
|
||||||
base_template = 'virtualization/virtualmachine.html'
|
base_template = 'virtualization/virtualmachine.html'
|
||||||
tab = ViewTab(
|
tab = ViewTab(
|
||||||
label=_('Config Context'),
|
label=_('Config Context'),
|
||||||
|
|||||||
Reference in New Issue
Block a user