Merge branch 'feature' into 8356-vm-virtual-disk

This commit is contained in:
Jeremy Stretch 2023-11-17 14:49:08 -05:00
commit 6f295a1fc9
97 changed files with 891 additions and 309 deletions

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.6.4 placeholder: v3.6.5
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.6.4 placeholder: v3.6.5
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

37
.github/ISSUE_TEMPLATE/translation.yaml vendored Normal file
View File

@ -0,0 +1,37 @@
---
name: 🌍 Translation
description: Request support for a new language in the user interface
labels: ["type: translation"]
body:
- type: markdown
attributes:
value: >
**NOTE:** This template is used only for proposing the addition of *new* languages. Please do
not use it to request changes to existing translations.
- type: input
attributes:
label: Language
description: What is the name of the language in English?
validations:
required: true
- type: input
attributes:
label: ISO 639-1 code
description: >
What is the two-letter [ISO 639-1 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes)
assigned to the language?
validations:
required: true
- type: dropdown
attributes:
label: Volunteer
description: Are you a fluent speaker of this language **and** willing to contribute a translation map?
options:
- "Yes"
- "No"
validations:
required: true
- type: textarea
attributes:
label: Comments
description: Any other notes you would like to share

View File

@ -53,7 +53,8 @@ django-tables2
# User-defined tags for objects # User-defined tags for objects
# https://github.com/jazzband/django-taggit/blob/master/CHANGELOG.rst # https://github.com/jazzband/django-taggit/blob/master/CHANGELOG.rst
django-taggit # TODO: Upgrade to v5.0 for NetBox v3.7 beta
django-taggit<5.0
# A Django field for representing time zones # A Django field for representing time zones
# https://github.com/mfogel/django-timezone-field/ # https://github.com/mfogel/django-timezone-field/
@ -125,10 +126,6 @@ PyYAML
# https://github.com/psf/requests/blob/main/HISTORY.md # https://github.com/psf/requests/blob/main/HISTORY.md
requests requests
# Sentry SDK
# https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md
sentry-sdk
# Social authentication framework # Social authentication framework
# https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md # https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md
social-auth-core social-auth-core

View File

@ -4,27 +4,15 @@
### Enabling Error Reporting ### Enabling Error Reporting
NetBox supports native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this functionality, simply set `SENTRY_ENABLED` to True in `configuration.py`. Errors will be sent to a Sentry ingestor maintained by the NetBox team for analysis. NetBox supports native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this functionality, set `SENTRY_ENABLED` to True and define your unique [data source name (DSN)](https://docs.sentry.io/product/sentry-basics/concepts/dsn-explainer/) in `configuration.py`.
```python
SENTRY_ENABLED = True
```
### Using a Custom DSN
If you prefer instead to use your own Sentry ingestor, you'll need to first create a new project under your Sentry account to represent your NetBox deployment and obtain its corresponding data source name (DSN). This looks like a URL similar to the example below:
```
https://examplePublicKey@o0.ingest.sentry.io/0
```
Once you have obtained a DSN, configure Sentry in NetBox's `configuration.py` file with the following parameters:
```python ```python
SENTRY_ENABLED = True SENTRY_ENABLED = True
SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0" SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
``` ```
Setting `SENTRY_ENABLED` to False will disable the Sentry integration.
### Assigning Tags ### Assigning Tags
You can optionally attach one or more arbitrary tags to the outgoing error reports if desired by setting the `SENTRY_TAGS` parameter: You can optionally attach one or more arbitrary tags to the outgoing error reports if desired by setting the `SENTRY_TAGS` parameter:

View File

@ -18,6 +18,9 @@ Default: False
Set to True to enable automatic error reporting via [Sentry](https://sentry.io/). Set to True to enable automatic error reporting via [Sentry](https://sentry.io/).
!!! note
The `sentry-sdk` Python package is required to enable Sentry integration.
--- ---
## SENTRY_SAMPLE_RATE ## SENTRY_SAMPLE_RATE

View File

@ -53,6 +53,10 @@ This store maintains all registered items for plugins, such as navigation menus,
A dictionary mapping each model (identified by its app and label) to its search index class, if one has been registered for it. A dictionary mapping each model (identified by its app and label) to its search index class, if one has been registered for it.
### `tables`
A dictionary mapping table classes to lists of extra columns that have been registered by plugins using the `register_table_column()` utility function. Each column is defined as a tuple of name and column instance.
### `views` ### `views`
A hierarchical mapping of registered views for each model. Mappings are added using the `register_model_view()` decorator, and URLs paths can be generated from these using `get_model_urls()`. A hierarchical mapping of registered views for each model. Mappings are added using the `register_model_view()` decorator, and URLs paths can be generated from these using `get_model_urls()`.

View File

@ -17,6 +17,7 @@ class MyModelIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('site', 'device', 'status', 'description')
``` ```
A SearchIndex subclass defines both its model and a list of two-tuples specifying which model fields to be indexed and the weight (precedence) associated with each. Guidance on weight assignment for fields is provided below. A SearchIndex subclass defines both its model and a list of two-tuples specifying which model fields to be indexed and the weight (precedence) associated with each. Guidance on weight assignment for fields is provided below.

View File

@ -227,6 +227,17 @@ sudo sh -c "echo 'boto3' >> /opt/netbox/local_requirements.txt"
!!! info !!! info
These packages were previously required in NetBox v3.5 but now are optional. These packages were previously required in NetBox v3.5 but now are optional.
### Sentry Integration
NetBox may be configured to send error reports to [Sentry](../administration/error-reporting.md) for analysis. This integration requires installation of the `sentry-sdk` Python library.
```no-highlight
sudo sh -c "echo 'sentry-sdk' >> /opt/netbox/local_requirements.txt"
```
!!! info
Sentry integration was previously included by default in NetBox v3.6 but is now optional.
## Run the Upgrade Script ## Run the Upgrade Script
Once NetBox has been configured, we're ready to proceed with the actual installation. We'll run the packaged upgrade script (`upgrade.sh`) to perform the following actions: Once NetBox has been configured, we're ready to proceed with the actual installation. We'll run the packaged upgrade script (`upgrade.sh`) to perform the following actions:

View File

@ -14,8 +14,11 @@ class MyModelIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('site', 'device', 'status', 'description')
``` ```
Fields listed in `display_attrs` will not be cached for search, but will be displayed alongside the object when it appears in global search results. This is helpful for conveying to the user additional information about an object.
To register one or more indexes with NetBox, define a list named `indexes` at the end of this file: To register one or more indexes with NetBox, define a list named `indexes` at the end of this file:
```python ```python

View File

@ -87,3 +87,28 @@ The table column classes listed below are supported for use in plugins. These cl
options: options:
members: members:
- __init__ - __init__
## Extending Core Tables
!!! info "This feature was introduced in NetBox v3.7."
Plugins can register their own custom columns on core tables using the `register_table_column()` utility function. This allows a plugin to attach additional information, such as relationships to its own models, to built-in object lists.
```python
import django_tables2
from django.utils.translation import gettext_lazy as _
from dcim.tables import SiteTable
from utilities.tables import register_table_column
mycol = django_tables2.Column(
verbose_name=_('My Column'),
accessor=django_tables2.A('description')
)
register_table_column(mycol, 'foo', SiteTable)
```
You'll typically want to define an accessor identifying the desired model field or relationship when defining a custom column. See the [django-tables2 documentation](https://django-tables2.readthedocs.io/) for more information on creating custom columns.
::: utilities.tables.register_table_column

View File

@ -116,7 +116,7 @@ Multiple conditions can be combined into nested sets using AND or OR logic. This
] ]
}, },
{ {
"attr": "tags", "attr": "tags.slug",
"value": "exempt", "value": "exempt",
"op": "contains" "op": "contains"
} }

View File

