mirror of
https://github.com/netbox-community/netbox.git
synced 2026-03-03 21:10:04 +01:00
Compare commits
15 Commits
21357-regi
...
21025-pre-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb99199340 | ||
|
|
7ec656bc7c | ||
|
|
06bbae0f84 | ||
|
|
8ff9fd26d1 | ||
|
|
a0e23ac3c9 | ||
|
|
071d4a63aa | ||
|
|
7db2739465 | ||
|
|
74326edc20 | ||
|
|
2ef21f7097 | ||
|
|
3adcdc34c3 | ||
|
|
f33109e485 | ||
|
|
d10453883f | ||
|
|
6dbd8f6170 | ||
|
|
715f9d150c | ||
|
|
f4567ba099 |
12
.github/workflows/ci.yml
vendored
12
.github/workflows/ci.yml
vendored
@@ -55,6 +55,13 @@ jobs:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check Python linting & PEP8 compliance
|
||||
uses: astral-sh/ruff-action@4919ec5cf1f49eff0871dbcea0da843445b837e6 # v3.6.1
|
||||
with:
|
||||
version: "0.15.2"
|
||||
args: "check --output-format=github"
|
||||
src: "netbox/"
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
@@ -82,7 +89,7 @@ jobs:
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install ruff coverage tblib
|
||||
pip install coverage tblib
|
||||
|
||||
- name: Build documentation
|
||||
run: mkdocs build
|
||||
@@ -93,9 +100,6 @@ jobs:
|
||||
- name: Check for missing migrations
|
||||
run: python netbox/manage.py makemigrations --check
|
||||
|
||||
- name: Check PEP8 compliance
|
||||
run: ruff check netbox/
|
||||
|
||||
- name: Check UI ESLint, TypeScript, and Prettier Compliance
|
||||
run: yarn --cwd netbox/project-static validate
|
||||
|
||||
|
||||
44
.github/workflows/claude-code-review.yml
vendored
Normal file
44
.github/workflows/claude-code-review.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, ready_for_review, reopened]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
|
||||
50
.github/workflows/claude.yml
vendored
Normal file
50
.github/workflows/claude.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.14.1
|
||||
rev: v0.15.2
|
||||
hooks:
|
||||
- id: ruff
|
||||
name: "Ruff linter"
|
||||
|
||||
@@ -168,6 +168,14 @@ Update the static OpenAPI schema definition at `contrib/openapi.json` with the m
|
||||
./manage.py spectacular --format openapi-json > ../contrib/openapi.json
|
||||
```
|
||||
|
||||
### Update Development Dependencies
|
||||
|
||||
Keep development tooling versions consistent across the project. If you upgrade a dev-only dependency, update all places where it’s pinned so local tooling and CI run the same versions.
|
||||
|
||||
* Ruff:
|
||||
* `.pre-commit-config.yaml`
|
||||
* `.github/workflows/ci.yml`
|
||||
|
||||
### Submit a Pull Request
|
||||
|
||||
Commit the above changes and submit a pull request titled **"Release vX.Y.Z"** to merge the current release branch (e.g. `release-vX.Y.Z`) into `main`. Copy the documented release notes into the pull request's body.
|
||||
|
||||
@@ -34,7 +34,8 @@ The following rules are ignored when linting.
|
||||
|
||||
##### [E501](https://docs.astral.sh/ruff/rules/line-too-long/): Line too long
|
||||
|
||||
NetBox does not enforce a hard restriction on line length, although a maximum length of 120 characters is strongly encouraged for Python code where possible. The maximum length does not apply to HTML templates or to automatically generated code (e.g. database migrations).
|
||||
NetBox enforces a maximum line length of 120 characters for Python code using Ruff (E501).
|
||||
The maximum length does not apply to HTML templates or to automatically generated code (e.g. database migrations).
|
||||
|
||||
##### [F403](https://docs.astral.sh/ruff/rules/undefined-local-with-import-star/): Undefined local with import star
|
||||
|
||||
@@ -47,6 +48,14 @@ Wildcard imports (for example, `from .constants import *`) are acceptable under
|
||||
|
||||
The justification for ignoring this rule is the same as F403 above.
|
||||
|
||||
##### [RET504](https://docs.astral.sh/ruff/rules/unnecessary-assign/): Unnecessary assign
|
||||
|
||||
There are multiple instances where it is more readable and clearer to first assign to a variable and then return it.
|
||||
|
||||
##### [UP032](https://docs.astral.sh/ruff/rules/f-string/): f-string
|
||||
|
||||
For localizable strings, it is necessary to not use the `f-string` syntax, as Django's translation functions (e.g. `gettext_lazy`) require plain string literals.
|
||||
|
||||
### Introducing New Dependencies
|
||||
|
||||
The introduction of a new dependency is best avoided unless it is absolutely necessary. For small features, it's generally preferable to replicate functionality within the NetBox code base rather than to introduce reliance on an external project. This reduces both the burden of tracking new releases and our exposure to outside bugs and supply chain attacks.
|
||||
|
||||
@@ -177,7 +177,7 @@ class VirtualCircuitTerminationType(CustomFieldsMixin, TagsMixin, ObjectType):
|
||||
filters=VirtualCircuitFilter,
|
||||
pagination=True
|
||||
)
|
||||
class VirtualCircuitType(PrimaryObjectType):
|
||||
class VirtualCircuitType(ContactsMixin, PrimaryObjectType):
|
||||
provider_network: ProviderNetworkType = strawberry_django.field(select_related=["provider_network"])
|
||||
provider_account: ProviderAccountType | None
|
||||
type: Annotated["VirtualCircuitTypeType", strawberry.lazy('circuits.graphql.types')] = strawberry_django.field(
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from circuits.choices import *
|
||||
from netbox.models import ChangeLoggedModel, PrimaryModel
|
||||
from netbox.models.features import CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, TagsMixin
|
||||
from netbox.models.features import ContactsMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, TagsMixin
|
||||
|
||||
from .base import BaseCircuitType
|
||||
|
||||
@@ -30,7 +30,7 @@ class VirtualCircuitType(BaseCircuitType):
|
||||
verbose_name_plural = _('virtual circuit types')
|
||||
|
||||
|
||||
class VirtualCircuit(PrimaryModel):
|
||||
class VirtualCircuit(ContactsMixin, PrimaryModel):
|
||||
"""
|
||||
A virtual connection between two or more endpoints, delivered across one or more physical circuits.
|
||||
"""
|
||||
|
||||
@@ -71,7 +71,7 @@ class VirtualCircuitTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModel
|
||||
model = VirtualCircuit
|
||||
fields = (
|
||||
'pk', 'id', 'cid', 'provider', 'provider_account', 'provider_network', 'type', 'status', 'tenant',
|
||||
'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated',
|
||||
'tenant_group', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'cid', 'provider', 'provider_account', 'provider_network', 'type', 'status', 'tenant',
|
||||
|
||||
@@ -2,6 +2,7 @@ import re
|
||||
import typing
|
||||
from collections import OrderedDict
|
||||
|
||||
from drf_spectacular.contrib.django_filters import DjangoFilterExtension
|
||||
from drf_spectacular.extensions import OpenApiSerializerExtension, OpenApiSerializerFieldExtension, _SchemaType
|
||||
from drf_spectacular.openapi import AutoSchema
|
||||
from drf_spectacular.plumbing import (
|
||||
@@ -9,6 +10,7 @@ from drf_spectacular.plumbing import (
|
||||
build_choice_field,
|
||||
build_media_type_object,
|
||||
build_object_type,
|
||||
follow_field_source,
|
||||
get_doc,
|
||||
)
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
@@ -23,6 +25,29 @@ BULK_ACTIONS = ("bulk_destroy", "bulk_partial_update", "bulk_update")
|
||||
WRITABLE_ACTIONS = ("PATCH", "POST", "PUT")
|
||||
|
||||
|
||||
class NetBoxDjangoFilterExtension(DjangoFilterExtension):
|
||||
"""
|
||||
Overrides drf-spectacular's DjangoFilterExtension to fix a regression in v0.29.0 where
|
||||
_get_model_field() incorrectly double-appends to_field_name when field_name already ends
|
||||
with that value (e.g. field_name='tags__slug', to_field_name='slug' produces the invalid
|
||||
path ['tags', 'slug', 'slug']). This caused hundreds of spurious warnings during schema
|
||||
generation for filters such as TagFilter, TenancyFilterSet.tenant, and OwnerFilterMixin.owner.
|
||||
|
||||
See: https://github.com/netbox-community/netbox/issues/20787
|
||||
https://github.com/tfranzel/drf-spectacular/issues/1475
|
||||
"""
|
||||
priority = 1
|
||||
|
||||
def _get_model_field(self, filter_field, model):
|
||||
if not filter_field.field_name:
|
||||
return None
|
||||
path = filter_field.field_name.split('__')
|
||||
to_field_name = filter_field.extra.get('to_field_name')
|
||||
if to_field_name is not None and path[-1] != to_field_name:
|
||||
path.append(to_field_name)
|
||||
return follow_field_source(model, path, emit_warnings=False)
|
||||
|
||||
|
||||
class FixTimeZoneSerializerField(OpenApiSerializerFieldExtension):
|
||||
target_class = 'timezone_field.rest_framework.TimeZoneSerializerField'
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from dcim import filtersets
|
||||
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
|
||||
from dcim.models import *
|
||||
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.metadata import ContentTypeMetadata
|
||||
from netbox.api.pagination import StripCountAnnotationsPaginator
|
||||
@@ -398,12 +398,7 @@ class PlatformViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
||||
# Devices/modules
|
||||
#
|
||||
|
||||
class DeviceViewSet(
|
||||
SequentialBulkCreatesMixin,
|
||||
ConfigContextQuerySetMixin,
|
||||
RenderConfigMixin,
|
||||
NetBoxModelViewSet
|
||||
):
|
||||
class DeviceViewSet(SequentialBulkCreatesMixin, RenderConfigMixin, NetBoxModelViewSet):
|
||||
queryset = Device.objects.prefetch_related(
|
||||
'parent_bay', # Referenced by DeviceSerializer.get_parent_device()
|
||||
)
|
||||
|
||||
@@ -921,6 +921,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
# 10 Gbps Ethernet
|
||||
TYPE_10GE_BR_D = '10gbase-br-d'
|
||||
TYPE_10GE_BR_U = '10gbase-br-u'
|
||||
TYPE_10GE_CU = '10gbase-cu'
|
||||
TYPE_10GE_CX4 = '10gbase-cx4'
|
||||
TYPE_10GE_ER = '10gbase-er'
|
||||
TYPE_10GE_LR = '10gbase-lr'
|
||||
@@ -943,6 +944,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_40GE_FR4 = '40gbase-fr4'
|
||||
TYPE_40GE_LR4 = '40gbase-lr4'
|
||||
TYPE_40GE_SR4 = '40gbase-sr4'
|
||||
TYPE_40GE_SR4_BD = '40gbase-sr4-bd'
|
||||
|
||||
# 50 Gbps Ethernet
|
||||
TYPE_50GE_CR = '50gbase-cr'
|
||||
@@ -1192,6 +1194,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(
|
||||
(TYPE_10GE_BR_D, '10GBASE-BR-D (10GE BiDi Down)'),
|
||||
(TYPE_10GE_BR_U, '10GBASE-BR-U (10GE BiDi Up)'),
|
||||
(TYPE_10GE_CU, '10GBASE-CU (10GE DAC Passive Twinax)'),
|
||||
(TYPE_10GE_CX4, '10GBASE-CX4 (10GE DAC)'),
|
||||
(TYPE_10GE_ER, '10GBASE-ER (10GE)'),
|
||||
(TYPE_10GE_LR, '10GBASE-LR (10GE)'),
|
||||
@@ -1220,6 +1223,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_40GE_FR4, '40GBASE-FR4 (40GE)'),
|
||||
(TYPE_40GE_LR4, '40GBASE-LR4 (40GE)'),
|
||||
(TYPE_40GE_SR4, '40GBASE-SR4 (40GE)'),
|
||||
(TYPE_40GE_SR4_BD, '40GBASE-SR4 (40GE BiDi)'),
|
||||
)
|
||||
),
|
||||
(
|
||||
|
||||
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),
|
||||
),
|
||||
]
|
||||
@@ -44,7 +44,7 @@ class RackPanel(panels.ObjectAttributesPanel):
|
||||
site = attrs.RelatedObjectAttr('site', linkify=True, grouped_by='group')
|
||||
location = attrs.NestedObjectAttr('location', linkify=True)
|
||||
name = attrs.TextAttr('name')
|
||||
facility = attrs.TextAttr('facility', label=_('Facility ID'))
|
||||
facility_id = attrs.TextAttr('facility_id', label=_('Facility ID'))
|
||||
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
|
||||
status = attrs.ChoiceAttr('status')
|
||||
rack_type = attrs.RelatedObjectAttr('rack_type', linkify=True, grouped_by='manufacturer')
|
||||
|
||||
@@ -2683,7 +2683,7 @@ class DeviceInventoryView(DeviceComponentsView):
|
||||
|
||||
@register_model_view(Device, 'configcontext', path='config-context')
|
||||
class DeviceConfigContextView(ObjectConfigContextView):
|
||||
queryset = Device.objects.annotate_config_context_data()
|
||||
queryset = Device.objects.all()
|
||||
base_template = 'dcim/device/base.html'
|
||||
tab = ViewTab(
|
||||
label=_('Config Context'),
|
||||
|
||||
@@ -10,34 +10,11 @@ from netbox.api.renderers import TextRenderer
|
||||
from .serializers import ConfigTemplateSerializer
|
||||
|
||||
__all__ = (
|
||||
'ConfigContextQuerySetMixin',
|
||||
'ConfigTemplateRenderMixin',
|
||||
'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:
|
||||
"""
|
||||
Provides a method to return a rendered ConfigTemplate as REST API data.
|
||||
|
||||
@@ -22,7 +22,7 @@ if TYPE_CHECKING:
|
||||
@strawberry.type
|
||||
class ConfigContextMixin:
|
||||
|
||||
@strawberry_django.field
|
||||
@strawberry_django.field(only=['config_context_data', 'local_context_data'])
|
||||
def config_context(self) -> strawberry.scalars.JSON:
|
||||
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"
|
||||
)
|
||||
)
|
||||
config_context_data = models.JSONField(
|
||||
blank=True,
|
||||
null=True,
|
||||
editable=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
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.
|
||||
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'):
|
||||
# The annotation is not available, so we fall back to manually querying for the config context objects
|
||||
config_context_data = ConfigContext.objects.get_for_object(self, aggregate_data=True) or []
|
||||
# Fall back to annotation if queryset was annotated
|
||||
data = {}
|
||||
if hasattr(self, '_annotated_config_context_data'):
|
||||
config_context_data = self._annotated_config_context_data or []
|
||||
else:
|
||||
# The attribute may exist, but the annotated value could be None if there is no config context data
|
||||
config_context_data = self.config_context_data or []
|
||||
# Last resort: compute on-the-fly
|
||||
config_context_data = ConfigContext.objects.get_for_object(self, aggregate_data=True) or []
|
||||
|
||||
for context in config_context_data:
|
||||
data = deepmerge(data, context)
|
||||
|
||||
# If the object has local config context data defined, merge it last
|
||||
if self.local_context_data:
|
||||
data = deepmerge(data, self.local_context_data)
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
|
||||
"""
|
||||
from extras.models import ConfigContext
|
||||
return self.annotate(
|
||||
config_context_data=Subquery(
|
||||
_annotated_config_context_data=Subquery(
|
||||
ConfigContext.objects.filter(
|
||||
self._get_config_context_filters()
|
||||
).annotate(
|
||||
|
||||
@@ -206,6 +206,7 @@ class ConfigContextTest(TestCase):
|
||||
"b": 456,
|
||||
"c": 777
|
||||
}
|
||||
device.refresh_from_db()
|
||||
self.assertEqual(device.get_config_context(), expected_data)
|
||||
|
||||
def test_name_ordering_after_weight(self):
|
||||
@@ -235,6 +236,7 @@ class ConfigContextTest(TestCase):
|
||||
"b": 456,
|
||||
"c": 789
|
||||
}
|
||||
device.refresh_from_db()
|
||||
self.assertEqual(device.get_config_context(), expected_data)
|
||||
|
||||
def test_schema_validation(self):
|
||||
@@ -303,6 +305,7 @@ class ConfigContextTest(TestCase):
|
||||
)
|
||||
ConfigContext.objects.bulk_create([context1, context2, context3, context4])
|
||||
|
||||
device.refresh_from_db()
|
||||
annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
|
||||
self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
|
||||
|
||||
@@ -666,7 +669,7 @@ class ConfigContextTest(TestCase):
|
||||
self.assertFalse(queryset.query.distinct)
|
||||
|
||||
# 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)
|
||||
|
||||
def find_tag_subqueries(where_node):
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
{% load i18n %}
|
||||
<a href="{{ value.get_absolute_url }}"{% if name %} id="attr_{{ name }}"{% endif %}>{{ value.address.ip }}</a>
|
||||
{% if value.nat_inside %}
|
||||
({% trans "NAT for" %} <a href="{{ value.nat_inside.get_absolute_url }}">{{ value.nat_inside.address.ip }}</a>)
|
||||
{% elif value.nat_outside.exists %}
|
||||
({% trans "NAT" %}: {% for nat in value.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
|
||||
{% endif %}
|
||||
<span>
|
||||
<a href="{{ value.get_absolute_url }}"{% if name %} id="attr_{{ name }}"{% endif %}>{{ value.address.ip }}</a>
|
||||
{% if value.nat_inside %}
|
||||
({% trans "NAT for" %} <a href="{{ value.nat_inside.get_absolute_url }}">{{ value.nat_inside.address.ip }}</a>)
|
||||
{% elif value.nat_outside.exists %}
|
||||
({% trans "NAT" %}: {% for nat in value.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
|
||||
{% endif %}
|
||||
</span>
|
||||
<a class="btn btn-sm btn-primary copy-content" data-clipboard-target="#attr_{{ name }}" title="{% trans "Copy to clipboard" %}">
|
||||
<i class="mdi mdi-content-copy"></i>
|
||||
</a>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,8 @@
|
||||
{% load i18n %}
|
||||
{% if customfield.type == 'integer' and value is not None %}
|
||||
{{ value }}
|
||||
{% elif customfield.type == 'decimal' and value is not None %}
|
||||
{{ value }}
|
||||
{% elif customfield.type == 'longtext' and value %}
|
||||
{{ value|markdown }}
|
||||
{% elif customfield.type == 'boolean' and value == True %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.db.models import Sum
|
||||
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 utilities.query_functions import CollateAsChar
|
||||
from virtualization import filtersets
|
||||
@@ -48,7 +48,7 @@ class ClusterViewSet(NetBoxModelViewSet):
|
||||
# Virtual machines
|
||||
#
|
||||
|
||||
class VirtualMachineViewSet(ConfigContextQuerySetMixin, RenderConfigMixin, NetBoxModelViewSet):
|
||||
class VirtualMachineViewSet(RenderConfigMixin, NetBoxModelViewSet):
|
||||
queryset = VirtualMachine.objects.all()
|
||||
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')
|
||||
class VirtualMachineConfigContextView(ObjectConfigContextView):
|
||||
queryset = VirtualMachine.objects.annotate_config_context_data()
|
||||
queryset = VirtualMachine.objects.all()
|
||||
base_template = 'virtualization/virtualmachine.html'
|
||||
tab = ViewTab(
|
||||
label=_('Config Context'),
|
||||
|
||||
@@ -45,6 +45,8 @@ extend-select = [
|
||||
"UP", # pyupgrade: modernize syntax for your target Python (e.g., f-strings, built-in generics, newer stdlib idioms)
|
||||
"RUF022", # ruff: enforce sorted `__all__` lists
|
||||
]
|
||||
# If you add a rule to `ignore`, please also update the "Linter Exceptions" section in
|
||||
# docs/development/style-guide.md.
|
||||
ignore = [
|
||||
"F403", # pyflakes: `from ... import *` used; unable to detect undefined names
|
||||
"F405", # pyflakes: name may be undefined or defined from star imports
|
||||
|
||||
Reference in New Issue
Block a user