@ -1,6 +1,36 @@
# NetBox v3.6 # NetBox v3.6
## v3.6.5 (FUTURE) ## v3.6.6 (FUTURE)
---
## v3.6.5 (2023-11-09)
### Enhancements
* [#12741](https://github.com/netbox-community/netbox/issues/12741) - Add selector widget to platform field on device & virtual machine forms
* [#13022](https://github.com/netbox-community/netbox/issues/13022) - Introduce support for assigning IP addresses when bulk importing services
* [#13587](https://github.com/netbox-community/netbox/issues/13587) - Annotate units of measurement on power port table columns
* [#13669](https://github.com/netbox-community/netbox/issues/13669) - Add bulk import button to contact assignments list view
* [#13723](https://github.com/netbox-community/netbox/issues/13723) - Add inventory items column to interfaces table
* [#13743](https://github.com/netbox-community/netbox/issues/13743) - Add site column to power feeds table
* [#13936](https://github.com/netbox-community/netbox/issues/13936) - Add primary IPv4 and IPv6 filters for virtual machines and VDCs
* [#13951](https://github.com/netbox-community/netbox/issues/13951) - Add device & virtual machine fields to service filter form
* [#14085](https://github.com/netbox-community/netbox/issues/14085) - Strip trailing port number from value returned by `get_client_ip()`
* [#14101](https://github.com/netbox-community/netbox/issues/14101) - Add greater/less than mask length filters for IP addresses
* [#14112](https://github.com/netbox-community/netbox/issues/14112) - Add tab listing child items under inventory item view
* [#14113](https://github.com/netbox-community/netbox/issues/14113) - Add optional parent column to inventory items table
* [#14220](https://github.com/netbox-community/netbox/issues/14220) - Order available columns alphabetically in table configuration form
* [#14221](https://github.com/netbox-community/netbox/issues/14221) - Add contact group column on contact assignments table
### Bug Fixes
* [#14033](https://github.com/netbox-community/netbox/issues/14033) - Avoid exception when attempting to connect both ends of a cable to the same object
* [#14117](https://github.com/netbox-community/netbox/issues/14117) - Check that enough rear port positions have been selected to accommodate the number of front ports being created
* [#14166](https://github.com/netbox-community/netbox/issues/14166) - Permit user login when maintenance mode is enabled
* [#14182](https://github.com/netbox-community/netbox/issues/14182) - Ensure the active configuration is restored upon clearing cache
* [#14195](https://github.com/netbox-community/netbox/issues/14195) - Correct permissions evaluation for ASN range child ASNs view
* [#14223](https://github.com/netbox-community/netbox/issues/14223) - Disable ordering of jobs by assigned object
--- ---

View File

@ -10,6 +10,7 @@ class CircuitIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('provider', 'provider_account', 'type', 'status', 'tenant', 'description')
@register_search @register_search
@ -22,6 +23,7 @@ class CircuitTerminationIndex(SearchIndex):
('port_speed', 2000), ('port_speed', 2000),
('upstream_speed', 2000), ('upstream_speed', 2000),
) )
display_attrs = ('circuit', 'site', 'provider_network', 'description')
@register_search @register_search
@ -32,6 +34,7 @@ class CircuitTypeIndex(SearchIndex):
('slug', 110), ('slug', 110),
('description', 500), ('description', 500),
) )
display_attrs = ('description',)
@register_search @register_search
@ -42,6 +45,7 @@ class ProviderIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('description',)
class ProviderAccountIndex(SearchIndex): class ProviderAccountIndex(SearchIndex):
@ -51,6 +55,7 @@ class ProviderAccountIndex(SearchIndex):
('account', 200), ('account', 200),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('provider', 'account', 'description')
@register_search @register_search
@ -62,3 +67,4 @@ class ProviderNetworkIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('provider', 'service_id', 'description')

View File

@ -1,12 +1,10 @@
from django import forms from django import forms
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.choices import * from core.choices import *
from core.models import * from core.models import *
from extras.forms.mixins import SavedFiltersMixin from extras.forms.mixins import SavedFiltersMixin
from extras.utils import FeatureQuery
from netbox.forms import NetBoxModelFilterSetForm from netbox.forms import NetBoxModelFilterSetForm
from netbox.utils import get_data_backend_choices from netbox.utils import get_data_backend_choices
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm
@ -69,7 +67,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
) )
object_type = ContentTypeChoiceField( object_type = ContentTypeChoiceField(
label=_('Object Type'), label=_('Object Type'),
queryset=ContentType.objects.filter(FeatureQuery('jobs').get_query()), queryset=ContentType.objects.with_feature('jobs'),
required=False, required=False,
) )
status = forms.MultipleChoiceField( status = forms.MultipleChoiceField(

View File

@ -1,11 +1,20 @@
from django.core.cache import cache from django.core.cache import cache
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from extras.models import ConfigRevision
class Command(BaseCommand): class Command(BaseCommand):
"""Command to clear the entire cache.""" """Command to clear the entire cache."""
help = 'Clears the cache.' help = 'Clears the cache.'
def handle(self, *args, **kwargs): def handle(self, *args, **kwargs):
# Fetch the current config revision from the cache
config_version = cache.get('config_version')
# Clear the cache
cache.clear() cache.clear()
self.stdout.write('Cache has been cleared.', ending="\n") self.stdout.write('Cache has been cleared.', ending="\n")
if config_version:
# Activate the current config revision
ConfigRevision.objects.get(id=config_version).activate()
self.stdout.write(f'Config revision ({config_version}) has been restored.', ending="\n")

View File

@ -4,7 +4,6 @@ from django.conf import settings
import django.core.validators import django.core.validators
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import extras.utils
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -30,7 +29,7 @@ class Migration(migrations.Migration):
('status', models.CharField(default='pending', max_length=30)), ('status', models.CharField(default='pending', max_length=30)),
('data', models.JSONField(blank=True, null=True)), ('data', models.JSONField(blank=True, null=True)),
('job_id', models.UUIDField(unique=True)), ('job_id', models.UUIDField(unique=True)),
('object_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('jobs'), on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='contenttypes.contenttype')), ('object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='contenttypes.contenttype')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
], ],
options={ options={

View File

@ -21,6 +21,24 @@ class ContentTypeManager(ContentTypeManager_):
q |= Q(app_label=app_label, model__in=models) q |= Q(app_label=app_label, model__in=models)
return self.get_queryset().filter(q) return self.get_queryset().filter(q)
def with_feature(self, feature):
"""
Return the ContentTypes only for models which are registered as supporting the specified feature. For example,
we can find all ContentTypes for models which support webhooks with
ContentType.objects.with_feature('webhooks')
"""
if feature not in registry['model_features']:
raise KeyError(
f"{feature} is not a registered model feature! Valid features are: {registry['model_features'].keys()}"
)
q = Q()
for app_label, models in registry['model_features'][feature].items():
q |= Q(app_label=app_label, model__in=models)
return self.get_queryset().filter(q)
class ContentType(ContentType_): class ContentType(ContentType_):
""" """

View File

@ -6,7 +6,6 @@ from urllib.parse import urlparse
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from django.db import models from django.db import models
@ -368,7 +367,7 @@ class AutoSyncRecord(models.Model):
related_name='+' related_name='+'
) )
object_type = models.ForeignKey( object_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='+' related_name='+'
) )

View File

@ -3,7 +3,7 @@ import uuid
import django_rq import django_rq
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
@ -11,8 +11,8 @@ from django.utils import timezone
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from core.choices import JobStatusChoices from core.choices import JobStatusChoices
from core.models import ContentType
from extras.constants import EVENT_JOB_END, EVENT_JOB_START from extras.constants import EVENT_JOB_END, EVENT_JOB_START
from extras.utils import FeatureQuery
from netbox.config import get_config from netbox.config import get_config
from netbox.constants import RQ_QUEUE_DEFAULT from netbox.constants import RQ_QUEUE_DEFAULT
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
@ -28,9 +28,8 @@ class Job(models.Model):
Tracks the lifecycle of a job which represents a background task (e.g. the execution of a custom script). Tracks the lifecycle of a job which represents a background task (e.g. the execution of a custom script).
""" """
object_type = models.ForeignKey( object_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
related_name='jobs', related_name='jobs',
limit_choices_to=FeatureQuery('jobs'),
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
object_id = models.PositiveBigIntegerField( object_id = models.PositiveBigIntegerField(
@ -123,6 +122,15 @@ class Job(models.Model):
def get_status_color(self): def get_status_color(self):
return JobStatusChoices.colors.get(self.status) return JobStatusChoices.colors.get(self.status)
def clean(self):
super().clean()
# Validate the assigned object type
if self.object_type not in ContentType.objects.with_feature('jobs'):
raise ValidationError(
_("Jobs cannot be assigned to this object type ({type}).").format(type=self.object_type)
)
@property @property
def duration(self): def duration(self):
if not self.completed: if not self.completed:

View File

@ -11,6 +11,7 @@ class DataSourceIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('type', 'status', 'description')
@register_search @register_search

View File

@ -19,7 +19,8 @@ class JobTable(NetBoxTable):
) )
object = tables.Column( object = tables.Column(
verbose_name=_('Object'), verbose_name=_('Object'),
linkify=True linkify=True,
orderable=False
) )
status = columns.ChoiceFieldColumn( status = columns.ChoiceFieldColumn(
verbose_name=_('Status'), verbose_name=_('Status'),

View File

@ -3,10 +3,8 @@ from django.shortcuts import get_object_or_404
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, OpenApiParameter from drf_spectacular.utils import extend_schema, OpenApiParameter
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from rest_framework.status import HTTP_400_BAD_REQUEST
from rest_framework.viewsets import ViewSet from rest_framework.viewsets import ViewSet
from circuits.models import Circuit from circuits.models import Circuit
@ -14,12 +12,11 @@ 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, ConfigTemplateRenderMixin from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin
from ipam.models import Prefix, VLAN from ipam.models import Prefix, VLAN
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
from netbox.api.renderers import TextRenderer
from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
from netbox.constants import NESTED_SERIALIZER_PREFIX from netbox.constants import NESTED_SERIALIZER_PREFIX
@ -390,7 +387,7 @@ class PlatformViewSet(NetBoxModelViewSet):
class DeviceViewSet( class DeviceViewSet(
SequentialBulkCreatesMixin, SequentialBulkCreatesMixin,
ConfigContextQuerySetMixin, ConfigContextQuerySetMixin,
ConfigTemplateRenderMixin, RenderConfigMixin,
NetBoxModelViewSet NetBoxModelViewSet
): ):
queryset = Device.objects.prefetch_related( queryset = Device.objects.prefetch_related(
@ -420,23 +417,6 @@ class DeviceViewSet(
return serializers.DeviceWithConfigContextSerializer return serializers.DeviceWithConfigContextSerializer
@action(detail=True, methods=['post'], url_path='render-config', renderer_classes=[JSONRenderer, TextRenderer])
def render_config(self, request, pk):
"""
Resolve and render the preferred ConfigTemplate for this Device.
"""
device = self.get_object()
configtemplate = device.get_config_template()
if not configtemplate:
return Response({'error': 'No config template found for this device.'}, status=HTTP_400_BAD_REQUEST)
# Compile context data
context_data = device.get_config_context()
context_data.update(request.data)
context_data.update({'device': device})
return self.render_configtemplate(request, configtemplate, context_data)
class VirtualDeviceContextViewSet(NetBoxModelViewSet): class VirtualDeviceContextViewSet(NetBoxModelViewSet):
queryset = VirtualDeviceContext.objects.prefetch_related( queryset = VirtualDeviceContext.objects.prefetch_related(

View File

@ -4,6 +4,7 @@ from django.utils.translation import gettext as _
from extras.filtersets import LocalConfigContextFilterSet from extras.filtersets import LocalConfigContextFilterSet
from extras.models import ConfigTemplate from extras.models import ConfigTemplate
from ipam.filtersets import PrimaryIPFilterSet
from ipam.models import ASN, L2VPN, IPAddress, VRF from ipam.models import ASN, L2VPN, IPAddress, VRF
from netbox.filtersets import ( from netbox.filtersets import (
BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet, BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
@ -818,7 +819,13 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
fields = ['id', 'name', 'slug', 'description'] fields = ['id', 'name', 'slug', 'description']
class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet): class DeviceFilterSet(
NetBoxModelFilterSet,
TenancyFilterSet,
ContactModelFilterSet,
LocalConfigContextFilterSet,
PrimaryIPFilterSet,
):
manufacturer_id = django_filters.ModelMultipleChoiceFilter( manufacturer_id = django_filters.ModelMultipleChoiceFilter(
field_name='device_type__manufacturer', field_name='device_type__manufacturer',
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
@ -994,16 +1001,6 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
method='_device_bays', method='_device_bays',
label=_('Has device bays'), label=_('Has device bays'),
) )
primary_ip4_id = django_filters.ModelMultipleChoiceFilter(
field_name='primary_ip4',
queryset=IPAddress.objects.all(),
label=_('Primary IPv4 (ID)'),
)
primary_ip6_id = django_filters.ModelMultipleChoiceFilter(
field_name='primary_ip6',
queryset=IPAddress.objects.all(),
label=_('Primary IPv6 (ID)'),
)
oob_ip_id = django_filters.ModelMultipleChoiceFilter( oob_ip_id = django_filters.ModelMultipleChoiceFilter(
field_name='oob_ip', field_name='oob_ip',
queryset=IPAddress.objects.all(), queryset=IPAddress.objects.all(),
@ -1070,7 +1067,7 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
return queryset.exclude(devicebays__isnull=value) return queryset.exclude(devicebays__isnull=value)
class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet, PrimaryIPFilterSet):
device_id = django_filters.ModelMultipleChoiceFilter( device_id = django_filters.ModelMultipleChoiceFilter(
field_name='device', field_name='device',
queryset=Device.objects.all(), queryset=Device.objects.all(),

View File

@ -443,7 +443,8 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
platform = DynamicModelChoiceField( platform = DynamicModelChoiceField(
label=_('Platform'), label=_('Platform'),
queryset=Platform.objects.all(), queryset=Platform.objects.all(),
required=False required=False,
selector=True
) )
cluster = DynamicModelChoiceField( cluster = DynamicModelChoiceField(
label=_('Cluster'), label=_('Cluster'),

View File

@ -151,6 +151,23 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp
) )
self.fields['rear_port'].choices = choices self.fields['rear_port'].choices = choices
def clean(self):
# Check that the number of FrontPortTemplates to be created matches the selected number of RearPortTemplate
# positions
frontport_count = len(self.cleaned_data['name'])
rearport_count = len(self.cleaned_data['rear_port'])
if frontport_count != rearport_count:
raise forms.ValidationError({
'rear_port': _(
"The number of front port templates to be created ({frontport_count}) must match the selected "
"number of rear port positions ({rearport_count})."
).format(
frontport_count=frontport_count,
rearport_count=rearport_count
)
})
def get_iterative_data(self, iteration): def get_iterative_data(self, iteration):
# Assign rear port and position from selected set # Assign rear port and position from selected set
@ -291,6 +308,22 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
) )
self.fields['rear_port'].choices = choices self.fields['rear_port'].choices = choices
def clean(self):
# Check that the number of FrontPorts to be created matches the selected number of RearPort positions
frontport_count = len(self.cleaned_data['name'])
rearport_count = len(self.cleaned_data['rear_port'])
if frontport_count != rearport_count:
raise forms.ValidationError({
'rear_port': _(
"The number of front ports to be created ({frontport_count}) must match the selected number of "
"rear port positions ({rearport_count})."
).format(
frontport_count=frontport_count,
rearport_count=rearport_count
)
})
def get_iterative_data(self, iteration): def get_iterative_data(self, iteration):
# Assign rear port and position from selected set # Assign rear port and position from selected set

View File

@ -2,7 +2,6 @@ import itertools
from collections import defaultdict from collections import defaultdict
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import Sum from django.db.models import Sum
@ -10,12 +9,12 @@ from django.dispatch import Signal
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import ContentType
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.fields import PathField from dcim.fields import PathField
from dcim.utils import decompile_path_node, object_to_path_node from dcim.utils import decompile_path_node, object_to_path_node
from netbox.models import ChangeLoggedModel, PrimaryModel from netbox.models import ChangeLoggedModel, PrimaryModel
from utilities.fields import ColorField from utilities.fields import ColorField
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.utils import to_meters from utilities.utils import to_meters
@ -180,6 +179,17 @@ class Cable(PrimaryModel):
if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type): if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type):
raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}") raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}")
if a_type == b_type:
# can't directly use self.a_terminations here as possible they
# don't have pk yet
a_pks = set(obj.pk for obj in self.a_terminations if obj.pk)
b_pks = set(obj.pk for obj in self.b_terminations if obj.pk)
if (a_pks & b_pks):
raise ValidationError(
_("A and B terminations cannot connect to the same object.")
)
# Run clean() on any new CableTerminations # Run clean() on any new CableTerminations
for termination in self.a_terminations: for termination in self.a_terminations:
CableTermination(cable=self, cable_end='A', termination=termination).clean() CableTermination(cable=self, cable_end='A', termination=termination).clean()
@ -247,7 +257,7 @@ class CableTermination(ChangeLoggedModel):
verbose_name=_('end') verbose_name=_('end')
) )
termination_type = models.ForeignKey( termination_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
limit_choices_to=CABLE_TERMINATION_MODELS, limit_choices_to=CABLE_TERMINATION_MODELS,
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='+' related_name='+'

View File

@ -1,5 +1,4 @@
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
@ -709,7 +708,7 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
db_index=True db_index=True
) )
component_type = models.ForeignKey( component_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS, limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS,
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='+', related_name='+',

View File

@ -1,7 +1,6 @@
from functools import cached_property from functools import cached_property
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
@ -1181,7 +1180,7 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
db_index=True db_index=True
) )
component_type = models.ForeignKey( component_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
limit_choices_to=MODULAR_COMPONENT_MODELS, limit_choices_to=MODULAR_COMPONENT_MODELS,
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='+', related_name='+',

View File

@ -10,6 +10,7 @@ class CableIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('type', 'status', 'tenant', 'label', 'description')
@register_search @register_search
@ -21,6 +22,7 @@ class ConsolePortIndex(SearchIndex):
('description', 500), ('description', 500),
('speed', 2000), ('speed', 2000),
) )
display_attrs = ('device', 'label', 'description')
@register_search @register_search
@ -32,6 +34,7 @@ class ConsoleServerPortIndex(SearchIndex):
('description', 500), ('description', 500),
('speed', 2000), ('speed', 2000),
) )
display_attrs = ('device', 'label', 'description')
@register_search @register_search
@ -44,6 +47,9 @@ class DeviceIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = (
'site', 'location', 'rack', 'device_type', 'role', 'tenant', 'platform', 'serial', 'asset_tag', 'description',
)
@register_search @register_search
@ -54,6 +60,7 @@ class DeviceBayIndex(SearchIndex):
('label', 200), ('label', 200),
('description', 500), ('description', 500),
) )
display_attrs = ('device', 'label', 'description')
@register_search @register_search
@ -64,6 +71,7 @@ class DeviceRoleIndex(SearchIndex):
('slug', 110), ('slug', 110),
('description', 500), ('description', 500),
) )
display_attrs = ('description',)
@register_search @register_search
@ -75,6 +83,7 @@ class DeviceTypeIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('manufacturer', 'part_number', 'description')
@register_search @register_search
@ -85,6 +94,7 @@ class FrontPortIndex(SearchIndex):
('label', 200), ('label', 200),
('description', 500), ('description', 500),
) )
display_attrs = ('device', 'label', 'description')
@register_search @register_search
@ -99,6 +109,7 @@ class InterfaceIndex(SearchIndex):
('mtu', 2000), ('mtu', 2000),
('speed', 2000), ('speed', 2000),
) )
display_attrs = ('device', 'label', 'description')
@register_search @register_search
@ -112,6 +123,7 @@ class InventoryItemIndex(SearchIndex):
('description', 500), ('description', 500),
('part_id', 2000), ('part_id', 2000),
) )
display_attrs = ('device', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description')
@register_search @register_search
@ -122,6 +134,7 @@ class LocationIndex(SearchIndex):
('slug', 110), ('slug', 110),
('description', 500), ('description', 500),
) )
display_attrs = ('site', 'status', 'tenant', 'description')
@register_search @register_search
@ -132,6 +145,7 @@ class ManufacturerIndex(SearchIndex):
('slug', 110), ('slug', 110),
('description', 500), ('description', 500),
) )
display_attrs = ('description',)
@register_search @register_search
@ -143,6 +157,7 @@ class ModuleIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag', 'description')
@register_search @register_search
@ -153,6 +168,7 @@ class ModuleBayIndex(SearchIndex):
('label', 200), ('label', 200),
('description', 500), ('description', 500),
) )
display_attrs = ('device', 'label', 'position', 'description')
@register_search @register_search
@ -164,6 +180,7 @@ class ModuleTypeIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('manufacturer', 'model', 'part_number', 'description')
@register_search @register_search
@ -174,6 +191,7 @@ class PlatformIndex(SearchIndex):
('slug', 110), ('slug', 110),
('description', 500), ('description', 500),
) )
display_attrs = ('manufacturer', 'description')
@register_search @register_search
@ -184,6 +202,7 @@ class PowerFeedIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('power_panel', 'rack', 'status', 'description')
@register_search @register_search
@ -194,6 +213,7 @@ class PowerOutletIndex(SearchIndex):
('label', 200), ('label', 200),
('description', 500), ('description', 500),
) )
display_attrs = ('device', 'label', 'description')
@register_search @register_search
@ -204,6 +224,7 @@ class PowerPanelIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('site', 'location', 'description')
@register_search @register_search
@ -216,6 +237,7 @@ class PowerPortIndex(SearchIndex):
('maximum_draw', 2000), ('maximum_draw', 2000),
('allocated_draw', 2000), ('allocated_draw', 2000),
) )
display_attrs = ('device', 'label', 'description')
@register_search @register_search
@ -229,6 +251,7 @@ class RackIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('site', 'location', 'facility_id', 'tenant', 'status', 'role', 'description')
@register_search @register_search
@ -238,6 +261,7 @@ class RackReservationIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('rack', 'tenant', 'user', 'description')
@register_search @register_search
@ -248,6 +272,7 @@ class RackRoleIndex(SearchIndex):
('slug', 110), ('slug', 110),
('description', 500), ('description', 500),
) )
display_attrs = ('device', 'label', 'description',)
@register_search @register_search
@ -258,6 +283,7 @@ class RearPortIndex(SearchIndex):
('label', 200), ('label', 200),
('description', 500), ('description', 500),
) )
display_attrs = ('device', 'label', 'description')
@register_search @register_search
@ -268,6 +294,7 @@ class RegionIndex(SearchIndex):
('slug', 110), ('slug', 110),
('description', 500), ('description', 500),
) )
display_attrs = ('parent', 'description')
@register_search @register_search
@ -282,6 +309,7 @@ class SiteIndex(SearchIndex):
('shipping_address', 2000), ('shipping_address', 2000),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('region', 'group', 'status', 'description')
@register_search @register_search
@ -292,6 +320,7 @@ class SiteGroupIndex(SearchIndex):
('slug', 110), ('slug', 110),
('description', 500), ('description', 500),
) )
display_attrs = ('parent', 'description')
@register_search @register_search
@ -303,6 +332,7 @@ class VirtualChassisIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('master', 'domain', 'description')
@register_search @register_search
@ -314,3 +344,4 @@ class VirtualDeviceContextIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('device', 'status', 'identifier', 'description')

View File

@ -466,6 +466,12 @@ class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable):
'args': [Accessor('device_id')], 'args': [Accessor('device_id')],
} }
) )
maximum_draw = tables.Column(
verbose_name=_('Maximum draw (W)')
)
allocated_draw = tables.Column(
verbose_name=_('Allocated draw (W)')
)
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='dcim:powerport_list' url_name='dcim:powerport_list'
) )
@ -625,6 +631,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
verbose_name=_('VRF'), verbose_name=_('VRF'),
linkify=True linkify=True
) )
inventory_items = tables.ManyToManyColumn(
linkify_item=True,
verbose_name=_('Inventory Items'),
)
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='dcim:interface_list' url_name='dcim:interface_list'
) )
@ -636,7 +646,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
'speed', 'speed_formatted', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel', 'speed', 'speed_formatted', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel',
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable',
'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn',
'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'inventory_items', 'created', 'last_updated',
) )
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
@ -933,6 +943,10 @@ class InventoryItemTable(DeviceComponentTable):
discovered = columns.BooleanColumn( discovered = columns.BooleanColumn(
verbose_name=_('Discovered'), verbose_name=_('Discovered'),
) )
parent = tables.Column(
linkify=True,
verbose_name=_('Parent'),
)
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='dcim:inventoryitem_list' url_name='dcim:inventoryitem_list'
) )
@ -941,7 +955,7 @@ class InventoryItemTable(DeviceComponentTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = models.InventoryItem model = models.InventoryItem
fields = ( fields = (
'pk', 'id', 'name', 'device', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'pk', 'id', 'name', 'device', 'parent', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial',
'asset_tag', 'description', 'discovered', 'tags', 'created', 'last_updated', 'asset_tag', 'description', 'discovered', 'tags', 'created', 'last_updated',
) )
default_columns = ( default_columns = (

View File

@ -87,6 +87,11 @@ class PowerFeedTable(TenancyColumnsMixin, CableTerminationTable):
linkify=True, linkify=True,
verbose_name=_('Tenant') verbose_name=_('Tenant')
) )
site = tables.Column(
accessor='rack__site',
linkify=True,
verbose_name=_('Site'),
)
comments = columns.MarkdownColumn( comments = columns.MarkdownColumn(
verbose_name=_('Comments'), verbose_name=_('Comments'),
) )
@ -97,9 +102,9 @@ class PowerFeedTable(TenancyColumnsMixin, CableTerminationTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = PowerFeed model = PowerFeed
fields = ( fields = (
'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'pk', 'id', 'name', 'power_panel', 'site', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage',
'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'available_power', 'tenant', 'phase', 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'available_power',
'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated', 'tenant', 'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable', 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable',

View File

@ -6,6 +6,7 @@ from rest_framework import status
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models import * from dcim.models import *
from extras.models import ConfigTemplate
from ipam.models import ASN, RIR, VLAN, VRF from ipam.models import ASN, RIR, VLAN, VRF
from netbox.api.serializers import GenericObjectSerializer from netbox.api.serializers import GenericObjectSerializer
from utilities.testing import APITestCase, APIViewTestCases, create_test_device from utilities.testing import APITestCase, APIViewTestCases, create_test_device
@ -1265,6 +1266,22 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
def test_render_config(self):
configtemplate = ConfigTemplate.objects.create(
name='Config Template 1',
template_code='Config for device {{ device.name }}'
)
device = Device.objects.first()
device.config_template = configtemplate
device.save()
self.add_permissions('dcim.add_device')
url = reverse('dcim-api:device-detail', kwargs={'pk': device.pk}) + 'render-config/'
response = self.client.post(url, {}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['content'], f'Config for device {device.name}')
class ModuleTest(APIViewTestCases.APIViewTestCase): class ModuleTest(APIViewTestCases.APIViewTestCase):
model = Module model = Module

View File

@ -4712,12 +4712,18 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
addresses = ( addresses = (
IPAddress(assigned_object=interfaces[0], address='10.1.1.1/24'), IPAddress(assigned_object=interfaces[0], address='10.1.1.1/24'),
IPAddress(assigned_object=interfaces[1], address='10.1.1.2/24'), IPAddress(assigned_object=interfaces[1], address='10.1.1.2/24'),
IPAddress(assigned_object=None, address='10.1.1.3/24'),
IPAddress(assigned_object=interfaces[0], address='2001:db8::1/64'),
IPAddress(assigned_object=interfaces[1], address='2001:db8::2/64'),
IPAddress(assigned_object=None, address='2001:db8::3/64'),
) )
IPAddress.objects.bulk_create(addresses) IPAddress.objects.bulk_create(addresses)
vdcs[0].primary_ip4 = addresses[0] vdcs[0].primary_ip4 = addresses[0]
vdcs[0].primary_ip6 = addresses[3]
vdcs[0].save() vdcs[0].save()
vdcs[1].primary_ip4 = addresses[1] vdcs[1].primary_ip4 = addresses[1]
vdcs[1].primary_ip6 = addresses[4]
vdcs[1].save() vdcs[1].save()
def test_device(self): def test_device(self):
@ -4738,3 +4744,17 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'has_primary_ip': False} params = {'has_primary_ip': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_primary_ip4(self):
addresses = IPAddress.objects.filter(address__family=4)
params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'primary_ip4_id': [addresses[2].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
def test_primary_ip6(self):
addresses = IPAddress.objects.filter(address__family=6)
params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'primary_ip6_id': [addresses[2].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)

View File

@ -2960,6 +2960,25 @@ class InventoryItemBulkDeleteView(generic.BulkDeleteView):
template_name = 'dcim/inventoryitem_bulk_delete.html' template_name = 'dcim/inventoryitem_bulk_delete.html'
@register_model_view(InventoryItem, 'children')
class InventoryItemChildrenView(generic.ObjectChildrenView):
queryset = InventoryItem.objects.all()
child_model = InventoryItem
table = tables.InventoryItemTable
filterset = filtersets.InventoryItemFilterSet
template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('Children'),
badge=lambda obj: obj.child_items.count(),
permission='dcim.view_inventoryitem',
hide_if_empty=True,
weight=5000
)
def get_children(self, request, parent):
return parent.child_items.restrict(request.user, 'view')
# #
# Inventory item roles # Inventory item roles
# #

View File

@ -1,10 +1,16 @@
from jinja2.exceptions import TemplateError from jinja2.exceptions import TemplateError
from rest_framework.decorators import action
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.status import HTTP_400_BAD_REQUEST
from netbox.api.renderers import TextRenderer
from .nested_serializers import NestedConfigTemplateSerializer from .nested_serializers import NestedConfigTemplateSerializer
__all__ = ( __all__ = (
'ConfigContextQuerySetMixin', 'ConfigContextQuerySetMixin',
'ConfigTemplateRenderMixin',
'RenderConfigMixin',
) )
@ -31,7 +37,9 @@ class ConfigContextQuerySetMixin:
class ConfigTemplateRenderMixin: class ConfigTemplateRenderMixin:
"""
Provides a method to return a rendered ConfigTemplate as REST API data.
"""
def render_configtemplate(self, request, configtemplate, context): def render_configtemplate(self, request, configtemplate, context):
try: try:
output = configtemplate.render(context=context) output = configtemplate.render(context=context)
@ -50,3 +58,28 @@ class ConfigTemplateRenderMixin:
'configtemplate': template_serializer.data, 'configtemplate': template_serializer.data,
'content': output 'content': output
}) })
class RenderConfigMixin(ConfigTemplateRenderMixin):
"""
Provides a /render-config/ endpoint for REST API views whose model may have a ConfigTemplate assigned.
"""
@action(detail=True, methods=['post'], url_path='render-config', renderer_classes=[JSONRenderer, TextRenderer])
def render_config(self, request, pk):
"""
Resolve and render the preferred ConfigTemplate for this Device.
"""
instance = self.get_object()
object_type = instance._meta.model_name
configtemplate = instance.get_config_template()
if not configtemplate:
return Response({
'error': f'No config template found for this {object_type}.'
}, status=HTTP_400_BAD_REQUEST)
# Compile context data
context_data = instance.get_config_context()
context_data.update(request.data)
context_data.update({object_type: instance})
return self.render_configtemplate(request, configtemplate, context_data)

View File

@ -1,10 +1,10 @@
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from rest_framework import serializers from rest_framework import serializers
from core.api.serializers import JobSerializer from core.api.serializers import JobSerializer
from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer, NestedJobSerializer from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer, NestedJobSerializer
from core.models import ContentType
from dcim.api.nested_serializers import ( from dcim.api.nested_serializers import (
NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer, NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer,
NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer, NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
@ -14,7 +14,6 @@ from drf_spectacular.utils import extend_schema_field
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from extras.choices import * from extras.choices import *
from extras.models import * from extras.models import *
from extras.utils import FeatureQuery
from netbox.api.exceptions import SerializerNotFound from netbox.api.exceptions import SerializerNotFound
from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer
@ -64,7 +63,7 @@ __all__ = (
class WebhookSerializer(NetBoxModelSerializer): class WebhookSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail') url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail')
content_types = ContentTypeField( content_types = ContentTypeField(
queryset=ContentType.objects.filter(FeatureQuery('webhooks').get_query()), queryset=ContentType.objects.with_feature('webhooks'),
many=True many=True
) )
@ -85,7 +84,7 @@ class WebhookSerializer(NetBoxModelSerializer):
class CustomFieldSerializer(ValidatedModelSerializer): class CustomFieldSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail') url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail')
content_types = ContentTypeField( content_types = ContentTypeField(
queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()), queryset=ContentType.objects.with_feature('custom_fields'),
many=True many=True
) )
type = ChoiceField(choices=CustomFieldTypeChoices) type = ChoiceField(choices=CustomFieldTypeChoices)
@ -151,7 +150,7 @@ class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
class CustomLinkSerializer(ValidatedModelSerializer): class CustomLinkSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail') url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')
content_types = ContentTypeField( content_types = ContentTypeField(
queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()), queryset=ContentType.objects.with_feature('custom_links'),
many=True many=True
) )
@ -170,7 +169,7 @@ class CustomLinkSerializer(ValidatedModelSerializer):
class ExportTemplateSerializer(ValidatedModelSerializer): class ExportTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail') url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail')
content_types = ContentTypeField( content_types = ContentTypeField(
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()), queryset=ContentType.objects.with_feature('export_templates'),
many=True many=True
) )
data_source = NestedDataSourceSerializer( data_source = NestedDataSourceSerializer(
@ -215,7 +214,7 @@ class SavedFilterSerializer(ValidatedModelSerializer):
class BookmarkSerializer(ValidatedModelSerializer): class BookmarkSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail') url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail')
object_type = ContentTypeField( object_type = ContentTypeField(
queryset=ContentType.objects.filter(FeatureQuery('bookmarks').get_query()), queryset=ContentType.objects.with_feature('bookmarks'),
) )
object = serializers.SerializerMethodField(read_only=True) object = serializers.SerializerMethodField(read_only=True)
user = NestedUserSerializer() user = NestedUserSerializer()
@ -239,7 +238,7 @@ class BookmarkSerializer(ValidatedModelSerializer):
class TagSerializer(ValidatedModelSerializer): class TagSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail') url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
object_types = ContentTypeField( object_types = ContentTypeField(
queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()), queryset=ContentType.objects.with_feature('tags'),
many=True, many=True,
required=False required=False
) )

View File

@ -32,13 +32,20 @@ __all__ = (
) )
def get_content_type_labels(): def get_object_type_choices():
return [ return [
(content_type_identifier(ct), content_type_name(ct)) (content_type_identifier(ct), content_type_name(ct))
for ct in ContentType.objects.public().order_by('app_label', 'model') for ct in ContentType.objects.public().order_by('app_label', 'model')
] ]
def get_bookmarks_object_type_choices():
return [
(content_type_identifier(ct), content_type_name(ct))
for ct in ContentType.objects.with_feature('bookmarks').order_by('app_label', 'model')
]
def get_models_from_content_types(content_types): def get_models_from_content_types(content_types):
""" """
Return a list of models corresponding to the given content types, identified by natural key. Return a list of models corresponding to the given content types, identified by natural key.
@ -158,7 +165,7 @@ class ObjectCountsWidget(DashboardWidget):
class ConfigForm(WidgetConfigForm): class ConfigForm(WidgetConfigForm):
models = forms.MultipleChoiceField( models = forms.MultipleChoiceField(
choices=get_content_type_labels choices=get_object_type_choices
) )
filters = forms.JSONField( filters = forms.JSONField(
required=False, required=False,
@ -207,7 +214,7 @@ class ObjectListWidget(DashboardWidget):
class ConfigForm(WidgetConfigForm): class ConfigForm(WidgetConfigForm):
model = forms.ChoiceField( model = forms.ChoiceField(
choices=get_content_type_labels choices=get_object_type_choices
) )
page_size = forms.IntegerField( page_size = forms.IntegerField(
required=False, required=False,
@ -343,8 +350,7 @@ class BookmarksWidget(DashboardWidget):
class ConfigForm(WidgetConfigForm): class ConfigForm(WidgetConfigForm):
object_types = forms.MultipleChoiceField( object_types = forms.MultipleChoiceField(
# TODO: Restrict the choices by FeatureQuery('bookmarks') choices=get_bookmarks_object_type_choices,
choices=get_content_type_labels,
required=False required=False
) )
order_by = forms.ChoiceField( order_by = forms.ChoiceField(

View File

@ -6,7 +6,6 @@ from django.utils.translation import gettext_lazy as _
from core.models import ContentType from core.models import ContentType
from extras.choices import * from extras.choices import *
from extras.models import * from extras.models import *
from extras.utils import FeatureQuery
from netbox.forms import NetBoxModelImportForm from netbox.forms import NetBoxModelImportForm
from utilities.forms import CSVModelForm from utilities.forms import CSVModelForm
from utilities.forms.fields import ( from utilities.forms.fields import (
@ -29,8 +28,7 @@ __all__ = (
class CustomFieldImportForm(CSVModelForm): class CustomFieldImportForm(CSVModelForm):
content_types = CSVMultipleContentTypeField( content_types = CSVMultipleContentTypeField(
label=_('Content types'), label=_('Content types'),
queryset=ContentType.objects.all(), queryset=ContentType.objects.with_feature('custom_fields'),
limit_choices_to=FeatureQuery('custom_fields'),
help_text=_("One or more assigned object types") help_text=_("One or more assigned object types")
) )
type = CSVChoiceField( type = CSVChoiceField(
@ -88,8 +86,7 @@ class CustomFieldChoiceSetImportForm(CSVModelForm):
class CustomLinkImportForm(CSVModelForm): class CustomLinkImportForm(CSVModelForm):
content_types = CSVMultipleContentTypeField( content_types = CSVMultipleContentTypeField(
label=_('Content types'), label=_('Content types'),
queryset=ContentType.objects.all(), queryset=ContentType.objects.with_feature('custom_links'),
limit_choices_to=FeatureQuery('custom_links'),
help_text=_("One or more assigned object types") help_text=_("One or more assigned object types")
) )
@ -104,8 +101,7 @@ class CustomLinkImportForm(CSVModelForm):
class ExportTemplateImportForm(CSVModelForm): class ExportTemplateImportForm(CSVModelForm):
content_types = CSVMultipleContentTypeField( content_types = CSVMultipleContentTypeField(
label=_('Content types'), label=_('Content types'),
queryset=ContentType.objects.all(), queryset=ContentType.objects.with_feature('export_templates'),
limit_choices_to=FeatureQuery('export_templates'),
help_text=_("One or more assigned object types") help_text=_("One or more assigned object types")
) )
@ -142,8 +138,7 @@ class SavedFilterImportForm(CSVModelForm):
class WebhookImportForm(NetBoxModelImportForm): class WebhookImportForm(NetBoxModelImportForm):
content_types = CSVMultipleContentTypeField( content_types = CSVMultipleContentTypeField(
label=_('Content types'), label=_('Content types'),
queryset=ContentType.objects.all(), queryset=ContentType.objects.with_feature('webhooks'),
limit_choices_to=FeatureQuery('webhooks'),
help_text=_("One or more assigned object types") help_text=_("One or more assigned object types")
) )

View File

@ -6,7 +6,6 @@ from core.models import ContentType, DataFile, DataSource
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
from extras.choices import * from extras.choices import *
from extras.models import * from extras.models import *
from extras.utils import FeatureQuery
from netbox.forms.base import NetBoxModelFilterSetForm from netbox.forms.base import NetBoxModelFilterSetForm
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
@ -44,7 +43,7 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
)), )),
) )
content_type_id = ContentTypeMultipleChoiceField( content_type_id = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()), queryset=ContentType.objects.with_feature('custom_fields'),
required=False, required=False,
label=_('Object type') label=_('Object type')
) )
@ -108,7 +107,7 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
) )
content_types = ContentTypeMultipleChoiceField( content_types = ContentTypeMultipleChoiceField(
label=_('Content types'), label=_('Content types'),
queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()), queryset=ContentType.objects.with_feature('custom_links'),
required=False required=False
) )
enabled = forms.NullBooleanField( enabled = forms.NullBooleanField(
@ -151,7 +150,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
} }
) )
content_type_id = ContentTypeMultipleChoiceField( content_type_id = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()), queryset=ContentType.objects.with_feature('export_templates'),
required=False, required=False,
label=_('Content types') label=_('Content types')
) )
@ -179,7 +178,7 @@ class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
) )
content_type_id = ContentTypeChoiceField( content_type_id = ContentTypeChoiceField(
label=_('Content type'), label=_('Content type'),
queryset=ContentType.objects.filter(FeatureQuery('image_attachments').get_query()), queryset=ContentType.objects.with_feature('image_attachments'),
required=False required=False
) )
name = forms.CharField( name = forms.CharField(
@ -228,7 +227,7 @@ class WebhookFilterForm(NetBoxModelFilterSetForm):
(_('Events'), ('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end')), (_('Events'), ('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end')),
) )
content_type_id = ContentTypeMultipleChoiceField( content_type_id = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.filter(FeatureQuery('webhooks').get_query()), queryset=ContentType.objects.with_feature('webhooks'),
required=False, required=False,
label=_('Object type') label=_('Object type')
) )
@ -284,12 +283,12 @@ class WebhookFilterForm(NetBoxModelFilterSetForm):
class TagFilterForm(SavedFiltersMixin, FilterForm): class TagFilterForm(SavedFiltersMixin, FilterForm):
model = Tag model = Tag
content_type_id = ContentTypeMultipleChoiceField( content_type_id = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()), queryset=ContentType.objects.with_feature('tags'),
required=False, required=False,
label=_('Tagged object type') label=_('Tagged object type')
) )
for_object_type_id = ContentTypeChoiceField( for_object_type_id = ContentTypeChoiceField(
queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()), queryset=ContentType.objects.with_feature('tags'),
required=False, required=False,
label=_('Allowed object type') label=_('Allowed object type')
) )

View File

@ -10,7 +10,6 @@ from core.models import ContentType
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
from extras.choices import * from extras.choices import *
from extras.models import * from extras.models import *
from extras.utils import FeatureQuery
from netbox.config import get_config, PARAMS from netbox.config import get_config, PARAMS
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
@ -43,8 +42,7 @@ __all__ = (
class CustomFieldForm(BootstrapMixin, forms.ModelForm): class CustomFieldForm(BootstrapMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField( content_types = ContentTypeMultipleChoiceField(
label=_('Content types'), label=_('Content types'),
queryset=ContentType.objects.all(), queryset=ContentType.objects.with_feature('custom_fields')
limit_choices_to=FeatureQuery('custom_fields'),
) )
object_type = ContentTypeChoiceField( object_type = ContentTypeChoiceField(
label=_('Object type'), label=_('Object type'),
@ -114,8 +112,7 @@ class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm):
class CustomLinkForm(BootstrapMixin, forms.ModelForm): class CustomLinkForm(BootstrapMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField( content_types = ContentTypeMultipleChoiceField(
label=_('Content types'), label=_('Content types'),
queryset=ContentType.objects.all(), queryset=ContentType.objects.with_feature('custom_links')
limit_choices_to=FeatureQuery('custom_links')
) )
fieldsets = ( fieldsets = (
@ -142,8 +139,7 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
class ExportTemplateForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm): class ExportTemplateForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField( content_types = ContentTypeMultipleChoiceField(
label=_('Content types'), label=_('Content types'),
queryset=ContentType.objects.all(), queryset=ContentType.objects.with_feature('export_templates')
limit_choices_to=FeatureQuery('export_templates')
) )
template_code = forms.CharField( template_code = forms.CharField(
label=_('Template code'), label=_('Template code'),
@ -210,8 +206,7 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm):
class BookmarkForm(BootstrapMixin, forms.ModelForm): class BookmarkForm(BootstrapMixin, forms.ModelForm):
object_type = ContentTypeChoiceField( object_type = ContentTypeChoiceField(
label=_('Object type'), label=_('Object type'),
queryset=ContentType.objects.all(), queryset=ContentType.objects.with_feature('bookmarks')
limit_choices_to=FeatureQuery('bookmarks').get_query()
) )
class Meta: class Meta:
@ -222,8 +217,7 @@ class BookmarkForm(BootstrapMixin, forms.ModelForm):
class WebhookForm(NetBoxModelForm): class WebhookForm(NetBoxModelForm):
content_types = ContentTypeMultipleChoiceField( content_types = ContentTypeMultipleChoiceField(
label=_('Content types'), label=_('Content types'),
queryset=ContentType.objects.all(), queryset=ContentType.objects.with_feature('webhooks')
limit_choices_to=FeatureQuery('webhooks')
) )
fieldsets = ( fieldsets = (
@ -257,8 +251,7 @@ class TagForm(BootstrapMixin, forms.ModelForm):
slug = SlugField() slug = SlugField()
object_types = ContentTypeMultipleChoiceField( object_types = ContentTypeMultipleChoiceField(
label=_('Object types'), label=_('Object types'),
queryset=ContentType.objects.all(), queryset=ContentType.objects.with_feature('tags'),
limit_choices_to=FeatureQuery('tags'),
required=False required=False
) )

View File

@ -88,7 +88,7 @@ class Migration(migrations.Migration):
('secret', models.CharField(blank=True, max_length=255)), ('secret', models.CharField(blank=True, max_length=255)),
('ssl_verification', models.BooleanField(default=True)), ('ssl_verification', models.BooleanField(default=True)),
('ca_file_path', models.CharField(blank=True, max_length=4096, null=True)), ('ca_file_path', models.CharField(blank=True, max_length=4096, null=True)),
('content_types', models.ManyToManyField(limit_choices_to=extras.utils.FeatureQuery('webhooks'), related_name='webhooks', to='contenttypes.ContentType')), ('content_types', models.ManyToManyField(related_name='webhooks', to='contenttypes.ContentType')),
], ],
options={ options={
'ordering': ('name',), 'ordering': ('name',),
@ -151,7 +151,7 @@ class Migration(migrations.Migration):
('status', models.CharField(default='pending', max_length=30)), ('status', models.CharField(default='pending', max_length=30)),
('data', models.JSONField(blank=True, null=True)), ('data', models.JSONField(blank=True, null=True)),
('job_id', models.UUIDField(unique=True)), ('job_id', models.UUIDField(unique=True)),
('obj_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('jobs'), on_delete=django.db.models.deletion.CASCADE, related_name='job_results', to='contenttypes.contenttype')), ('obj_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='job_results', to='contenttypes.contenttype')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
], ],
options={ options={
@ -184,7 +184,7 @@ class Migration(migrations.Migration):
('mime_type', models.CharField(blank=True, max_length=50)), ('mime_type', models.CharField(blank=True, max_length=50)),
('file_extension', models.CharField(blank=True, max_length=15)), ('file_extension', models.CharField(blank=True, max_length=15)),
('as_attachment', models.BooleanField(default=True)), ('as_attachment', models.BooleanField(default=True)),
('content_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('export_templates'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
], ],
options={ options={
'ordering': ['content_type', 'name'], 'ordering': ['content_type', 'name'],
@ -201,7 +201,7 @@ class Migration(migrations.Migration):
('group_name', models.CharField(blank=True, max_length=50)), ('group_name', models.CharField(blank=True, max_length=50)),
('button_class', models.CharField(default='default', max_length=30)), ('button_class', models.CharField(default='default', max_length=30)),
('new_window', models.BooleanField(default=False)), ('new_window', models.BooleanField(default=False)),
('content_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('custom_links'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
], ],
options={ options={
'ordering': ['group_name', 'weight', 'name'], 'ordering': ['group_name', 'weight', 'name'],
@ -223,7 +223,7 @@ class Migration(migrations.Migration):
('validation_maximum', models.PositiveIntegerField(blank=True, null=True)), ('validation_maximum', models.PositiveIntegerField(blank=True, null=True)),
('validation_regex', models.CharField(blank=True, max_length=500, validators=[utilities.validators.validate_regex])), ('validation_regex', models.CharField(blank=True, max_length=500, validators=[utilities.validators.validate_regex])),
('choices', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), blank=True, null=True, size=None)), ('choices', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), blank=True, null=True, size=None)),
('content_types', models.ManyToManyField(limit_choices_to=extras.utils.FeatureQuery('custom_fields'), related_name='custom_fields', to='contenttypes.ContentType')), ('content_types', models.ManyToManyField(related_name='custom_fields', to='contenttypes.ContentType')),
], ],
options={ options={
'ordering': ['weight', 'name'], 'ordering': ['weight', 'name'],

View File

@ -1,5 +1,4 @@
from django.db import migrations, models from django.db import migrations, models
import extras.utils
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -13,7 +12,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='tag', model_name='tag',
name='object_types', name='object_types',
field=models.ManyToManyField(blank=True, limit_choices_to=extras.utils.FeatureQuery('tags'), related_name='+', to='contenttypes.contenttype'), field=models.ManyToManyField(blank=True, related_name='+', to='contenttypes.contenttype'),
), ),
migrations.RenameIndex( migrations.RenameIndex(
model_name='taggeditem', model_name='taggeditem',

View File

@ -1,10 +1,11 @@
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import ContentType
from extras.choices import * from extras.choices import *
from ..querysets import ObjectChangeQuerySet from ..querysets import ObjectChangeQuerySet
@ -48,7 +49,7 @@ class ObjectChange(models.Model):
choices=ObjectChangeActionChoices choices=ObjectChangeActionChoices
) )
changed_object_type = models.ForeignKey( changed_object_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='+' related_name='+'
) )
@ -58,7 +59,7 @@ class ObjectChange(models.Model):
fk_field='changed_object_id' fk_field='changed_object_id'
) )
related_object_type = models.ForeignKey( related_object_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='+', related_name='+',
blank=True, blank=True,
@ -104,6 +105,17 @@ class ObjectChange(models.Model):
self.user_name self.user_name
) )
def clean(self):
super().clean()
# Validate the assigned object type
if self.changed_object_type not in ContentType.objects.with_feature('change_logging'):
raise ValidationError(
_("Change logging is not supported for this object type ({type}).").format(
type=self.changed_object_type
)
)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# Record the user's name and the object's representation as static strings # Record the user's name and the object's representation as static strings

View File

@ -5,18 +5,16 @@ from datetime import datetime, date
import django_filters import django_filters
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.core.validators import RegexValidator, ValidationError from django.core.validators import RegexValidator, ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import ContentType
from extras.choices import * from extras.choices import *
from extras.data import CHOICE_SETS from extras.data import CHOICE_SETS
from extras.utils import FeatureQuery
from netbox.models import ChangeLoggedModel from netbox.models import ChangeLoggedModel
from netbox.models.features import CloningMixin, ExportTemplatesMixin from netbox.models.features import CloningMixin, ExportTemplatesMixin
from netbox.search import FieldTypes from netbox.search import FieldTypes
@ -60,9 +58,8 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
content_types = models.ManyToManyField( content_types = models.ManyToManyField(
to=ContentType, to='contenttypes.ContentType',
related_name='custom_fields', related_name='custom_fields',
limit_choices_to=FeatureQuery('custom_fields'),
help_text=_('The object(s) to which this field applies.') help_text=_('The object(s) to which this field applies.')
) )
type = models.CharField( type = models.CharField(
@ -73,7 +70,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
help_text=_('The type of data this custom field holds') help_text=_('The type of data this custom field holds')
) )
object_type = models.ForeignKey( object_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
on_delete=models.PROTECT, on_delete=models.PROTECT,
blank=True, blank=True,
null=True, null=True,

View File

@ -3,7 +3,6 @@ import urllib.parse
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache from django.core.cache import cache
from django.core.validators import ValidationError from django.core.validators import ValidationError
from django.db import models from django.db import models
@ -14,10 +13,11 @@ from django.utils.formats import date_format
from django.utils.translation import gettext, gettext_lazy as _ from django.utils.translation import gettext, gettext_lazy as _
from rest_framework.utils.encoders import JSONEncoder from rest_framework.utils.encoders import JSONEncoder
from core.models import ContentType
from extras.choices import * from extras.choices import *
from extras.conditions import ConditionSet from extras.conditions import ConditionSet
from extras.constants import * from extras.constants import *
from extras.utils import FeatureQuery, image_upload from extras.utils import image_upload
from netbox.config import get_config from netbox.config import get_config
from netbox.models import ChangeLoggedModel from netbox.models import ChangeLoggedModel
from netbox.models.features import ( from netbox.models.features import (
@ -45,10 +45,9 @@ class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedMo
Each Webhook can be limited to firing only on certain actions or certain object types. Each Webhook can be limited to firing only on certain actions or certain object types.
""" """
content_types = models.ManyToManyField( content_types = models.ManyToManyField(
to=ContentType, to='contenttypes.ContentType',
related_name='webhooks', related_name='webhooks',
verbose_name=_('object types'), verbose_name=_('object types'),
limit_choices_to=FeatureQuery('webhooks'),
help_text=_("The object(s) to which this Webhook applies.") help_text=_("The object(s) to which this Webhook applies.")
) )
name = models.CharField( name = models.CharField(
@ -235,7 +234,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
code to be rendered with an object as context. code to be rendered with an object as context.
""" """
content_types = models.ManyToManyField( content_types = models.ManyToManyField(
to=ContentType, to='contenttypes.ContentType',
related_name='custom_links', related_name='custom_links',
help_text=_('The object type(s) to which this link applies.') help_text=_('The object type(s) to which this link applies.')
) )
@ -331,7 +330,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
content_types = models.ManyToManyField( content_types = models.ManyToManyField(
to=ContentType, to='contenttypes.ContentType',
related_name='export_templates', related_name='export_templates',
help_text=_('The object type(s) to which this template applies.') help_text=_('The object type(s) to which this template applies.')
) )
@ -440,7 +439,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
A set of predefined keyword parameters that can be reused to filter for specific objects. A set of predefined keyword parameters that can be reused to filter for specific objects.
""" """
content_types = models.ManyToManyField( content_types = models.ManyToManyField(
to=ContentType, to='contenttypes.ContentType',
related_name='saved_filters', related_name='saved_filters',
help_text=_('The object type(s) to which this filter applies.') help_text=_('The object type(s) to which this filter applies.')
) )
@ -520,7 +519,7 @@ class ImageAttachment(ChangeLoggedModel):
An uploaded image which is associated with an object. An uploaded image which is associated with an object.
""" """
content_type = models.ForeignKey( content_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
on_delete=models.CASCADE on_delete=models.CASCADE
) )
object_id = models.PositiveBigIntegerField() object_id = models.PositiveBigIntegerField()
@ -560,6 +559,15 @@ class ImageAttachment(ChangeLoggedModel):
filename = self.image.name.rsplit('/', 1)[-1] filename = self.image.name.rsplit('/', 1)[-1]
return filename.split('_', 2)[2] return filename.split('_', 2)[2]
def clean(self):
super().clean()
# Validate the assigned object type
if self.content_type not in ContentType.objects.with_feature('image_attachments'):
raise ValidationError(
_("Image attachments cannot be assigned to this object type ({type}).").format(type=self.content_type)
)
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
_name = self.image.name _name = self.image.name
@ -605,7 +613,7 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat
might record a new journal entry when a device undergoes maintenance, or when a prefix is expanded. might record a new journal entry when a device undergoes maintenance, or when a prefix is expanded.
""" """
assigned_object_type = models.ForeignKey( assigned_object_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
on_delete=models.CASCADE on_delete=models.CASCADE
) )
assigned_object_id = models.PositiveBigIntegerField() assigned_object_id = models.PositiveBigIntegerField()
@ -644,9 +652,8 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat
def clean(self): def clean(self):
super().clean() super().clean()
# Prevent the creation of journal entries on unsupported models # Validate the assigned object type
permitted_types = ContentType.objects.filter(FeatureQuery('journaling').get_query()) if self.assigned_object_type not in ContentType.objects.with_feature('journaling'):
if self.assigned_object_type not in permitted_types:
raise ValidationError( raise ValidationError(
_("Journaling is not supported for this object type ({type}).").format(type=self.assigned_object_type) _("Journaling is not supported for this object type ({type}).").format(type=self.assigned_object_type)
) )
@ -664,7 +671,7 @@ class Bookmark(models.Model):
auto_now_add=True auto_now_add=True
) )
object_type = models.ForeignKey( object_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
on_delete=models.PROTECT on_delete=models.PROTECT
) )
object_id = models.PositiveBigIntegerField() object_id = models.PositiveBigIntegerField()
@ -695,6 +702,15 @@ class Bookmark(models.Model):
return str(self.object) return str(self.object)
return super().__str__() return super().__str__()
def clean(self):
super().clean()
# Validate the assigned object type
if self.object_type not in ContentType.objects.with_feature('bookmarks'):
raise ValidationError(
_("Bookmarks cannot be assigned to this object type ({type}).").format(type=self.object_type)
)
class ConfigRevision(models.Model): class ConfigRevision(models.Model):
""" """

View File

@ -1,10 +1,12 @@
import uuid import uuid
from django.contrib.contenttypes.models import ContentType
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from netbox.search.utils import get_indexer
from netbox.registry import registry
from utilities.fields import RestrictedGenericForeignKey from utilities.fields import RestrictedGenericForeignKey
from utilities.utils import content_type_identifier
from ..fields import CachedValueField from ..fields import CachedValueField
__all__ = ( __all__ = (
@ -24,7 +26,7 @@ class CachedValue(models.Model):
editable=False editable=False
) )
object_type = models.ForeignKey( object_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='+' related_name='+'
) )
@ -58,3 +60,19 @@ class CachedValue(models.Model):
def __str__(self): def __str__(self):
return f'{self.object_type} {self.object_id}: {self.field}={self.value}' return f'{self.object_type} {self.object_id}: {self.field}={self.value}'
@property
def display_attrs(self):
"""
Render any display attributes associated with this search result.
"""
indexer = get_indexer(self.object_type)
attrs = {}
for attr in indexer.display_attrs:
name = self.object._meta.get_field(attr).verbose_name
if value := getattr(self.object, attr):
if display_func := getattr(self.object, f'get_{attr}_display', None):
attrs[name] = display_func()
else:
attrs[name] = value
return attrs

View File

@ -2,7 +2,6 @@ import logging
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models, transaction from django.db import models, transaction
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -71,7 +70,7 @@ class StagedChange(ChangeLoggedModel):
choices=ChangeActionChoices choices=ChangeActionChoices
) )
object_type = models.ForeignKey( object_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='+' related_name='+'
) )

View File

@ -1,13 +1,10 @@
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from taggit.models import TagBase, GenericTaggedItemBase from taggit.models import TagBase, GenericTaggedItemBase
from extras.utils import FeatureQuery
from netbox.models import ChangeLoggedModel from netbox.models import ChangeLoggedModel
from netbox.models.features import CloningMixin, ExportTemplatesMixin from netbox.models.features import CloningMixin, ExportTemplatesMixin
from utilities.choices import ColorChoices from utilities.choices import ColorChoices
@ -37,9 +34,8 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase):
blank=True, blank=True,
) )
object_types = models.ManyToManyField( object_types = models.ManyToManyField(
to=ContentType, to='contenttypes.ContentType',
related_name='+', related_name='+',
limit_choices_to=FeatureQuery('tags'),
blank=True, blank=True,
help_text=_("The object type(s) to which this this tag can be applied.") help_text=_("The object type(s) to which this this tag can be applied.")
) )

View File

@ -457,7 +457,7 @@ class ConfigContextTestCase(
'platforms': [], 'platforms': [],
'tenant_groups': [], 'tenant_groups': [],
'tenants': [], 'tenants': [],
'device_types': [devicetype.id,], 'device_types': [devicetype.id],
'tags': [], 'tags': [],
'data': '{"foo": 123}', 'data': '{"foo": 123}',
} }

View File

@ -1,5 +1,3 @@
from django.db.models import Q
from django.utils.deconstruct import deconstructible
from taggit.managers import _TaggableManager from taggit.managers import _TaggableManager
from netbox.registry import registry from netbox.registry import registry
@ -31,29 +29,6 @@ def image_upload(instance, filename):
return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename) return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename)
@deconstructible
class FeatureQuery:
"""
Helper class that delays evaluation of the registry contents for the functionality store
until it has been populated.
"""
def __init__(self, feature):
self.feature = feature
def __call__(self):
return self.get_query()
def get_query(self):
"""
Given an extras feature, return a Q object for content type lookup
"""
query = Q()
for app_label, models in registry['model_features'][self.feature].items():
query |= Q(app_label=app_label, model__in=models)
return query
def register_features(model, features): def register_features(model, features):
""" """
Register model features in the application registry. Register model features in the application registry.

View File

@ -29,6 +29,7 @@ __all__ = (
'L2VPNFilterSet', 'L2VPNFilterSet',
'L2VPNTerminationFilterSet', 'L2VPNTerminationFilterSet',
'PrefixFilterSet', 'PrefixFilterSet',
'PrimaryIPFilterSet',
'RIRFilterSet', 'RIRFilterSet',
'RoleFilterSet', 'RoleFilterSet',
'RouteTargetFilterSet', 'RouteTargetFilterSet',
@ -266,7 +267,8 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
) )
mask_length = MultiValueNumberFilter( mask_length = MultiValueNumberFilter(
field_name='prefix', field_name='prefix',
lookup_expr='net_mask_length' lookup_expr='net_mask_length',
label=_('Mask length')
) )
mask_length__gte = django_filters.NumberFilter( mask_length__gte = django_filters.NumberFilter(
field_name='prefix', field_name='prefix',
@ -531,9 +533,18 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
method='filter_address', method='filter_address',
label=_('Address'), label=_('Address'),
) )
mask_length = django_filters.NumberFilter( mask_length = MultiValueNumberFilter(
method='filter_mask_length', field_name='address',
label=_('Mask length'), lookup_expr='net_mask_length',
label=_('Mask length')
)
mask_length__gte = django_filters.NumberFilter(
field_name='address',
lookup_expr='net_mask_length__gte'
)
mask_length__lte = django_filters.NumberFilter(
field_name='address',
lookup_expr='net_mask_length__lte'
) )
vrf_id = django_filters.ModelMultipleChoiceFilter( vrf_id = django_filters.ModelMultipleChoiceFilter(
queryset=VRF.objects.all(), queryset=VRF.objects.all(),
@ -677,11 +688,6 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
except ValidationError: except ValidationError:
return queryset.none() return queryset.none()
def filter_mask_length(self, queryset, name, value):
if not value:
return queryset
return queryset.filter(address__net_mask_length=value)
@extend_schema_field(OpenApiTypes.STR) @extend_schema_field(OpenApiTypes.STR)
def filter_present_in_vrf(self, queryset, name, vrf): def filter_present_in_vrf(self, queryset, name, vrf):
if vrf is None: if vrf is None:
@ -1227,3 +1233,19 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet):
) )
) )
return qs return qs
class PrimaryIPFilterSet(django_filters.FilterSet):
"""
An inheritable FilterSet for models which support primary IP assignment.
"""
primary_ip4_id = django_filters.ModelMultipleChoiceFilter(
field_name='primary_ip4',
queryset=IPAddress.objects.all(),
label=_('Primary IPv4 (ID)'),
)
primary_ip6_id = django_filters.ModelMultipleChoiceFilter(
field_name='primary_ip6',
queryset=IPAddress.objects.all(),
label=_('Primary IPv6 (ID)'),
)

View File

@ -507,10 +507,28 @@ class ServiceImportForm(NetBoxModelImportForm):
choices=ServiceProtocolChoices, choices=ServiceProtocolChoices,
help_text=_('IP protocol') help_text=_('IP protocol')
) )
ipaddresses = CSVModelMultipleChoiceField(
queryset=IPAddress.objects.all(),
required=False,
to_field_name='address',
help_text=_('IP Address'),
)
class Meta: class Meta:
model = Service model = Service
fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description', 'comments', 'tags') fields = (
'device', 'virtual_machine', 'ipaddresses', 'name', 'protocol', 'ports', 'description', 'comments', 'tags',
)
def clean_ipaddresses(self):
parent = self.cleaned_data.get('device') or self.cleaned_data.get('virtual_machine')
for ip_address in self.cleaned_data['ipaddresses']:
if not ip_address.assigned_object or getattr(ip_address.assigned_object, 'parent_object') != parent:
raise forms.ValidationError(
_("{ip} is not assigned to this device/VM.").format(ip=ip_address)
)
return self.cleaned_data['ipaddresses']
class L2VPNImportForm(NetBoxModelImportForm): class L2VPNImportForm(NetBoxModelImportForm):

View File

@ -523,6 +523,21 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
class ServiceFilterForm(ServiceTemplateFilterForm): class ServiceFilterForm(ServiceTemplateFilterForm):
model = Service model = Service
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
(_('Attributes'), ('protocol', 'port')),
(_('Assignment'), ('device_id', 'virtual_machine_id')),
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
label=_('Device'),
)
virtual_machine_id = DynamicModelMultipleChoiceField(
queryset=VirtualMachine.objects.all(),
required=False,
label=_('Virtual Machine'),
)
tag = TagFilterField(model) tag = TagFilterField(model)

View File

@ -1,13 +1,12 @@
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from netbox.models import ChangeLoggedModel, PrimaryModel
from ipam.choices import * from ipam.choices import *
from ipam.constants import * from ipam.constants import *
from netbox.models import ChangeLoggedModel, PrimaryModel
__all__ = ( __all__ = (
'FHRPGroup', 'FHRPGroup',
@ -78,7 +77,7 @@ class FHRPGroup(PrimaryModel):
class FHRPGroupAssignment(ChangeLoggedModel): class FHRPGroupAssignment(ChangeLoggedModel):
interface_type = models.ForeignKey( interface_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
on_delete=models.CASCADE on_delete=models.CASCADE
) )
interface_id = models.PositiveBigIntegerField() interface_id = models.PositiveBigIntegerField()

View File

@ -1,6 +1,5 @@
import netaddr import netaddr
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import F from django.db.models import F
@ -9,6 +8,7 @@ from django.urls import reverse
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import ContentType
from ipam.choices import * from ipam.choices import *
from ipam.constants import * from ipam.constants import *
from ipam.fields import IPNetworkField, IPAddressField from ipam.fields import IPNetworkField, IPAddressField
@ -740,7 +740,7 @@ class IPAddress(PrimaryModel):
help_text=_('The functional role of this IP') help_text=_('The functional role of this IP')
) )
assigned_object_type = models.ForeignKey( assigned_object_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
limit_choices_to=IPADDRESS_ASSIGNMENT_MODELS, limit_choices_to=IPADDRESS_ASSIGNMENT_MODELS,
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='+', related_name='+',

View File

@ -1,11 +1,11 @@
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import ContentType
from ipam.choices import L2VPNTypeChoices from ipam.choices import L2VPNTypeChoices
from ipam.constants import L2VPN_ASSIGNMENT_MODELS from ipam.constants import L2VPN_ASSIGNMENT_MODELS
from netbox.models import NetBoxModel, PrimaryModel from netbox.models import NetBoxModel, PrimaryModel
@ -86,7 +86,7 @@ class L2VPNTermination(NetBoxModel):
related_name='terminations' related_name='terminations'
) )
assigned_object_type = models.ForeignKey( assigned_object_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
limit_choices_to=L2VPN_ASSIGNMENT_MODELS, limit_choices_to=L2VPN_ASSIGNMENT_MODELS,
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='+' related_name='+'

View File

@ -1,5 +1,4 @@
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
@ -32,7 +31,7 @@ class VLANGroup(OrganizationalModel):
max_length=100 max_length=100
) )
scope_type = models.ForeignKey( scope_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
on_delete=models.CASCADE, on_delete=models.CASCADE,
limit_choices_to=Q(model__in=VLANGROUP_SCOPE_TYPES), limit_choices_to=Q(model__in=VLANGROUP_SCOPE_TYPES),
blank=True, blank=True,

View File

@ -11,6 +11,7 @@ class AggregateIndex(SearchIndex):
('date_added', 2000), ('date_added', 2000),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('rir', 'tenant', 'description')
@register_search @register_search
@ -20,6 +21,7 @@ class ASNIndex(SearchIndex):
('asn', 100), ('asn', 100),
('description', 500), ('description', 500),
) )
display_attrs = ('rir', 'tenant', 'description')
@register_search @register_search
@ -28,6 +30,7 @@ class ASNRangeIndex(SearchIndex):
fields = ( fields = (
('description', 500), ('description', 500),
) )
display_attrs = ('rir', 'tenant', 'description')
@register_search @register_search
@ -39,6 +42,7 @@ class FHRPGroupIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('protocol', 'auth_type', 'description')
@register_search @register_search
@ -50,6 +54,7 @@ class IPAddressIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('vrf', 'tenant', 'status', 'role', 'description')
@register_search @register_search
@ -61,6 +66,7 @@ class IPRangeIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('vrf', 'tenant', 'status', 'role', 'description')
@register_search @register_search
@ -72,6 +78,7 @@ class L2VPNIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('type', 'identifier', 'tenant', 'description')
@register_search @register_search
@ -82,6 +89,7 @@ class PrefixIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'description')
@register_search @register_search
@ -92,6 +100,7 @@ class RIRIndex(SearchIndex):
('slug', 110), ('slug', 110),
('description', 500), ('description', 500),
) )
display_attrs = ('description',)
@register_search @register_search
@ -102,6 +111,7 @@ class RoleIndex(SearchIndex):
('slug', 110), ('slug', 110),
('description', 500), ('description', 500),
) )
display_attrs = ('description',)
@register_search @register_search
@ -112,6 +122,7 @@ class RouteTargetIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('tenant', 'description')
@register_search @register_search
@ -122,6 +133,7 @@ class ServiceIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('device', 'virtual_machine', 'description')
@register_search @register_search
@ -132,6 +144,7 @@ class ServiceTemplateIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('description',)
@register_search @register_search
@ -143,6 +156,7 @@ class VLANIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('site', 'group', 'tenant', 'status', 'role', 'description')
@register_search @register_search
@ -154,6 +168,7 @@ class VLANGroupIndex(SearchIndex):
('description', 500), ('description', 500),
('max_vid', 2000), ('max_vid', 2000),
) )
display_attrs = ('scope_type', 'min_vid', 'max_vid', 'description')
@register_search @register_search
@ -165,3 +180,4 @@ class VRFIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('rd', 'tenant', 'description')

View File

@ -627,8 +627,12 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_mask_length(self): def test_mask_length(self):
params = {'mask_length': ['24']} params = {'mask_length': [24]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'mask_length__gte': 32}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
params = {'mask_length__lte': 24}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
def test_vrf(self): def test_vrf(self):
vrfs = VRF.objects.all()[:2] vrfs = VRF.objects.all()[:2]
@ -954,8 +958,12 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_mask_length(self): def test_mask_length(self):
params = {'mask_length': '24'} params = {'mask_length': [24]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
params = {'mask_length__gte': 64}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'mask_length__lte': 25}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_vrf(self): def test_vrf(self):
vrfs = VRF.objects.all()[:2] vrfs = VRF.objects.all()[:2]

View File

@ -4,6 +4,7 @@ from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from netaddr import IPNetwork from netaddr import IPNetwork
from dcim.constants import InterfaceTypeChoices
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site, Interface from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site, Interface
from ipam.choices import * from ipam.choices import *
from ipam.models import * from ipam.models import *
@ -911,6 +912,7 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1') devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, role=role) device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, role=role)
interface = Interface.objects.create(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL)
services = ( services = (
Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]), Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]),
@ -919,6 +921,12 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
) )
Service.objects.bulk_create(services) Service.objects.bulk_create(services)
ip_addresses = (
IPAddress(assigned_object=interface, address='192.0.2.1/24'),
IPAddress(assigned_object=interface, address='192.0.2.2/24'),
)
IPAddress.objects.bulk_create(ip_addresses)
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
@ -933,10 +941,10 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
cls.csv_data = ( cls.csv_data = (
"device,name,protocol,ports,description", "device,name,protocol,ports,ipaddresses,description",
"Device 1,Service 1,tcp,1,First service", "Device 1,Service 1,tcp,1,192.0.2.1/24,First service",
"Device 1,Service 2,tcp,2,Second service", "Device 1,Service 2,tcp,2,192.0.2.2/24,Second service",
"Device 1,Service 3,udp,3,Third service", "Device 1,Service 3,udp,3,,Third service",
) )
cls.csv_update_data = ( cls.csv_update_data = (

View File

@ -220,7 +220,7 @@ class ASNRangeASNsView(generic.ObjectChildrenView):
tab = ViewTab( tab = ViewTab(
label=_('ASNs'), label=_('ASNs'),
badge=lambda x: x.get_child_asns().count(), badge=lambda x: x.get_child_asns().count(),
permission='ipam.view_asns', permission='ipam.view_asn',
weight=500 weight=500
) )

View File

@ -3,7 +3,6 @@ from collections import defaultdict
from functools import cached_property from functools import cached_property
from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core.validators import ValidationError from django.core.validators import ValidationError
from django.db import models from django.db import models
from django.db.models.signals import class_prepared from django.db.models.signals import class_prepared
@ -13,6 +12,7 @@ from django.utils.translation import gettext_lazy as _
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
from core.choices import JobStatusChoices from core.choices import JobStatusChoices
from core.models import ContentType
from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices
from extras.utils import is_taggable, register_features from extras.utils import is_taggable, register_features
from netbox.registry import registry from netbox.registry import registry

View File

@ -28,6 +28,7 @@ registry = Registry({
'models': collections.defaultdict(set), 'models': collections.defaultdict(set),
'plugins': dict(), 'plugins': dict(),
'search': dict(), 'search': dict(),
'tables': collections.defaultdict(dict),
'views': collections.defaultdict(dict), 'views': collections.defaultdict(dict),
'widgets': dict(), 'widgets': dict(),
}) })

View File

@ -33,10 +33,12 @@ class SearchIndex:
category: The label of the group under which this indexer is categorized (for form field display). If none, category: The label of the group under which this indexer is categorized (for form field display). If none,
the name of the model's app will be used. the name of the model's app will be used.
fields: An iterable of two-tuples defining the model fields to be indexed and the weight associated with each. fields: An iterable of two-tuples defining the model fields to be indexed and the weight associated with each.
display_attrs: An iterable of additional object attributes to include when displaying search results.
""" """
model = None model = None
category = None category = None
fields = () fields = ()
display_attrs = ()
@staticmethod @staticmethod
def get_field_type(instance, field_name): def get_field_type(instance, field_name):

View File

@ -3,7 +3,8 @@ from collections import defaultdict
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.db.models import F, Window, Q from django.db.models import F, Window, Q, prefetch_related_objects
from django.db.models.fields.related import ForeignKey
from django.db.models.functions import window from django.db.models.functions import window
from django.db.models.signals import post_delete, post_save from django.db.models.signals import post_delete, post_save
from django.utils.module_loading import import_string from django.utils.module_loading import import_string
@ -13,7 +14,7 @@ from netaddr.core import AddrFormatError
from extras.models import CachedValue, CustomField from extras.models import CachedValue, CustomField
from netbox.registry import registry from netbox.registry import registry
from utilities.querysets import RestrictedPrefetch from utilities.querysets import RestrictedPrefetch
from utilities.utils import title from utilities.utils import content_type_identifier, title
from . import FieldTypes, LookupTypes, get_indexer from . import FieldTypes, LookupTypes, get_indexer
DEFAULT_LOOKUP_TYPE = LookupTypes.PARTIAL DEFAULT_LOOKUP_TYPE = LookupTypes.PARTIAL
@ -103,17 +104,17 @@ class CachedValueSearchBackend(SearchBackend):
def search(self, value, user=None, object_types=None, lookup=DEFAULT_LOOKUP_TYPE): def search(self, value, user=None, object_types=None, lookup=DEFAULT_LOOKUP_TYPE):
# Build the filter used to find relevant CachedValue records
query_filter = Q(**{f'value__{lookup}': value}) query_filter = Q(**{f'value__{lookup}': value})
if object_types: if object_types:
# Limit results by object type
query_filter &= Q(object_type__in=object_types) query_filter &= Q(object_type__in=object_types)
if lookup in (LookupTypes.STARTSWITH, LookupTypes.ENDSWITH): if lookup in (LookupTypes.STARTSWITH, LookupTypes.ENDSWITH):
# Partial string matches are valid only on string values # "Starts/ends with" matches are valid only on string values
query_filter &= Q(type=FieldTypes.STRING) query_filter &= Q(type=FieldTypes.STRING)
elif lookup == LookupTypes.PARTIAL:
if lookup == LookupTypes.PARTIAL:
try: try:
# If the value looks like an IP address, add an extra match for CIDR values
address = str(netaddr.IPNetwork(value.strip()).cidr) address = str(netaddr.IPNetwork(value.strip()).cidr)
query_filter |= Q(type=FieldTypes.CIDR) & Q(value__net_contains_or_equals=address) query_filter |= Q(type=FieldTypes.CIDR) & Q(value__net_contains_or_equals=address)
except (AddrFormatError, ValueError): except (AddrFormatError, ValueError):
@ -129,6 +130,12 @@ class CachedValueSearchBackend(SearchBackend):
) )
)[:MAX_RESULTS] )[:MAX_RESULTS]
# Gather all ContentTypes present in the search results (used for prefetching related
# objects). This must be done before generating the final results list, which returns
# a RawQuerySet.
content_type_ids = set(queryset.values_list('object_type', flat=True))
content_types = ContentType.objects.filter(pk__in=content_type_ids)
# Construct a Prefetch to pre-fetch only those related objects for which the # Construct a Prefetch to pre-fetch only those related objects for which the
# user has permission to view. # user has permission to view.
if user: if user:
@ -144,12 +151,34 @@ class CachedValueSearchBackend(SearchBackend):
params params
) )
# Iterate through each ContentType represented in the search results and prefetch any
# related objects necessary to render the prescribed display attributes (display_attrs).
for ct in content_types:
model = ct.model_class()
indexer = registry['search'].get(content_type_identifier(ct))
if not (display_attrs := getattr(indexer, 'display_attrs', None)):
continue
# Add ForeignKey fields to prefetch list
prefetch_fields = []
for attr in display_attrs:
field = model._meta.get_field(attr)
if type(field) is ForeignKey:
prefetch_fields.append(f'object__{attr}')
# Compile a list of all CachedValues referencing this object type, and prefetch
# any related objects
if prefetch_fields:
objects = [r for r in results if r.object_type == ct]
prefetch_related_objects(objects, *prefetch_fields)
# Omit any results pertaining to an object the user does not have permission to view # Omit any results pertaining to an object the user does not have permission to view
ret = [] ret = []
for r in results: for r in results:
if r.object is not None: if r.object is not None:
r.name = str(r.object) r.name = str(r.object)
ret.append(r) ret.append(r)
return ret return ret
def cache(self, instances, indexer=None, remove_existing=True): def cache(self, instances, indexer=None, remove_existing=True):

View File

@ -0,0 +1,14 @@
from netbox.registry import registry
from utilities.utils import content_type_identifier
__all__ = (
'get_indexer',
)
def get_indexer(content_type):
"""
Return the registered search indexer for the given ContentType.
"""
ct_identifier = content_type_identifier(content_type)
return registry['search'].get(ct_identifier)

View File

@ -9,12 +9,14 @@ import warnings
from urllib.parse import urlencode, urlsplit from urllib.parse import urlencode, urlsplit
import django import django
import sentry_sdk
from django.contrib.messages import constants as messages from django.contrib.messages import constants as messages
from django.core.exceptions import ImproperlyConfigured, ValidationError from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.core.validators import URLValidator from django.core.validators import URLValidator
from django.utils.encoding import force_str from django.utils.encoding import force_str
from sentry_sdk.integrations.django import DjangoIntegration try:
import sentry_sdk
except ModuleNotFoundError:
pass
from netbox.config import PARAMS from netbox.config import PARAMS
from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
@ -25,7 +27,7 @@ from netbox.plugins import PluginConfig
# Environment setup # Environment setup
# #
VERSION = '3.6.5-dev' VERSION = '3.6.6-dev'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()
@ -39,8 +41,6 @@ if sys.version_info < (3, 8):
f"NetBox requires Python 3.8 or later. (Currently installed: Python {platform.python_version()})" f"NetBox requires Python 3.8 or later. (Currently installed: Python {platform.python_version()})"
) )
DEFAULT_SENTRY_DSN = 'https://198cf560b29d4054ab8e583a1d10ea58@o1242133.ingest.sentry.io/6396485'
# #
# Configuration import # Configuration import
# #
@ -158,7 +158,7 @@ RQ_RETRY_MAX = getattr(configuration, 'RQ_RETRY_MAX', 0)
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/') SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend') SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend')
SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False) SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False)
SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', DEFAULT_SENTRY_DSN) SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', None)
SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False) SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False)
SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0) SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0)
SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE', 0) SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE', 0)
@ -502,6 +502,9 @@ AUTH_EXEMPT_PATHS = (
MAINTENANCE_EXEMPT_PATHS = ( MAINTENANCE_EXEMPT_PATHS = (
f'/{BASE_PATH}admin/', f'/{BASE_PATH}admin/',
f'/{BASE_PATH}extras/config-revisions/', # Allow modifying the configuration f'/{BASE_PATH}extras/config-revisions/', # Allow modifying the configuration
LOGIN_URL,
LOGIN_REDIRECT_URL,
LOGOUT_REDIRECT_URL
) )
SERIALIZATION_MODULES = { SERIALIZATION_MODULES = {
@ -514,12 +517,12 @@ SERIALIZATION_MODULES = {
# #
if SENTRY_ENABLED: if SENTRY_ENABLED:
try:
from sentry_sdk.integrations.django import DjangoIntegration
except ModuleNotFoundError:
raise ImproperlyConfigured("SENTRY_ENABLED is True but the sentry-sdk package is not installed.")
if not SENTRY_DSN: if not SENTRY_DSN:
raise ImproperlyConfigured("SENTRY_ENABLED is True but SENTRY_DSN has not been defined.") raise ImproperlyConfigured("SENTRY_ENABLED is True but SENTRY_DSN has not been defined.")
# If using the default DSN, force sampling rates
if SENTRY_DSN == DEFAULT_SENTRY_DSN:
SENTRY_SAMPLE_RATE = 1.0
SENTRY_TRACES_SAMPLE_RATE = 0
# Initialize the SDK # Initialize the SDK
sentry_sdk.init( sentry_sdk.init(
dsn=SENTRY_DSN, dsn=SENTRY_DSN,
@ -534,9 +537,6 @@ if SENTRY_ENABLED:
# Assign any configured tags # Assign any configured tags
for k, v in SENTRY_TAGS.items(): for k, v in SENTRY_TAGS.items():
sentry_sdk.set_tag(k, v) sentry_sdk.set_tag(k, v)
# If using the default DSN, append a unique deployment ID tag for error correlation
if SENTRY_DSN == DEFAULT_SENTRY_DSN:
sentry_sdk.set_tag('netbox.deployment_id', DEPLOYMENT_ID)
# #

View File

@ -1,3 +1,5 @@
from copy import deepcopy
import django_tables2 as tables import django_tables2 as tables
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
@ -12,9 +14,11 @@ from django_tables2.data import TableQuerysetData
from extras.models import CustomField, CustomLink from extras.models import CustomField, CustomLink
from extras.choices import CustomFieldVisibilityChoices from extras.choices import CustomFieldVisibilityChoices
from netbox.registry import registry
from netbox.tables import columns from netbox.tables import columns
from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.utils import get_viewname, highlight_string, title from utilities.utils import get_viewname, highlight_string, title
from .template_code import *
__all__ = ( __all__ = (
'BaseTable', 'BaseTable',
@ -119,7 +123,7 @@ class BaseTable(tables.Table):
@property @property
def available_columns(self): def available_columns(self):
return self._get_columns(visible=False) return sorted(self._get_columns(visible=False))
@property @property
def selected_columns(self): def selected_columns(self):
@ -190,12 +194,17 @@ class NetBoxTable(BaseTable):
if extra_columns is None: if extra_columns is None:
extra_columns = [] extra_columns = []
if registered_columns := registry['tables'].get(self.__class__):
extra_columns.extend([
# Create a copy to avoid modifying the original Column
(name, deepcopy(column)) for name, column in registered_columns.items()
])
# Add custom field & custom link columns # Add custom field & custom link columns
content_type = ContentType.objects.get_for_model(self._meta.model) content_type = ContentType.objects.get_for_model(self._meta.model)
custom_fields = CustomField.objects.filter( custom_fields = CustomField.objects.filter(
content_types=content_type content_types=content_type
).exclude(ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN) ).exclude(ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN)
extra_columns.extend([ extra_columns.extend([
(f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields
]) ])
@ -236,6 +245,10 @@ class SearchTable(tables.Table):
value = tables.Column( value = tables.Column(
verbose_name=_('Value'), verbose_name=_('Value'),
) )
attrs = columns.TemplateColumn(
template_code=SEARCH_RESULT_ATTRS,
verbose_name=_('Attributes')
)
trim_length = 30 trim_length = 30

View File

@ -0,0 +1,18 @@
SEARCH_RESULT_ATTRS = """
{% for name, value in record.display_attrs.items %}
<span class="badge bg-secondary"
{% if value|length > 40 %} data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ value }}"{% endif %}
>
{{ name|bettertitle }}:
{% with url=value.get_absolute_url %}
{% if url %}<a href="url">{% endif %}
{% if value|length > 40 %}
{{ value|truncatechars:"40" }}
{% else %}
{{ value }}
{% endif %}
{% if url %}</a>{% endif %}
{% endwith %}
</span>
{% endfor %}
"""

View File

@ -0,0 +1,11 @@
import django_tables2 as tables
from dcim.tables import SiteTable
from utilities.tables import register_table_column
mycol = tables.Column(
verbose_name='My column',
accessor=tables.A('description')
)
register_table_column(mycol, 'foo', SiteTable)

View File

@ -4,6 +4,8 @@ from django.views.generic import View
from dcim.models import Site from dcim.models import Site
from utilities.views import register_model_view from utilities.views import register_model_view
from .models import DummyModel from .models import DummyModel
# Trigger registration of custom column
from .tables import mycol
class DummyModelsView(View): class DummyModelsView(View):

View File

@ -97,6 +97,16 @@ class PluginTest(TestCase):
self.assertIn(SiteContent, registry['plugins']['template_extensions']['dcim.site']) self.assertIn(SiteContent, registry['plugins']['template_extensions']['dcim.site'])
def test_registered_columns(self):
"""
Check that a plugin can register a custom column on a core model table.
"""
from dcim.models import Site
from dcim.tables import SiteTable
table = SiteTable(Site.objects.all())
self.assertIn('foo', table.columns.names())
def test_user_preferences(self): def test_user_preferences(self):
""" """
Check that plugin UserPreferences are registered. Check that plugin UserPreferences are registered.

View File

@ -9,7 +9,6 @@ from django.template.exceptions import TemplateDoesNotExist
from django.views.decorators.csrf import requires_csrf_token from django.views.decorators.csrf import requires_csrf_token
from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found
from django.views.generic import View from django.views.generic import View
from sentry_sdk import capture_message
from netbox.plugins.utils import get_installed_plugins from netbox.plugins.utils import get_installed_plugins
@ -34,7 +33,9 @@ def handler_404(request, exception):
""" """
Wrap Django's default 404 handler to enable Sentry reporting. Wrap Django's default 404 handler to enable Sentry reporting.
""" """
capture_message("Page not found", level="error") if settings.SENTRY_ENABLED:
from sentry_sdk import capture_message
capture_message("Page not found", level="error")
return page_not_found(request, exception) return page_not_found(request, exception)

View File

@ -25,4 +25,11 @@
{% render_field form.priority %} {% render_field form.priority %}
{% render_field form.tags %} {% render_field form.tags %}
</div> </div>
<div class="field-group mb-5">
<div class="row mb-2">
<h5 class="offset-sm-3">{% trans "Custom Fields" %}</h5>
</div>
{% render_custom_fields form %}
</div>
{% endblock %} {% endblock %}

View File

@ -105,7 +105,7 @@ class ContactAssignmentSerializer(NetBoxModelSerializer):
model = ContactAssignment model = ContactAssignment
fields = [ fields = [
'id', 'url', 'display', 'content_type', 'object_id', 'object', 'contact', 'role', 'priority', 'tags', 'id', 'url', 'display', 'content_type', 'object_id', 'object', 'contact', 'role', 'priority', 'tags',
'created', 'last_updated', 'custom_fields', 'created', 'last_updated',
] ]
@extend_schema_field(OpenApiTypes.OBJECT) @extend_schema_field(OpenApiTypes.OBJECT)

View File

@ -3,11 +3,10 @@ from django.db.models import Q
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from extras.filters import TagFilter from extras.filters import TagFilter
from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet
from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter
from .models import * from .models import *
__all__ = ( __all__ = (
'ContactAssignmentFilterSet', 'ContactAssignmentFilterSet',
'ContactFilterSet', 'ContactFilterSet',
@ -81,7 +80,7 @@ class ContactFilterSet(NetBoxModelFilterSet):
) )
class ContactAssignmentFilterSet(ChangeLoggedModelFilterSet): class ContactAssignmentFilterSet(NetBoxModelFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label=_('Search'), label=_('Search'),

View File

@ -1,8 +1,7 @@
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from extras.utils import FeatureQuery from core.models import ContentType
from netbox.forms import NetBoxModelFilterSetForm from netbox.forms import NetBoxModelFilterSetForm
from tenancy.choices import * from tenancy.choices import *
from tenancy.models import * from tenancy.models import *
@ -87,8 +86,7 @@ class ContactAssignmentFilterForm(NetBoxModelFilterSetForm):
(_('Assignment'), ('content_type_id', 'group_id', 'contact_id', 'role_id', 'priority')), (_('Assignment'), ('content_type_id', 'group_id', 'contact_id', 'role_id', 'priority')),
) )
content_type_id = ContentTypeMultipleChoiceField( content_type_id = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(), queryset=ContentType.objects.with_feature('contacts'),
limit_choices_to=FeatureQuery('contacts'),
required=False, required=False,
label=_('Object type') label=_('Object type')
) )

View File

@ -1,12 +1,9 @@
from django import forms from django import forms
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from extras.forms.mixins import TagsMixin
from extras.models import Tag
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm
from tenancy.models import * from tenancy.models import *
from utilities.forms.mixins import BootstrapMixin from utilities.forms.fields import CommentField, DynamicModelChoiceField, SlugField
from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
__all__ = ( __all__ = (
'ContactAssignmentForm', 'ContactAssignmentForm',
@ -122,7 +119,7 @@ class ContactForm(NetBoxModelForm):
} }
class ContactAssignmentForm(BootstrapMixin, TagsMixin, forms.ModelForm): class ContactAssignmentForm(NetBoxModelForm):
group = DynamicModelChoiceField( group = DynamicModelChoiceField(
label=_('Group'), label=_('Group'),
queryset=ContactGroup.objects.all(), queryset=ContactGroup.objects.all(),

View File

@ -1,6 +1,6 @@
import graphene import graphene
from extras.graphql.mixins import TagsMixin from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
from tenancy import filtersets, models from tenancy import filtersets, models
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
@ -69,7 +69,7 @@ class ContactGroupType(OrganizationalObjectType):
filterset_class = filtersets.ContactGroupFilterSet filterset_class = filtersets.ContactGroupFilterSet
class ContactAssignmentType(TagsMixin, BaseObjectType): class ContactAssignmentType(CustomFieldsMixin, TagsMixin, BaseObjectType):
class Meta: class Meta:
model = models.ContactAssignment model = models.ContactAssignment

View File

@ -0,0 +1,19 @@
# Generated by Django 4.2.6 on 2023-11-06 20:23
from django.db import migrations, models
import utilities.json
class Migration(migrations.Migration):
dependencies = [
('tenancy', '0011_contactassignment_tags'),
]
operations = [
migrations.AddField(
model_name='contactassignment',
name='custom_field_data',
field=models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
),
]

View File

@ -1,11 +1,12 @@
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import ContentType
from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel
from netbox.models.features import TagsMixin from netbox.models.features import CustomFieldsMixin, TagsMixin
from tenancy.choices import * from tenancy.choices import *
__all__ = ( __all__ = (
@ -109,9 +110,9 @@ class Contact(PrimaryModel):
return reverse('tenancy:contact', args=[self.pk]) return reverse('tenancy:contact', args=[self.pk])
class ContactAssignment(ChangeLoggedModel, TagsMixin): class ContactAssignment(CustomFieldsMixin, TagsMixin, ChangeLoggedModel):
content_type = models.ForeignKey( content_type = models.ForeignKey(
to=ContentType, to='contenttypes.ContentType',
on_delete=models.CASCADE on_delete=models.CASCADE
) )
object_id = models.PositiveBigIntegerField() object_id = models.PositiveBigIntegerField()
@ -157,6 +158,15 @@ class ContactAssignment(ChangeLoggedModel, TagsMixin):
def get_absolute_url(self): def get_absolute_url(self):
return reverse('tenancy:contact', args=[self.contact.pk]) return reverse('tenancy:contact', args=[self.contact.pk])
def clean(self):
super().clean()
# Validate the assigned object type
if self.content_type not in ContentType.objects.with_feature('contacts'):
raise ValidationError(
_("Contacts cannot be assigned to this object type ({type}).").format(type=self.content_type)
)
def to_objectchange(self, action): def to_objectchange(self, action):
objectchange = super().to_objectchange(action) objectchange = super().to_objectchange(action)
objectchange.related_object = self.object objectchange.related_object = self.object

View File

@ -15,6 +15,7 @@ class ContactIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('group', 'title', 'phone', 'email', 'description')
@register_search @register_search
@ -25,6 +26,7 @@ class ContactGroupIndex(SearchIndex):
('slug', 110), ('slug', 110),
('description', 500), ('description', 500),
) )
display_attrs = ('description',)
@register_search @register_search
@ -35,6 +37,7 @@ class ContactRoleIndex(SearchIndex):
('slug', 110), ('slug', 110),
('description', 500), ('description', 500),
) )
display_attrs = ('description',)
@register_search @register_search
@ -46,6 +49,7 @@ class TenantIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('group', 'description')
@register_search @register_search
@ -56,3 +60,4 @@ class TenantGroupIndex(SearchIndex):
('slug', 110), ('slug', 110),
('description', 500), ('description', 500),
) )
display_attrs = ('description',)

View File

@ -102,6 +102,11 @@ class ContactAssignmentTable(NetBoxTable):
verbose_name=_('Role'), verbose_name=_('Role'),
linkify=True linkify=True
) )
contact_group = tables.Column(
accessor=Accessor('contact__group'),
verbose_name=_('Group'),
linkify=True
)
contact_title = tables.Column( contact_title = tables.Column(
accessor=Accessor('contact__title'), accessor=Accessor('contact__title'),
verbose_name=_('Contact Title') verbose_name=_('Contact Title')
@ -137,7 +142,8 @@ class ContactAssignmentTable(NetBoxTable):
model = ContactAssignment model = ContactAssignment
fields = ( fields = (
'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_title', 'contact_phone', 'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_title', 'contact_phone',
'contact_email', 'contact_address', 'contact_link', 'contact_description', 'tags', 'actions' 'contact_email', 'contact_address', 'contact_link', 'contact_description', 'contact_group', 'tags',
'actions'
) )
default_columns = ( default_columns = (
'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_email', 'contact_phone' 'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_email', 'contact_phone'

View File

@ -2,14 +2,9 @@ from django.contrib.contenttypes.models import ContentType
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from circuits.models import Circuit
from dcim.models import Cable, Device, Location, PowerFeed, Rack, RackReservation, Site, VirtualDeviceContext
from ipam.models import Aggregate, ASN, IPAddress, IPRange, L2VPN, Prefix, VLAN, VRF
from netbox.views import generic from netbox.views import generic
from utilities.utils import count_related from utilities.utils import count_related, get_related_models
from utilities.views import register_model_view, ViewTab from utilities.views import register_model_view, ViewTab
from virtualization.models import VirtualMachine, Cluster
from wireless.models import WirelessLAN, WirelessLink
from . import filtersets, forms, tables from . import filtersets, forms, tables
from .models import * from .models import *
@ -132,32 +127,8 @@ class TenantView(generic.ObjectView):
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
related_models = [ related_models = [
# DCIM (model.objects.restrict(request.user, 'view').filter(tenant=instance), f'{field}_id')
(Site.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), for model, field in get_related_models(Tenant)
(Rack.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
(RackReservation.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
(Location.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
(Device.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
(VirtualDeviceContext.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
(Cable.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
(PowerFeed.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
# IPAM
(VRF.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
(Aggregate.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
(Prefix.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
(IPRange.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
(IPAddress.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
(ASN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
(VLAN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
(L2VPN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
# Circuits
(Circuit.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
# Virtualization
(VirtualMachine.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
(Cluster.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
# Wireless
(WirelessLAN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
(WirelessLink.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
] ]
return { return {
@ -387,6 +358,7 @@ class ContactAssignmentListView(generic.ObjectListView):
filterset_form = forms.ContactAssignmentFilterForm filterset_form = forms.ContactAssignmentFilterForm
table = tables.ContactAssignmentTable table = tables.ContactAssignmentTable
actions = { actions = {
'import': {'add'},
'export': {'view'}, 'export': {'view'},
'bulk_edit': {'change'}, 'bulk_edit': {'change'},
'bulk_delete': {'delete'}, 'bulk_delete': {'delete'},

View File

@ -3,7 +3,6 @@ import os
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Group, GroupManager, User, UserManager from django.contrib.auth.models import Group, GroupManager, User, UserManager
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MinLengthValidator from django.core.validators import MinLengthValidator
@ -15,6 +14,7 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from netaddr import IPNetwork from netaddr import IPNetwork
from core.models import ContentType
from ipam.fields import IPNetworkField from ipam.fields import IPNetworkField
from netbox.config import get_config from netbox.config import get_config
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
@ -353,7 +353,7 @@ class ObjectPermission(models.Model):
default=True default=True
) )
object_types = models.ManyToManyField( object_types = models.ManyToManyField(
to=ContentType, to='contenttypes.ContentType',
limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES, limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES,
related_name='object_permissions' related_name='object_permissions'
) )

View File

@ -17,7 +17,7 @@ def get_client_ip(request, additional_headers=()):
) )
for header in HTTP_HEADERS: for header in HTTP_HEADERS:
if header in request.META: if header in request.META:
client_ip = request.META[header].split(',')[0] client_ip = request.META[header].split(',')[0].partition(':')[0]
try: try:
return IPAddress(client_ip) return IPAddress(client_ip)
except ValueError: except ValueError:

View File

@ -1,6 +1,9 @@
from netbox.registry import registry
__all__ = ( __all__ = (
'get_table_ordering', 'get_table_ordering',
'linkify_phone', 'linkify_phone',
'register_table_column'
) )
@ -26,3 +29,19 @@ def linkify_phone(value):
if value is None: if value is None:
return None return None
return f"tel:{value}" return f"tel:{value}"
def register_table_column(column, name, *tables):
"""
Register a custom column for use on one or more tables.
Args:
column: The column instance to register
name: The name of the table column
tables: One or more table classes
"""
for table in tables:
reg = registry['tables'][table]
if name in reg:
raise ValueError(f"A column named {name} is already defined for table {table.__name__}")
reg[name] = column

View File

@ -8,7 +8,7 @@ from itertools import count, groupby
import bleach import bleach
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core import serializers from django.core import serializers
from django.db.models import Count, OuterRef, Subquery from django.db.models import Count, ManyToOneRel, OuterRef, Subquery
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.http import QueryDict from django.http import QueryDict
from django.utils import timezone from django.utils import timezone
@ -567,3 +567,20 @@ def local_now():
Return the current date & time in the system timezone. Return the current date & time in the system timezone.
""" """
return localtime(timezone.now()) return localtime(timezone.now())
def get_related_models(model, ordered=True):
"""
Return a list of all models which have a ForeignKey to the given model and the name of the field. For example,
`get_related_models(Tenant)` will return all models which have a ForeignKey relationship to Tenant.
"""
related_models = [
(field.related_model, field.remote_field.name)
for field in model._meta.related_objects
if type(field) is ManyToOneRel
]
if ordered:
return sorted(related_models, key=lambda x: x[0]._meta.verbose_name)
return related_models

View File

@ -1,7 +1,7 @@
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from dcim.models import Device from dcim.models import Device
from extras.api.mixins import ConfigContextQuerySetMixin from extras.api.mixins import ConfigContextQuerySetMixin, 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 utilities.utils import count_related from utilities.utils import count_related
@ -53,9 +53,10 @@ class ClusterViewSet(NetBoxModelViewSet):
# Virtual machines # Virtual machines
# #
class VirtualMachineViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet): class VirtualMachineViewSet(ConfigContextQuerySetMixin, RenderConfigMixin, NetBoxModelViewSet):
queryset = VirtualMachine.objects.prefetch_related( queryset = VirtualMachine.objects.prefetch_related(
'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags', 'virtualdisks', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'config_template',
'tags', 'virtualdisks',
) )
filterset_class = filtersets.VirtualMachineFilterSet filterset_class = filtersets.VirtualMachineFilterSet

View File

@ -6,6 +6,7 @@ from dcim.filtersets import CommonInterfaceFilterSet
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
from extras.filtersets import LocalConfigContextFilterSet from extras.filtersets import LocalConfigContextFilterSet
from extras.models import ConfigTemplate from extras.models import ConfigTemplate
from ipam.filtersets import PrimaryIPFilterSet
from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
@ -115,7 +116,8 @@ class VirtualMachineFilterSet(
NetBoxModelFilterSet, NetBoxModelFilterSet,
TenancyFilterSet, TenancyFilterSet,
ContactModelFilterSet, ContactModelFilterSet,
LocalConfigContextFilterSet LocalConfigContextFilterSet,
PrimaryIPFilterSet,
): ):
status = django_filters.MultipleChoiceFilter( status = django_filters.MultipleChoiceFilter(
choices=VirtualMachineStatusChoices, choices=VirtualMachineStatusChoices,

View File

@ -205,7 +205,8 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
platform = DynamicModelChoiceField( platform = DynamicModelChoiceField(
label=_('Platform'), label=_('Platform'),
queryset=Platform.objects.all(), queryset=Platform.objects.all(),
required=False required=False,
selector=True
) )
local_context_data = JSONField( local_context_data = JSONField(
required=False, required=False,

View File

@ -10,6 +10,7 @@ class ClusterIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('type', 'group', 'status', 'tenant', 'site', 'description')
@register_search @register_search
@ -20,6 +21,7 @@ class ClusterGroupIndex(SearchIndex):
('slug', 110), ('slug', 110),
('description', 500), ('description', 500),
) )
display_attrs = ('description',)
@register_search @register_search
@ -30,6 +32,7 @@ class ClusterTypeIndex(SearchIndex):
('slug', 110), ('slug', 110),
('description', 500), ('description', 500),
) )
display_attrs = ('description',)
@register_search @register_search
@ -40,6 +43,7 @@ class VirtualMachineIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('site', 'cluster', 'device', 'tenant', 'platform', 'status', 'role', 'description')
@register_search @register_search
@ -51,6 +55,7 @@ class VMInterfaceIndex(SearchIndex):
('description', 500), ('description', 500),
('mtu', 2000), ('mtu', 2000),
) )
display_attrs = ('virtual_machine', 'description')
@register_search @register_search
@ -60,3 +65,4 @@ class VirtualDiskIndex(SearchIndex):
('name', 100), ('name', 100),
('description', 500), ('description', 500),
) )
display_attrs = ('virtual_machine', 'description')

View File

@ -3,6 +3,7 @@ from rest_framework import status
from dcim.choices import InterfaceModeChoices from dcim.choices import InterfaceModeChoices
from dcim.models import Site from dcim.models import Site
from extras.models import ConfigTemplate
from ipam.models import VLAN, VRF from ipam.models import VLAN, VRF
from utilities.testing import APITestCase, APIViewTestCases, create_test_device, create_test_virtualmachine from utilities.testing import APITestCase, APIViewTestCases, create_test_device, create_test_virtualmachine
from virtualization.choices import * from virtualization.choices import *
@ -228,6 +229,22 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
response = self.client.post(url, data, format='json', **self.header) response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
def test_render_config(self):
configtemplate = ConfigTemplate.objects.create(
name='Config Template 1',
template_code='Config for virtual machine {{ virtualmachine.name }}'
)
vm = VirtualMachine.objects.first()
vm.config_template = configtemplate
vm.save()
self.add_permissions('virtualization.add_virtualmachine')
url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': vm.pk}) + 'render-config/'
response = self.client.post(url, {}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['content'], f'Config for virtual machine {vm.name}')
class VMInterfaceTest(APIViewTestCases.APIViewTestCase): class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
model = VMInterface model = VMInterface

View File

@ -291,10 +291,14 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
ipaddresses = ( ipaddresses = (
IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]), IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]),
IPAddress(address='192.0.2.2/24', assigned_object=interfaces[1]), IPAddress(address='192.0.2.2/24', assigned_object=interfaces[1]),
IPAddress(address='192.0.2.3/24', assigned_object=None),
IPAddress(address='2001:db8::1/64', assigned_object=interfaces[0]),
IPAddress(address='2001:db8::2/64', assigned_object=interfaces[1]),
IPAddress(address='2001:db8::3/64', assigned_object=None),
) )
IPAddress.objects.bulk_create(ipaddresses) IPAddress.objects.bulk_create(ipaddresses)
VirtualMachine.objects.filter(pk=vms[0].pk).update(primary_ip4=ipaddresses[0]) VirtualMachine.objects.filter(pk=vms[0].pk).update(primary_ip4=ipaddresses[0], primary_ip6=ipaddresses[3])
VirtualMachine.objects.filter(pk=vms[1].pk).update(primary_ip4=ipaddresses[1]) VirtualMachine.objects.filter(pk=vms[1].pk).update(primary_ip4=ipaddresses[1], primary_ip6=ipaddresses[4])
def test_name(self): def test_name(self):
params = {'name': ['Virtual Machine 1', 'Virtual Machine 2']} params = {'name': ['Virtual Machine 1', 'Virtual Machine 2']}
@ -412,6 +416,20 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]} params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_primary_ip4(self):
addresses = IPAddress.objects.filter(address__family=4)
params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'primary_ip4_id': [addresses[2].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
def test_primary_ip6(self):
addresses = IPAddress.objects.filter(address__family=6)
params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'primary_ip6_id': [addresses[2].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = VMInterface.objects.all() queryset = VMInterface.objects.all()

View File

@ -11,6 +11,7 @@ class WirelessLANIndex(SearchIndex):
('auth_psk', 2000), ('auth_psk', 2000),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('group', 'status', 'vlan', 'tenant', 'description')
@register_search @register_search
@ -21,6 +22,7 @@ class WirelessLANGroupIndex(SearchIndex):
('slug', 110), ('slug', 110),
('description', 500), ('description', 500),
) )
display_attrs = ('description',)
@register_search @register_search
@ -32,3 +34,4 @@ class WirelessLinkIndex(SearchIndex):
('auth_psk', 2000), ('auth_psk', 2000),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('status', 'tenant', 'description')

View File

@ -1,5 +1,5 @@
bleach==6.1.0 bleach==6.1.0
Django==4.2.6 Django==4.2.7
django-cors-headers==4.3.0 django-cors-headers==4.3.0
django-debug-toolbar==4.2.0 django-debug-toolbar==4.2.0
django-filter==23.3 django-filter==23.3
@ -21,16 +21,15 @@ graphene-django==3.0.0
gunicorn==21.2.0 gunicorn==21.2.0
Jinja2==3.1.2 Jinja2==3.1.2
Markdown==3.3.7 Markdown==3.3.7
mkdocs-material==9.4.6 mkdocs-material==9.4.8
mkdocstrings[python-legacy]==0.23.0 mkdocstrings[python-legacy]==0.23.0
netaddr==0.9.0 netaddr==0.9.0
Pillow==10.1.0 Pillow==10.1.0
psycopg[binary,pool]==3.1.12 psycopg[binary,pool]==3.1.12
PyYAML==6.0.1 PyYAML==6.0.1
requests==2.31.0 requests==2.31.0
sentry-sdk==1.32.0
social-auth-app-django==5.4.0 social-auth-app-django==5.4.0
social-auth-core[openidconnect]==4.4.2 social-auth-core[openidconnect]==4.5.0
svgwrite==1.4.3 svgwrite==1.4.3
tablib==3.5.0 tablib==3.5.0
tzdata==2023.3 tzdata==2023.3