Merge branch 'feature' into 15154-uwsgi

This commit is contained in:
Arthur 2024-03-22 08:32:42 -07:00
commit fa4a31a52e
106 changed files with 1117 additions and 987 deletions

View File

@ -4,7 +4,7 @@ NetBox validates every object prior to it being written to the database to ensur
## Custom Validation Rules
Custom validation rules are expressed as a mapping of model attributes to a set of rules to which that attribute must conform. For example:
Custom validation rules are expressed as a mapping of object attributes to a set of rules to which that attribute must conform. For example:
```json
{
@ -17,6 +17,8 @@ Custom validation rules are expressed as a mapping of model attributes to a set
This defines a custom validator which checks that the length of the `name` attribute for an object is at least five characters long, and no longer than 30 characters. This validation is executed _after_ NetBox has performed its own internal validation.
### Validation Types
The `CustomValidator` class supports several validation types:
* `min`: Minimum value
@ -34,16 +36,33 @@ The `min` and `max` types should be defined for numeric values, whereas `min_len
!!! warning
Bear in mind that these validators merely supplement NetBox's own validation: They will not override it. For example, if a certain model field is required by NetBox, setting a validator for it with `{'prohibited': True}` will not work.
### Validating Request Parameters
!!! info "This feature was introduced in NetBox v4.0."
In addition to validating object attributes, custom validators can also match against parameters of the current request (where available). For example, the following rule will permit only the user named "admin" to modify an object:
```json
{
"request.user.username": {
"eq": "admin"
}
}
```
!!! tip
Custom validation should generally not be used to enforce permissions. NetBox provides a robust [object-based permissions](../administration/permissions.md) mechanism which should be used for this purpose.
### Custom Validation Logic
There may be instances where the provided validation types are insufficient. NetBox provides a `CustomValidator` class which can be extended to enforce arbitrary validation logic by overriding its `validate()` method, and calling `fail()` when an unsatisfactory condition is detected.
There may be instances where the provided validation types are insufficient. NetBox provides a `CustomValidator` class which can be extended to enforce arbitrary validation logic by overriding its `validate()` method, and calling `fail()` when an unsatisfactory condition is detected. The `validate()` method should accept an instance (the object being saved) as well as the current request effecting the change.
```python
from extras.validators import CustomValidator
class MyValidator(CustomValidator):
def validate(self, instance):
def validate(self, instance, request):
if instance.status == 'active' and not instance.description:
self.fail("Active sites must have a description set!", field='status')
```

View File

@ -49,8 +49,8 @@ menu_items = (item1, item2, item3)
Each menu item represents a link and (optionally) a set of buttons comprising one entry in NetBox's navigation menu. Menu items are defined as PluginMenuItem instances. An example is shown below.
```python title="navigation.py"
from netbox.choices import ButtonColorChoices
from netbox.plugins import PluginMenuButton, PluginMenuItem
from utilities.choices import ButtonColorChoices
item1 = PluginMenuItem(
link='plugins:myplugin:myview',

View File

@ -6,7 +6,7 @@ from dcim.views import PathTraceView
from netbox.views import generic
from tenancy.views import ObjectContactsView
from utilities.forms import ConfirmationForm
from utilities.utils import count_related
from utilities.query import count_related
from utilities.views import register_model_view
from . import filtersets, forms, tables
from .models import *

View File

@ -1,3 +1,4 @@
import hashlib
import logging
import os
import yaml
@ -18,7 +19,6 @@ from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
from netbox.models import PrimaryModel
from netbox.models.features import JobsMixin
from netbox.registry import registry
from utilities.files import sha256_hash
from utilities.querysets import RestrictedQuerySet
from ..choices import *
from ..exceptions import SyncError
@ -357,7 +357,8 @@ class DataFile(models.Model):
has changed.
"""
file_path = os.path.join(source_root, self.path)
file_hash = sha256_hash(file_path).hexdigest()
with open(file_path, 'rb') as f:
file_hash = hashlib.sha256(f.read()).hexdigest()
# Update instance file attributes & data
if is_modified := file_hash != self.hash:

View File

@ -25,7 +25,7 @@ from netbox.views import generic
from netbox.views.generic.base import BaseObjectView
from netbox.views.generic.mixins import TableMixin
from utilities.forms import ConfirmationForm
from utilities.utils import count_related
from utilities.query import count_related
from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
from . import filtersets, forms, tables
from .models import *

View File

@ -10,12 +10,12 @@ from extras.filtersets import LocalConfigContextFilterSet
from extras.models import ConfigTemplate
from ipam.filtersets import PrimaryIPFilterSet
from ipam.models import ASN, IPAddress, VRF
from netbox.choices import ColorChoices
from netbox.filtersets import (
BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
)
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
from tenancy.models import *
from utilities.choices import ColorChoices
from utilities.filters import (
ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
NumericArrayFilter, TreeNodeMultipleChoiceFilter,

View File

@ -15,9 +15,9 @@ from dcim.constants import *
from dcim.fields import PathField
from dcim.utils import decompile_path_node, object_to_path_node
from netbox.models import ChangeLoggedModel, PrimaryModel
from utilities.conversion import to_meters
from utilities.fields import ColorField
from utilities.querysets import RestrictedQuerySet
from utilities.utils import to_meters
from wireless.models import WirelessLink
from .device_components import FrontPort, RearPort, PathEndpoint

View File

@ -12,8 +12,8 @@ from mptt.models import MPTTModel, TreeForeignKey
from dcim.choices import *
from dcim.constants import *
from dcim.fields import MACAddressField, WWNField
from netbox.choices import ColorChoices
from netbox.models import OrganizationalModel, NetBoxModel
from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField
from utilities.mptt import TreeManager
from utilities.ordering import naturalize_interface

View File

@ -18,10 +18,10 @@ from dcim.choices import *
from dcim.constants import *
from extras.models import ConfigContextModel, CustomField
from extras.querysets import ConfigContextModelQuerySet
from netbox.choices import ColorChoices
from netbox.config import ConfigItem
from netbox.models import OrganizationalModel, PrimaryModel
from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
from utilities.choices import ColorChoices
from utilities.fields import ColorField, CounterCacheField, NaturalOrderingField
from utilities.tracking import TrackingModelMixin
from .device_components import *

View File

@ -2,7 +2,7 @@ from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
from dcim.choices import *
from utilities.utils import to_grams
from utilities.conversion import to_grams
__all__ = (
'RenderConfigMixin',

View File

@ -14,11 +14,12 @@ from django.utils.translation import gettext_lazy as _
from dcim.choices import *
from dcim.constants import *
from dcim.svg import RackElevationSVG
from netbox.choices import ColorChoices
from netbox.models import OrganizationalModel, PrimaryModel
from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
from utilities.choices import ColorChoices
from utilities.conversion import to_grams
from utilities.data import array_to_string, drange
from utilities.fields import ColorField, NaturalOrderingField
from utilities.utils import array_to_string, drange, to_grams
from .device_components import PowerPort
from .devices import Device, Module
from .mixins import WeightMixin

View File

@ -6,7 +6,7 @@ from svgwrite.text import Text
from django.conf import settings
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
from utilities.utils import foreground_color
from utilities.html import foreground_color
__all__ = (

View File

@ -14,7 +14,8 @@ from django.urls import reverse
from django.utils.http import urlencode
from netbox.config import get_config
from utilities.utils import foreground_color, array_to_ranges
from utilities.data import array_to_ranges
from utilities.html import foreground_color
from dcim.constants import RACK_ELEVATION_BORDER_WIDTH

View File

@ -6,13 +6,12 @@ from dcim.choices import *
from dcim.filtersets import *
from dcim.models import *
from ipam.models import ASN, IPAddress, RIR, VRF
from netbox.choices import ColorChoices
from tenancy.models import Tenant, TenantGroup
from utilities.choices import ColorChoices
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device
from virtualization.models import Cluster, ClusterType
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
User = get_user_model()

View File

@ -7,7 +7,7 @@ from dcim.choices import *
from dcim.models import *
from extras.models import CustomField
from tenancy.models import Tenant
from utilities.utils import drange
from utilities.data import drange
class LocationTestCase(TestCase):

View File

@ -11,12 +11,11 @@ from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from ipam.models import ASN, RIR, VLAN, VRF
from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
from tenancy.models import Tenant
from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
from wireless.models import WirelessLAN
User = get_user_model()

View File

@ -25,8 +25,8 @@ from tenancy.views import ObjectContactsView
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.permissions import get_permission_for_model
from utilities.query import count_related
from utilities.query_functions import CollateAsChar
from utilities.utils import count_related
from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
from virtualization.models import VirtualMachine
from . import filtersets, forms, tables

View File

@ -20,7 +20,7 @@ from netbox.api.metadata import ContentTypeMetadata
from netbox.api.renderers import TextRenderer
from netbox.api.viewsets import NetBoxModelViewSet
from utilities.exceptions import RQWorkerNotRunningException
from utilities.utils import copy_safe_request
from utilities.request import copy_safe_request
from . import serializers
from .mixins import ConfigTemplateRenderMixin

View File

@ -2,7 +2,8 @@ import logging
from django.utils.translation import gettext_lazy as _
from utilities.choices import ButtonColorChoices, ChoiceSet
from netbox.choices import ButtonColorChoices
from utilities.choices import ChoiceSet
#

View File

@ -14,10 +14,12 @@ from django.utils.translation import gettext as _
from core.models import ObjectType
from extras.choices import BookmarkOrderingChoices
from utilities.choices import ButtonColorChoices
from netbox.choices import ButtonColorChoices
from utilities.object_types import object_type_identifier, object_type_name
from utilities.permissions import get_permission_for_model
from utilities.querydict import dict_to_querydict
from utilities.templatetags.builtins.filters import render_markdown
from utilities.utils import content_type_identifier, content_type_name, dict_to_querydict, get_viewname
from utilities.views import get_viewname
from .utils import register_widget
__all__ = (
@ -33,15 +35,15 @@ __all__ = (
def get_object_type_choices():
return [
(content_type_identifier(ct), content_type_name(ct))
for ct in ObjectType.objects.public().order_by('app_label', 'model')
(object_type_identifier(ot), object_type_name(ot))
for ot in ObjectType.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 ObjectType.objects.with_feature('bookmarks').order_by('app_label', 'model')
(object_type_identifier(ot), object_type_name(ot))
for ot in ObjectType.objects.with_feature('bookmarks').order_by('app_label', 'model')
]

View File

@ -1,9 +1,6 @@
import logging
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.utils import timezone
from django.utils.module_loading import import_string
from django.utils.translation import gettext as _
@ -15,9 +12,9 @@ from netbox.constants import RQ_QUEUE_DEFAULT
from netbox.registry import registry
from utilities.api import get_serializer_for_model
from utilities.rqworker import get_rq_retry
from utilities.utils import serialize_object
from utilities.serialization import serialize_object
from .choices import *
from .models import EventRule, ScriptModule
from .models import EventRule
logger = logging.getLogger('netbox.events_processor')

View File

@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _
from extras.choices import DurationChoices
from utilities.forms.widgets import DateTimePicker, NumberWithOptions
from utilities.utils import local_now
from utilities.datetime import local_now
__all__ = (
'ReportForm',

View File

@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _
from extras.choices import DurationChoices
from utilities.forms.widgets import DateTimePicker, NumberWithOptions
from utilities.utils import local_now
from utilities.datetime import local_now
__all__ = (
'ScriptForm',

View File

@ -14,7 +14,7 @@ from extras.context_managers import event_tracking
from extras.scripts import get_module_and_script
from extras.signals import clear_events
from utilities.exceptions import AbortTransaction
from utilities.utils import NetBoxFakeRequest
from utilities.request import NetBoxFakeRequest
class Command(BaseCommand):

View File

@ -9,11 +9,11 @@ from jinja2.sandbox import SandboxedEnvironment
from extras.querysets import ConfigContextQuerySet
from netbox.config import get_config
from netbox.registry import registry
from netbox.models import ChangeLoggedModel
from netbox.models.features import CloningMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
from utilities.jinja2 import ConfigTemplateLoader
from utilities.utils import deepmerge
from netbox.registry import registry
from utilities.data import deepmerge
from utilities.jinja2 import DataFileLoader
__all__ = (
'ConfigContext',
@ -290,7 +290,7 @@ class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, Ta
"""
# Initialize the template loader & cache the base template code (if applicable)
if self.data_file:
loader = ConfigTemplateLoader(data_source=self.data_source)
loader = DataFileLoader(data_source=self.data_source)
loader.cache_templates({
self.data_file.path: self.template_code
})

View File

@ -22,8 +22,10 @@ from netbox.models import ChangeLoggedModel
from netbox.models.features import (
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin,
)
from utilities.html import clean_html
from utilities.querydict import dict_to_querydict
from utilities.querysets import RestrictedQuerySet
from utilities.utils import clean_html, dict_to_querydict, render_jinja2
from utilities.jinja2 import render_jinja2
__all__ = (
'Bookmark',

View File

@ -4,9 +4,7 @@ from django.db import models
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.utils import content_type_identifier
from ..fields import CachedValueField
__all__ = (

View File

@ -8,7 +8,7 @@ from django.utils.translation import gettext_lazy as _
from extras.choices import ChangeActionChoices
from netbox.models import ChangeLoggedModel
from netbox.models.features import *
from utilities.utils import deserialize_object
from utilities.serialization import deserialize_object
__all__ = (
'Branch',

View File

@ -5,9 +5,9 @@ from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from taggit.models import TagBase, GenericTaggedItemBase
from netbox.choices import ColorChoices
from netbox.models import ChangeLoggedModel
from netbox.models.features import CloningMixin, ExportTemplatesMixin
from utilities.choices import ColorChoices
from utilities.fields import ColorField
__all__ = (

View File

@ -1,7 +1,8 @@
import importlib
import logging
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.db.models.fields.reverse_related import ManyToManyRel
from django.db.models.signals import m2m_changed, post_save, pre_delete
from django.dispatch import receiver, Signal
@ -13,7 +14,6 @@ from core.signals import job_end, job_start
from extras.constants import EVENT_JOB_END, EVENT_JOB_START
from extras.events import process_event_rules
from extras.models import EventRule
from extras.validators import run_validators
from netbox.config import get_config
from netbox.context import current_request, events_queue
from netbox.models.features import ChangeLoggingMixin
@ -22,6 +22,30 @@ from utilities.exceptions import AbortRequest
from .choices import ObjectChangeActionChoices
from .events import enqueue_object, get_snapshots, serialize_for_event
from .models import CustomField, ObjectChange, TaggedItem
from .validators import CustomValidator
def run_validators(instance, validators):
"""
Run the provided iterable of validators for the instance.
"""
request = current_request.get()
for validator in validators:
# Loading a validator class by dotted path
if type(validator) is str:
module, cls = validator.rsplit('.', 1)
validator = getattr(importlib.import_module(module), cls)()
# Constructing a new instance on the fly from a ruleset
elif type(validator) is dict:
validator = CustomValidator(validator)
elif not issubclass(validator.__class__, CustomValidator):
raise ImproperlyConfigured(f"Invalid value for custom validator: {validator}")
validator(instance, request)
#
# Change logging/webhooks

View File

@ -5,7 +5,7 @@ from circuits.api.serializers import ProviderSerializer
from circuits.forms import ProviderForm
from circuits.models import Provider
from ipam.models import ASN, RIR
from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
from utilities.testing import APITestCase, ModelViewTestCase, create_tags, post_data

View File

@ -12,7 +12,7 @@ from dcim.models import Manufacturer, Rack, Site
from extras.choices import *
from extras.models import CustomField, CustomFieldChoiceSet
from ipam.models import VLAN
from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
from utilities.testing import APITestCase, TestCase
from virtualization.models import VirtualMachine

View File

@ -3,11 +3,13 @@ from django.core.exceptions import ValidationError
from django.db import transaction
from django.test import TestCase, override_settings
from ipam.models import ASN, RIR
from dcim.choices import SiteStatusChoices
from dcim.models import Site
from extras.validators import CustomValidator
from ipam.models import ASN, RIR
from users.models import User
from utilities.exceptions import AbortRequest
from utilities.request import NetBoxFakeRequest
class MyValidator(CustomValidator):
@ -79,6 +81,13 @@ prohibited_validator = CustomValidator({
}
})
request_validator = CustomValidator({
'request.user.username': {
'eq': 'Bob'
}
})
custom_validator = MyValidator()
@ -154,6 +163,28 @@ class CustomValidatorTest(TestCase):
def test_custom_valid(self):
Site(name='foo', slug='foo').clean()
@override_settings(CUSTOM_VALIDATORS={'dcim.site': [request_validator]})
def test_request_validation(self):
alice = User.objects.create(username='Alice')
bob = User.objects.create(username='Bob')
request = NetBoxFakeRequest({
'META': {},
'POST': {},
'GET': {},
'FILES': {},
'user': alice,
'path': '',
})
site = Site(name='abc', slug='abc')
# Attempt to create the Site as Alice
with self.assertRaises(ValidationError):
request_validator(site, request)
# Creating the Site as Bob should succeed
request.user = bob
request_validator(site, request)
class CustomValidatorConfigTest(TestCase):

View File

@ -1,4 +1,5 @@
import importlib
import inspect
import operator
from django.core import validators
from django.core.exceptions import ValidationError
@ -74,6 +75,8 @@ class CustomValidator:
:param validation_rules: A dictionary mapping object attributes to validation rules
"""
REQUEST_TOKEN = 'request'
VALIDATORS = {
'eq': IsEqualValidator,
'neq': IsNotEqualValidator,
@ -88,25 +91,56 @@ class CustomValidator:
def __init__(self, validation_rules=None):
self.validation_rules = validation_rules or {}
assert type(self.validation_rules) is dict, "Validation rules must be passed as a dictionary"
if type(self.validation_rules) is not dict:
raise ValueError(_("Validation rules must be passed as a dictionary"))
def __call__(self, instance):
# Validate instance attributes per validation rules
for attr_name, rules in self.validation_rules.items():
attr = self._getattr(instance, attr_name)
def __call__(self, instance, request=None):
"""
Validate the instance and (optional) request against the validation rule(s).
"""
for attr_path, rules in self.validation_rules.items():
# The rule applies to the current request
if attr_path.split('.')[0] == self.REQUEST_TOKEN:
# Skip if no request has been provided (we can't validate)
if request is None:
continue
attr = self._get_request_attr(request, attr_path)
# The rule applies to the instance
else:
attr = self._get_instance_attr(instance, attr_path)
# Validate the attribute's value against each of the rules defined for it
for descriptor, value in rules.items():
validator = self.get_validator(descriptor, value)
try:
validator(attr)
except ValidationError as exc:
# Re-package the raised ValidationError to associate it with the specific attr
raise ValidationError({attr_name: exc})
raise ValidationError(
_("Custom validation failed for {attribute}: {exception}").format(
attribute=attr_path, exception=exc
)
)
# Execute custom validation logic (if any)
self.validate(instance)
# TODO: Remove in v4.1
# Inspect the validate() method, which may have been overridden, to determine
# whether we should pass the request (maintains backward compatibility for pre-v4.0)
if 'request' in inspect.signature(self.validate).parameters:
self.validate(instance, request)
else:
self.validate(instance)
@staticmethod
def _getattr(instance, name):
def _get_request_attr(request, name):
name = name.split('.', maxsplit=1)[1] # Remove token
try:
return operator.attrgetter(name)(request)
except AttributeError:
raise ValidationError(_('Invalid attribute "{name}" for request').format(name=name))
@staticmethod
def _get_instance_attr(instance, name):
# Attempt to resolve many-to-many fields to their stored values
m2m_fields = [f.name for f in instance._meta.local_many_to_many]
if name in m2m_fields:
@ -137,7 +171,7 @@ class CustomValidator:
validator_cls = self.VALIDATORS.get(descriptor)
return validator_cls(value)
def validate(self, instance):
def validate(self, instance, request):
"""
Custom validation method, to be overridden by the user. Validation failures should
raise a ValidationError exception.
@ -151,21 +185,3 @@ class CustomValidator:
if field is not None:
raise ValidationError({field: message})
raise ValidationError(message)
def run_validators(instance, validators):
"""
Run the provided iterable of validators for the instance.
"""
for validator in validators:
# Loading a validator class by dotted path
if type(validator) is str:
module, cls = validator.rsplit('.', 1)
validator = getattr(importlib.import_module(module), cls)()
# Constructing a new instance on the fly from a ruleset
elif type(validator) is dict:
validator = CustomValidator(validator)
validator(instance)

View File

@ -18,12 +18,15 @@ from extras.dashboard.utils import get_widget_class
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
from netbox.views import generic
from netbox.views.generic.mixins import TableMixin
from utilities.data import shallow_compare_dict
from utilities.forms import ConfirmationForm, get_field_value
from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.query import count_related
from utilities.querydict import normalize_querydict
from utilities.request import copy_safe_request
from utilities.rqworker import get_workers_for_queue
from utilities.templatetags.builtins.filters import render_markdown
from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
from utilities.views import ContentTypePermissionRequiredMixin, get_viewname, register_model_view
from . import filtersets, forms, tables
from .models import *
from .scripts import run_script

View File

@ -8,8 +8,7 @@ from django.utils.translation import gettext_lazy as _
from ipam.choices import *
from ipam.constants import *
from netbox.models import PrimaryModel
from utilities.utils import array_to_string
from utilities.data import array_to_string
__all__ = (
'Service',

View File

@ -3,8 +3,8 @@ from django.db.models import Count, F, OuterRef, Q, Subquery, Value
from django.db.models.expressions import RawSQL
from django.db.models.functions import Round
from utilities.query import count_related
from utilities.querysets import RestrictedQuerySet
from utilities.utils import count_related
__all__ = (
'ASNRangeQuerySet',

View File

@ -9,8 +9,8 @@ from circuits.models import Provider
from dcim.filtersets import InterfaceFilterSet
from dcim.models import Interface, Site
from netbox.views import generic
from utilities.query import count_related
from utilities.tables import get_table_ordering
from utilities.utils import count_related
from utilities.views import ViewTab, register_model_view
from virtualization.filtersets import VMInterfaceFilterSet
from virtualization.models import VMInterface

View File

@ -2,9 +2,10 @@ from django.contrib.contenttypes.models import ContentType
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from core.models import ObjectType
from netbox.api.fields import ContentTypeField
from utilities.api import get_serializer_for_model
from utilities.utils import content_type_identifier
from utilities.object_types import object_type_identifier
__all__ = (
'GenericObjectSerializer',
@ -27,9 +28,9 @@ class GenericObjectSerializer(serializers.Serializer):
return model.objects.get(pk=data['object_id'])
def to_representation(self, instance):
ct = ContentType.objects.get_for_model(instance)
object_type = ObjectType.objects.get_for_model(instance)
data = {
'object_type': content_type_identifier(ct),
'object_type': object_type_identifier(object_type),
'object_id': instance.pk,
}
if 'request' in self.context:

View File

@ -12,7 +12,7 @@ from django.utils.translation import gettext_lazy as _
from users.constants import CONSTRAINT_TOKEN_USER
from users.models import Group, ObjectPermission
from utilities.permissions import (
permission_is_exempt, qs_filter_from_constraints, resolve_permission, resolve_permission_ct,
permission_is_exempt, qs_filter_from_constraints, resolve_permission, resolve_permission_type,
)
UserModel = get_user_model()
@ -284,11 +284,9 @@ class RemoteUserBackend(_RemoteUserBackend):
permissions_list = []
for permission_name, constraints in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS.items():
try:
object_type, action = resolve_permission_ct(
permission_name)
# TODO: Merge multiple actions into a single ObjectPermission per content type
obj_perm = ObjectPermission(
actions=[action], constraints=constraints)
object_type, action = resolve_permission_type(permission_name)
# TODO: Merge multiple actions into a single ObjectPermission per object type
obj_perm = ObjectPermission(actions=[action], constraints=constraints)
obj_perm.save()
obj_perm.users.add(user)
obj_perm.object_types.add(object_type)
@ -303,7 +301,9 @@ class RemoteUserBackend(_RemoteUserBackend):
f"Assigned permissions to remotely-authenticated user {user}: {permissions_list}")
else:
logger.debug(
f"Skipped initial assignment of permissions and groups to remotely-authenticated user {user} as Group sync is enabled")
f"Skipped initial assignment of permissions and groups to remotely-authenticated user {user} as "
f"Group sync is enabled"
)
return user

162
netbox/netbox/choices.py Normal file
View File

@ -0,0 +1,162 @@
from django.utils.translation import gettext_lazy as _
from utilities.choices import ChoiceSet
from utilities.constants import CSV_DELIMITERS
__all__ = (
'ButtonColorChoices',
'ColorChoices',
'CSVDelimiterChoices',
'ImportFormatChoices',
'ImportMethodChoices',
)
#
# Generic color choices
#
class ColorChoices(ChoiceSet):
COLOR_DARK_RED = 'aa1409'
COLOR_RED = 'f44336'
COLOR_PINK = 'e91e63'
COLOR_ROSE = 'ffe4e1'
COLOR_FUCHSIA = 'ff66ff'
COLOR_PURPLE = '9c27b0'
COLOR_DARK_PURPLE = '673ab7'
COLOR_INDIGO = '3f51b5'
COLOR_BLUE = '2196f3'
COLOR_LIGHT_BLUE = '03a9f4'
COLOR_CYAN = '00bcd4'
COLOR_TEAL = '009688'
COLOR_AQUA = '00ffff'
COLOR_DARK_GREEN = '2f6a31'
COLOR_GREEN = '4caf50'
COLOR_LIGHT_GREEN = '8bc34a'
COLOR_LIME = 'cddc39'
COLOR_YELLOW = 'ffeb3b'
COLOR_AMBER = 'ffc107'
COLOR_ORANGE = 'ff9800'
COLOR_DARK_ORANGE = 'ff5722'
COLOR_BROWN = '795548'
COLOR_LIGHT_GREY = 'c0c0c0'
COLOR_GREY = '9e9e9e'
COLOR_DARK_GREY = '607d8b'
COLOR_BLACK = '111111'
COLOR_WHITE = 'ffffff'
CHOICES = (
(COLOR_DARK_RED, _('Dark Red')),
(COLOR_RED, _('Red')),
(COLOR_PINK, _('Pink')),
(COLOR_ROSE, _('Rose')),
(COLOR_FUCHSIA, _('Fuchsia')),
(COLOR_PURPLE, _('Purple')),
(COLOR_DARK_PURPLE, _('Dark Purple')),
(COLOR_INDIGO, _('Indigo')),
(COLOR_BLUE, _('Blue')),
(COLOR_LIGHT_BLUE, _('Light Blue')),
(COLOR_CYAN, _('Cyan')),
(COLOR_TEAL, _('Teal')),
(COLOR_AQUA, _('Aqua')),
(COLOR_DARK_GREEN, _('Dark Green')),
(COLOR_GREEN, _('Green')),
(COLOR_LIGHT_GREEN, _('Light Green')),
(COLOR_LIME, _('Lime')),
(COLOR_YELLOW, _('Yellow')),
(COLOR_AMBER, _('Amber')),
(COLOR_ORANGE, _('Orange')),
(COLOR_DARK_ORANGE, _('Dark Orange')),
(COLOR_BROWN, _('Brown')),
(COLOR_LIGHT_GREY, _('Light Grey')),
(COLOR_GREY, _('Grey')),
(COLOR_DARK_GREY, _('Dark Grey')),
(COLOR_BLACK, _('Black')),
(COLOR_WHITE, _('White')),
)
#
# Button color choices
#
class ButtonColorChoices(ChoiceSet):
"""
Map standard button color choices to Bootstrap 3 button classes
"""
DEFAULT = 'outline-dark'
BLUE = 'blue'
INDIGO = 'indigo'
PURPLE = 'purple'
PINK = 'pink'
RED = 'red'
ORANGE = 'orange'
YELLOW = 'yellow'
GREEN = 'green'
TEAL = 'teal'
CYAN = 'cyan'
GRAY = 'gray'
GREY = 'gray' # Backward compatability for <3.2
BLACK = 'black'
WHITE = 'white'
CHOICES = (
(DEFAULT, _('Default')),
(BLUE, _('Blue')),
(INDIGO, _('Indigo')),
(PURPLE, _('Purple')),
(PINK, _('Pink')),
(RED, _('Red')),
(ORANGE, _('Orange')),
(YELLOW, _('Yellow')),
(GREEN, _('Green')),
(TEAL, _('Teal')),
(CYAN, _('Cyan')),
(GRAY, _('Gray')),
(BLACK, _('Black')),
(WHITE, _('White')),
)
#
# Import Choices
#
class ImportMethodChoices(ChoiceSet):
DIRECT = 'direct'
UPLOAD = 'upload'
DATA_FILE = 'datafile'
CHOICES = [
(DIRECT, _('Direct')),
(UPLOAD, _('Upload')),
(DATA_FILE, _('Data file')),
]
class ImportFormatChoices(ChoiceSet):
AUTO = 'auto'
CSV = 'csv'
JSON = 'json'
YAML = 'yaml'
CHOICES = [
(AUTO, _('Auto-detect')),
(CSV, 'CSV'),
(JSON, 'JSON'),
(YAML, 'YAML'),
]
class CSVDelimiterChoices(ChoiceSet):
AUTO = 'auto'
COMMA = CSV_DELIMITERS['comma']
SEMICOLON = CSV_DELIMITERS['semicolon']
TAB = CSV_DELIMITERS['tab']
CHOICES = [
(AUTO, _('Auto-detect')),
(COMMA, _('Comma')),
(SEMICOLON, _('Semicolon')),
(TAB, _('Tab')),
]

View File

@ -13,7 +13,8 @@ from django.http import Http404, HttpResponseRedirect
from extras.context_managers import event_tracking
from netbox.config import clear_config, get_config
from netbox.views import handler_500
from utilities.api import is_api_request, rest_api_server_error
from utilities.api import is_api_request
from utilities.error_handlers import handle_rest_api_exception
__all__ = (
'CoreMiddleware',
@ -71,7 +72,7 @@ class CoreMiddleware:
# Cleanly handle exceptions that occur from REST API requests
if is_api_request(request):
return rest_api_server_error(request)
return handle_rest_api_exception(request)
# Ignore Http404s (defer to Django's built-in 404 handling)
if isinstance(exception, Http404):
@ -211,7 +212,7 @@ class MaintenanceModeMiddleware:
'operations. Please try again later.'
if is_api_request(request):
return rest_api_server_error(request, error=error_message)
return handle_rest_api_exception(request, error=error_message)
messages.error(request, error_message)
return HttpResponseRedirect(request.path_info)

View File

@ -17,7 +17,7 @@ from netbox.config import get_config
from netbox.registry import registry
from netbox.signals import post_clean
from utilities.json import CustomFieldJSONEncoder
from utilities.utils import serialize_object
from utilities.serialization import serialize_object
from utilities.views import register_model_view
__all__ = (

View File

@ -1,8 +1,6 @@
from dataclasses import dataclass
from typing import Sequence, Optional
from utilities.choices import ButtonColorChoices
__all__ = (
'get_model_item',

View File

@ -1,7 +1,6 @@
from django.utils.translation import gettext_lazy as _
from netbox.registry import registry
from utilities.choices import ButtonColorChoices
from . import *
#

View File

@ -1,8 +1,9 @@
from netbox.navigation import MenuGroup
from utilities.choices import ButtonColorChoices
from django.utils.text import slugify
from django.utils.translation import gettext as _
from netbox.choices import ButtonColorChoices
from netbox.navigation import MenuGroup
__all__ = (
'PluginMenu',
'PluginMenuButton',

View File

@ -14,8 +14,9 @@ from netaddr.core import AddrFormatError
from core.models import ObjectType
from extras.models import CachedValue, CustomField
from netbox.registry import registry
from utilities.object_types import object_type_identifier
from utilities.querysets import RestrictedPrefetch
from utilities.utils import content_type_identifier, title
from utilities.string import title
from . import FieldTypes, LookupTypes, get_indexer
DEFAULT_LOOKUP_TYPE = LookupTypes.PARTIAL
@ -156,7 +157,7 @@ class CachedValueSearchBackend(SearchBackend):
# related objects necessary to render the prescribed display attributes (display_attrs).
for object_type in object_types:
model = object_type.model_class()
indexer = registry['search'].get(content_type_identifier(object_type))
indexer = registry['search'].get(object_type_identifier(object_type))
if not (display_attrs := getattr(indexer, 'display_attrs', None)):
continue

View File

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

View File

@ -6,7 +6,7 @@ from django.db.models.signals import m2m_changed, pre_delete, post_save
from extras.choices import ChangeActionChoices
from extras.models import StagedChange
from utilities.utils import serialize_object
from utilities.serialization import serialize_object
logger = logging.getLogger('netbox.staging')

View File

@ -18,9 +18,10 @@ from django_tables2.columns import library
from django_tables2.utils import Accessor
from extras.choices import CustomFieldTypeChoices
from utilities.object_types import object_type_identifier, object_type_name
from utilities.permissions import get_permission_for_model
from utilities.templatetags.builtins.filters import render_markdown
from utilities.utils import content_type_identifier, content_type_name, get_viewname
from utilities.views import get_viewname
__all__ = (
'ActionsColumn',
@ -338,12 +339,12 @@ class ContentTypeColumn(tables.Column):
def render(self, value):
if value is None:
return None
return content_type_name(value, include_app=False)
return object_type_name(value, include_app=False)
def value(self, value):
if value is None:
return None
return content_type_identifier(value)
return object_type_identifier(value)
class ContentTypesColumn(tables.ManyToManyColumn):
@ -357,11 +358,11 @@ class ContentTypesColumn(tables.ManyToManyColumn):
super().__init__(separator=separator, *args, **kwargs)
def transform(self, obj):
return content_type_name(obj, include_app=False)
return object_type_name(obj, include_app=False)
def value(self, value):
return ','.join([
content_type_identifier(ct) for ct in self.filter(value)
object_type_identifier(ot) for ot in self.filter(value)
])

View File

@ -17,7 +17,9 @@ from extras.models import CustomField, CustomLink
from netbox.registry import registry
from netbox.tables import columns
from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.utils import get_viewname, highlight_string, title
from utilities.html import highlight
from utilities.string import title
from utilities.views import get_viewname
from .template_code import *
__all__ = (
@ -273,6 +275,6 @@ class SearchTable(tables.Table):
if not self.highlight:
return value
value = highlight_string(value, self.highlight, trim_pre=self.trim_length, trim_post=self.trim_length)
value = highlight(value, self.highlight, trim_pre=self.trim_length, trim_post=self.trim_length)
return mark_safe(value)

View File

@ -2,8 +2,8 @@ from django.test import override_settings
from core.models import ObjectType
from dcim.models import *
from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
from users.models import ObjectPermission
from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
from utilities.testing import ModelViewTestCase, create_tags

View File

@ -24,8 +24,7 @@ from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViol
from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields
from utilities.forms.bulk_import import BulkImportForm
from utilities.permissions import get_permission_for_model
from utilities.utils import get_viewname
from utilities.views import GetReturnURLMixin
from utilities.views import GetReturnURLMixin, get_viewname
from .base import BaseMultiObjectView
from .mixins import ActionsMixin, TableMixin
from .utils import get_prerequisite_model

View File

@ -18,8 +18,8 @@ from utilities.error_handlers import handle_protectederror
from utilities.exceptions import AbortRequest, PermissionsViolation
from utilities.forms import ConfirmationForm, restrict_form_fields
from utilities.permissions import get_permission_for_model
from utilities.utils import get_viewname, normalize_querydict, prepare_cloned_fields
from utilities.views import GetReturnURLMixin
from utilities.querydict import normalize_querydict, prepare_cloned_fields
from utilities.views import GetReturnURLMixin, get_viewname
from .base import BaseObjectView
from .mixins import ActionsMixin, TableMixin
from .utils import get_prerequisite_model

View File

@ -41,7 +41,7 @@ Blocks:
{# Top menu #}
<header class="navbar navbar-expand-md d-none d-lg-flex d-print-none">
<div class="container-xl">
<div class="container-fluid">
{# Nav menu toggle #}
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-menu" aria-controls="navbar-menu" aria-expanded="false" aria-label="Toggle navigation">
@ -105,7 +105,7 @@ Blocks:
{# Page body #}
{% block page %}
<div class="page-body my-1">
<div class="container-xl tab-content py-3">
<div class="container-fluid tab-content py-3">
{# Page content #}
{% block content %}{% endblock %}
@ -124,7 +124,7 @@ Blocks:
{# Page footer #}
<footer class="footer footer-transparent d-print-none py-2">
<div class="container-xl d-flex justify-content-between align-items-center">
<div class="container-fluid d-flex justify-content-between align-items-center">
{% block footer %}
{# Footer links #}

View File

@ -5,7 +5,7 @@
{% load render_table from django_tables2 %}
{% block page-header %}
<div class="container-xl">
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mt-2">
{# Breadcrumbs #}
<nav class="breadcrumb-container" aria-label="breadcrumb">

View File

@ -4,7 +4,7 @@
{% load render_table from django_tables2 %}
{% block page-header %}
<div class="container-xl">
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mt-2">
{# Breadcrumbs #}
<nav class="breadcrumb-container" aria-label="breadcrumb">

View File

@ -11,7 +11,7 @@
{% endblock %}
{% block page-header %}
<div class="container-xl mt-2">
<div class="container-fluid mt-2">
<nav class="breadcrumb-container" aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}">{% trans "Scripts" %}</a></li>

View File

@ -5,7 +5,7 @@
{{ block.super }}
{% block page-header %}
<div class="container-xl mt-2 d-print-none">
<div class="container-fluid mt-2 d-print-none">
<div class="d-flex justify-content-between">
{# Title #}
@ -29,7 +29,7 @@
{# Tabs #}
<div class="page-tabs mt-3">
<div class="container-xl">
<div class="container-fluid">
{% block tabs %}{% endblock %}
</div>
</div>

View File

@ -71,7 +71,7 @@ Context:
{# Selected objects list #}
<div class="tab-pane" id="object-list" role="tabpanel" aria-labelledby="object-list-tab">
<div class="card">
<div class="card-body table-responsive">
<div class="table-responsive">
{% render_table table 'inc/table.html' %}
</div>
</div>

View File

@ -33,9 +33,11 @@
</div>
</div>
</div>
<div class="container-xl px-0">
<div class="table-responsive">
{% render_table table 'inc/table.html' %}
<div class="container-fluid px-0">
<div class="card">
<div class="table-responsive">
{% render_table table 'inc/table.html' %}
</div>
</div>
<form action="." method="post" class="form">
{% csrf_token %}

View File

@ -19,7 +19,7 @@ Context:
{% endcomment %}
{% block page-header %}
<div class="container-xl">
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mt-2">
{# Breadcrumbs #}

View File

@ -3,7 +3,8 @@ from django.shortcuts import get_object_or_404
from django.utils.translation import gettext as _
from netbox.views import generic
from utilities.utils import count_related, get_related_models
from utilities.query import count_related
from utilities.relations import get_related_models
from utilities.views import register_model_view, ViewTab
from . import filtersets, forms, tables
from .models import *

View File

@ -14,8 +14,8 @@ from rest_framework.viewsets import ViewSet
from netbox.api.viewsets import NetBoxModelViewSet
from users import filtersets
from users.models import Group, ObjectPermission, Token, UserConfig
from utilities.data import deepmerge
from utilities.querysets import RestrictedQuerySet
from utilities.utils import deepmerge
from . import serializers

View File

@ -12,11 +12,11 @@ from ipam.validators import prefix_validator
from netbox.preferences import PREFERENCES
from users.constants import *
from users.models import *
from utilities.data import flatten_dict
from utilities.forms.fields import ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import DateTimePicker
from utilities.permissions import qs_filter_from_constraints
from utilities.utils import flatten_dict
__all__ = (
'UserTokenForm',

View File

@ -25,8 +25,8 @@ from netaddr import IPNetwork
from core.models import ObjectType
from ipam.fields import IPNetworkField
from netbox.config import get_config
from utilities.data import flatten_dict
from utilities.querysets import RestrictedQuerySet
from utilities.utils import flatten_dict
from .constants import *
__all__ = (

View File

@ -3,8 +3,8 @@ from django.urls import reverse
from core.models import ObjectType
from users.models import Group, ObjectPermission, Token
from utilities.data import deepmerge
from utilities.testing import APIViewTestCases, APITestCase, create_test_user
from utilities.utils import deepmerge
User = get_user_model()

View File

@ -1,22 +1,19 @@
import platform
import sys
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import (
FieldDoesNotExist, FieldError, MultipleObjectsReturned, ObjectDoesNotExist, ValidationError,
)
from django.db.models.fields.related import ManyToOneRel, RelatedField
from django.http import JsonResponse
from django.urls import reverse
from django.utils.module_loading import import_string
from django.utils.translation import gettext_lazy as _
from rest_framework import status
from rest_framework.serializers import Serializer
from rest_framework.utils import formatting
from rest_framework.views import get_view_name as drf_get_view_name
from netbox.api.fields import RelatedObjectCountField
from extras.constants import HTTP_CONTENT_TYPE_JSON
from netbox.api.exceptions import GraphQLTypeNotFound, SerializerNotFound
from .utils import count_related, dict_to_filter_params, dynamic_import
from netbox.api.fields import RelatedObjectCountField
from .query import count_related, dict_to_filter_params
from .string import title
__all__ = (
'get_annotations_for_serializer',
@ -26,19 +23,18 @@ __all__ = (
'get_serializer_for_model',
'get_view_name',
'is_api_request',
'rest_api_server_error',
)
def get_serializer_for_model(model, prefix=''):
"""
Dynamically resolve and return the appropriate serializer for a model.
Return the appropriate REST API serializer for the given model.
"""
app_label, model_name = model._meta.label.split('.')
serializer_name = f'{app_label}.api.serializers.{prefix}{model_name}Serializer'
try:
return dynamic_import(serializer_name)
except AttributeError:
return import_string(serializer_name)
except ImportError:
raise SerializerNotFound(
f"Could not determine serializer for {app_label}.{model_name} with prefix '{prefix}'"
)
@ -48,15 +44,12 @@ def get_graphql_type_for_model(model):
"""
Return the GraphQL type class for the given model.
"""
app_name, model_name = model._meta.label.split('.')
# Object types for Django's auth models are in the users app
if app_name == 'auth':
app_name = 'users'
class_name = f'{app_name}.graphql.types.{model_name}Type'
app_label, model_name = model._meta.label.split('.')
class_name = f'{app_label}.graphql.types.{model_name}Type'
try:
return dynamic_import(class_name)
except AttributeError:
raise GraphQLTypeNotFound(f"Could not find GraphQL type for {app_name}.{model_name}")
return import_string(class_name)
except ImportError:
raise GraphQLTypeNotFound(f"Could not find GraphQL type for {app_label}.{model_name}")
def is_api_request(request):
@ -64,30 +57,23 @@ def is_api_request(request):
Return True of the request is being made via the REST API.
"""
api_path = reverse('api-root')
return request.path_info.startswith(api_path) and request.content_type == 'application/json'
return request.path_info.startswith(api_path) and request.content_type == HTTP_CONTENT_TYPE_JSON
def get_view_name(view, suffix=None):
def get_view_name(view):
"""
Derive the view name from its associated model, if it has one. Fall back to DRF's built-in `get_view_name`.
Derive the view name from its associated model, if it has one. Fall back to DRF's built-in `get_view_name()`.
This function is provided to DRF as its VIEW_NAME_FUNCTION.
"""
if hasattr(view, 'queryset'):
# Determine the model name from the queryset.
name = view.queryset.model._meta.verbose_name
name = ' '.join([w[0].upper() + w[1:] for w in name.split()]) # Capitalize each word
# Derive the model name from the queryset.
name = title(view.queryset.model._meta.verbose_name)
if suffix := getattr(view, 'suffix', None):
name = f'{name} {suffix}'
return name
else:
# Replicate DRF's built-in behavior.
name = view.__class__.__name__
name = formatting.remove_trailing_string(name, 'View')
name = formatting.remove_trailing_string(name, 'ViewSet')
name = formatting.camelcase_to_spaces(name)
if suffix:
name += ' ' + suffix
return name
# Fall back to DRF's default behavior
return drf_get_view_name(view)
def get_prefetches_for_serializer(serializer_class, fields_to_include=None):
@ -189,17 +175,3 @@ def get_related_object_by_attrs(queryset, attrs):
return queryset.get(pk=pk)
except ObjectDoesNotExist:
raise ValidationError(_("Related object not found using the provided numeric ID: {id}").format(id=pk))
def rest_api_server_error(request, *args, **kwargs):
"""
Handle exceptions and return a useful error message for REST API requests.
"""
type_, error, traceback = sys.exc_info()
data = {
'error': str(error),
'exception': type_.__name__,
'netbox_version': settings.VERSION,
'python_version': platform.python_version(),
}
return JsonResponse(data, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

View File

@ -1,7 +1,10 @@
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from .constants import CSV_DELIMITERS
__all__ = (
'ChoiceSet',
'unpack_grouped_choices',
)
class ChoiceSetMeta(type):
@ -96,153 +99,3 @@ def unpack_grouped_choices(choices):
else:
unpacked_choices.append((key, value))
return unpacked_choices
#
# Generic color choices
#
class ColorChoices(ChoiceSet):
COLOR_DARK_RED = 'aa1409'
COLOR_RED = 'f44336'
COLOR_PINK = 'e91e63'
COLOR_ROSE = 'ffe4e1'
COLOR_FUCHSIA = 'ff66ff'
COLOR_PURPLE = '9c27b0'
COLOR_DARK_PURPLE = '673ab7'
COLOR_INDIGO = '3f51b5'
COLOR_BLUE = '2196f3'
COLOR_LIGHT_BLUE = '03a9f4'
COLOR_CYAN = '00bcd4'
COLOR_TEAL = '009688'
COLOR_AQUA = '00ffff'
COLOR_DARK_GREEN = '2f6a31'
COLOR_GREEN = '4caf50'
COLOR_LIGHT_GREEN = '8bc34a'
COLOR_LIME = 'cddc39'
COLOR_YELLOW = 'ffeb3b'
COLOR_AMBER = 'ffc107'
COLOR_ORANGE = 'ff9800'
COLOR_DARK_ORANGE = 'ff5722'
COLOR_BROWN = '795548'
COLOR_LIGHT_GREY = 'c0c0c0'
COLOR_GREY = '9e9e9e'
COLOR_DARK_GREY = '607d8b'
COLOR_BLACK = '111111'
COLOR_WHITE = 'ffffff'
CHOICES = (
(COLOR_DARK_RED, _('Dark Red')),
(COLOR_RED, _('Red')),
(COLOR_PINK, _('Pink')),
(COLOR_ROSE, _('Rose')),
(COLOR_FUCHSIA, _('Fuchsia')),
(COLOR_PURPLE, _('Purple')),
(COLOR_DARK_PURPLE, _('Dark Purple')),
(COLOR_INDIGO, _('Indigo')),
(COLOR_BLUE, _('Blue')),
(COLOR_LIGHT_BLUE, _('Light Blue')),
(COLOR_CYAN, _('Cyan')),
(COLOR_TEAL, _('Teal')),
(COLOR_AQUA, _('Aqua')),
(COLOR_DARK_GREEN, _('Dark Green')),
(COLOR_GREEN, _('Green')),
(COLOR_LIGHT_GREEN, _('Light Green')),
(COLOR_LIME, _('Lime')),
(COLOR_YELLOW, _('Yellow')),
(COLOR_AMBER, _('Amber')),
(COLOR_ORANGE, _('Orange')),
(COLOR_DARK_ORANGE, _('Dark Orange')),
(COLOR_BROWN, _('Brown')),
(COLOR_LIGHT_GREY, _('Light Grey')),
(COLOR_GREY, _('Grey')),
(COLOR_DARK_GREY, _('Dark Grey')),
(COLOR_BLACK, _('Black')),
(COLOR_WHITE, _('White')),
)
#
# Button color choices
#
class ButtonColorChoices(ChoiceSet):
"""
Map standard button color choices to Bootstrap 3 button classes
"""
DEFAULT = 'outline-dark'
BLUE = 'blue'
INDIGO = 'indigo'
PURPLE = 'purple'
PINK = 'pink'
RED = 'red'
ORANGE = 'orange'
YELLOW = 'yellow'
GREEN = 'green'
TEAL = 'teal'
CYAN = 'cyan'
GRAY = 'gray'
GREY = 'gray' # Backward compatability for <3.2
BLACK = 'black'
WHITE = 'white'
CHOICES = (
(DEFAULT, _('Default')),
(BLUE, _('Blue')),
(INDIGO, _('Indigo')),
(PURPLE, _('Purple')),
(PINK, _('Pink')),
(RED, _('Red')),
(ORANGE, _('Orange')),
(YELLOW, _('Yellow')),
(GREEN, _('Green')),
(TEAL, _('Teal')),
(CYAN, _('Cyan')),
(GRAY, _('Gray')),
(BLACK, _('Black')),
(WHITE, _('White')),
)
#
# Import Choices
#
class ImportMethodChoices(ChoiceSet):
DIRECT = 'direct'
UPLOAD = 'upload'
DATA_FILE = 'datafile'
CHOICES = [
(DIRECT, _('Direct')),
(UPLOAD, _('Upload')),
(DATA_FILE, _('Data file')),
]
class ImportFormatChoices(ChoiceSet):
AUTO = 'auto'
CSV = 'csv'
JSON = 'json'
YAML = 'yaml'
CHOICES = [
(AUTO, _('Auto-detect')),
(CSV, 'CSV'),
(JSON, 'JSON'),
(YAML, 'YAML'),
]
class CSVDelimiterChoices(ChoiceSet):
AUTO = 'auto'
COMMA = CSV_DELIMITERS['comma']
SEMICOLON = CSV_DELIMITERS['semicolon']
TAB = CSV_DELIMITERS['tab']
CHOICES = [
(AUTO, _('Auto-detect')),
(COMMA, _('Comma')),
(SEMICOLON, _('Semicolon')),
(TAB, _('Tab')),
]

View File

@ -0,0 +1,66 @@
from decimal import Decimal
from django.utils.translation import gettext as _
from dcim.choices import CableLengthUnitChoices, WeightUnitChoices
__all__ = (
'to_grams',
'to_meters',
)
def to_grams(weight, unit):
"""
Convert the given weight to kilograms.
"""
try:
if weight < 0:
raise ValueError(_("Weight must be a positive number"))
except TypeError:
raise TypeError(_("Invalid value '{weight}' for weight (must be a number)").format(weight=weight))
if unit == WeightUnitChoices.UNIT_KILOGRAM:
return weight * 1000
if unit == WeightUnitChoices.UNIT_GRAM:
return weight
if unit == WeightUnitChoices.UNIT_POUND:
return weight * Decimal(453.592)
if unit == WeightUnitChoices.UNIT_OUNCE:
return weight * Decimal(28.3495)
raise ValueError(
_("Unknown unit {unit}. Must be one of the following: {valid_units}").format(
unit=unit,
valid_units=', '.join(WeightUnitChoices.values())
)
)
def to_meters(length, unit):
"""
Convert the given length to meters.
"""
try:
if length < 0:
raise ValueError(_("Length must be a positive number"))
except TypeError:
raise TypeError(_("Invalid value '{length}' for length (must be a number)").format(length=length))
if unit == CableLengthUnitChoices.UNIT_KILOMETER:
return length * 1000
if unit == CableLengthUnitChoices.UNIT_METER:
return length
if unit == CableLengthUnitChoices.UNIT_CENTIMETER:
return length / 100
if unit == CableLengthUnitChoices.UNIT_MILE:
return length * Decimal(1609.344)
if unit == CableLengthUnitChoices.UNIT_FOOT:
return length * Decimal(0.3048)
if unit == CableLengthUnitChoices.UNIT_INCH:
return length * Decimal(0.0254)
raise ValueError(
_("Unknown unit {unit}. Must be one of the following: {valid_units}").format(
unit=unit,
valid_units=', '.join(CableLengthUnitChoices.values())
)
)

115
netbox/utilities/data.py Normal file
View File

@ -0,0 +1,115 @@
import decimal
from itertools import count, groupby
__all__ = (
'array_to_ranges',
'array_to_string',
'deepmerge',
'drange',
'flatten_dict',
'shallow_compare_dict',
)
#
# Dictionary utilities
#
def deepmerge(original, new):
"""
Deep merge two dictionaries (new into original) and return a new dict
"""
merged = dict(original)
for key, val in new.items():
if key in original and isinstance(original[key], dict) and val and isinstance(val, dict):
merged[key] = deepmerge(original[key], val)
else:
merged[key] = val
return merged
def flatten_dict(d, prefix='', separator='.'):
"""
Flatten nested dictionaries into a single level by joining key names with a separator.
:param d: The dictionary to be flattened
:param prefix: Initial prefix (if any)
:param separator: The character to use when concatenating key names
"""
ret = {}
for k, v in d.items():
key = separator.join([prefix, k]) if prefix else k
if type(v) is dict:
ret.update(flatten_dict(v, prefix=key, separator=separator))
else:
ret[key] = v
return ret
def shallow_compare_dict(source_dict, destination_dict, exclude=tuple()):
"""
Return a new dictionary of the different keys. The values of `destination_dict` are returned. Only the equality of
the first layer of keys/values is checked. `exclude` is a list or tuple of keys to be ignored.
"""
difference = {}
for key, value in destination_dict.items():
if key in exclude:
continue
if source_dict.get(key) != value:
difference[key] = value
return difference
#
# Array utilities
#
def array_to_ranges(array):
"""
Convert an arbitrary array of integers to a list of consecutive values. Nonconsecutive values are returned as
single-item tuples. For example:
[0, 1, 2, 10, 14, 15, 16] => [(0, 2), (10,), (14, 16)]"
"""
group = (
list(x) for _, x in groupby(sorted(array), lambda x, c=count(): next(c) - x)
)
return [
(g[0], g[-1])[:len(g)] for g in group
]
def array_to_string(array):
"""
Generate an efficient, human-friendly string from a set of integers. Intended for use with ArrayField.
For example:
[0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16"
"""
ret = []
ranges = array_to_ranges(array)
for value in ranges:
if len(value) == 1:
ret.append(str(value[0]))
else:
ret.append(f'{value[0]}-{value[1]}')
return ', '.join(ret)
#
# Range utilities
#
def drange(start, end, step=decimal.Decimal(1)):
"""
Decimal-compatible implementation of Python's range()
"""
start, end, step = decimal.Decimal(start), decimal.Decimal(end), decimal.Decimal(step)
if start < end:
while start < end:
yield start
start += step
else:
while start > end:
yield start
start += step

View File

@ -0,0 +1,13 @@
from django.utils import timezone
from django.utils.timezone import localtime
__all__ = (
'local_now',
)
def local_now():
"""
Return the current date & time in the system timezone.
"""
return localtime(timezone.now())

View File

@ -1,8 +1,19 @@
import platform
import sys
from django.conf import settings
from django.contrib import messages
from django.db.models import ProtectedError, RestrictedError
from django.http import JsonResponse
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from rest_framework import status
__all__ = (
'handle_protectederror',
'handle_rest_api_exception',
)
def handle_protectederror(obj_list, request, e):
@ -32,3 +43,17 @@ def handle_protectederror(obj_list, request, e):
err_message += ', '.join(dependent_objects)
messages.error(request, mark_safe(err_message))
def handle_rest_api_exception(request, *args, **kwargs):
"""
Handle exceptions and return a useful error message for REST API requests.
"""
type_, error, traceback = sys.exc_info()
data = {
'error': str(error),
'exception': type_.__name__,
'netbox_version': settings.VERSION,
'python_version': platform.python_version(),
}
return JsonResponse(data, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

View File

@ -1,13 +0,0 @@
import hashlib
__all__ = (
'sha256_hash',
)
def sha256_hash(filepath):
"""
Return the SHA256 hash of the file at the specified path.
"""
with open(filepath, 'rb') as f:
return hashlib.sha256(f.read())

View File

@ -8,7 +8,6 @@ from drf_spectacular.types import OpenApiTypes
__all__ = (
'ContentTypeFilter',
'MACAddressFilter',
'MultiValueArrayFilter',
'MultiValueCharFilter',
'MultiValueDateFilter',
@ -101,10 +100,6 @@ class MultiValueArrayFilter(django_filters.MultipleChoiceFilter):
return super().get_filter_predicate(v)
class MACAddressFilter(django_filters.CharFilter):
pass
@extend_schema_field(OpenApiTypes.STR)
class MultiValueMACAddressFilter(django_filters.MultipleChoiceFilter):
field_class = multivalue_field_factory(forms.CharField)

View File

@ -7,7 +7,7 @@ from django import forms
from django.utils.translation import gettext as _
from core.forms.mixins import SyncedDataMixin
from utilities.choices import CSVDelimiterChoices, ImportFormatChoices, ImportMethodChoices
from netbox.choices import CSVDelimiterChoices, ImportFormatChoices, ImportMethodChoices
from utilities.constants import CSV_DELIMITERS
from utilities.forms.utils import parse_csv

View File

@ -1,6 +1,6 @@
from django import forms
from utilities.utils import content_type_name
from utilities.object_types import object_type_name
__all__ = (
'ContentTypeChoiceField',
@ -17,7 +17,7 @@ class ContentTypeChoiceMixin:
def label_from_instance(self, obj):
try:
return content_type_name(obj)
return object_type_name(obj)
except AttributeError:
return super().label_from_instance(obj)

View File

@ -5,7 +5,7 @@ from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
from django.db.models import Q
from utilities.choices import unpack_grouped_choices
from utilities.utils import content_type_identifier
from utilities.object_types import object_type_identifier
__all__ = (
'CSVChoiceField',
@ -86,7 +86,7 @@ class CSVContentTypeField(CSVModelChoiceField):
STATIC_CHOICES = True
def prepare_value(self, value):
return content_type_identifier(value)
return object_type_identifier(value)
def to_python(self, value):
if not value:
@ -115,4 +115,4 @@ class CSVMultipleContentTypeField(forms.ModelMultipleChoiceField):
app_label, model = name.split('.')
ct_filter |= Q(app_label=app_label, model=model)
return list(ContentType.objects.filter(ct_filter).values_list('pk', flat=True))
return content_type_identifier(value)
return object_type_identifier(value)

View File

@ -5,7 +5,7 @@ from django.forms import BoundField
from django.urls import reverse
from utilities.forms import widgets
from utilities.utils import get_viewname
from utilities.views import get_viewname
__all__ = (
'DynamicChoiceField',

View File

@ -1,6 +1,6 @@
from django import forms
from utilities.choices import ColorChoices
from netbox.choices import ColorChoices
from ..utils import add_blank_choice
__all__ = (

72
netbox/utilities/html.py Normal file
View File

@ -0,0 +1,72 @@
import re
import nh3
from django.utils.html import escape
from .constants import HTML_ALLOWED_ATTRIBUTES, HTML_ALLOWED_TAGS
__all__ = (
'clean_html',
'foreground_color',
'highlight',
)
def clean_html(html, schemes):
"""
Sanitizes HTML based on a whitelist of allowed tags and attributes.
Also takes a list of allowed URI schemes.
"""
return nh3.clean(
html,
tags=HTML_ALLOWED_TAGS,
attributes=HTML_ALLOWED_ATTRIBUTES,
url_schemes=set(schemes)
)
def foreground_color(bg_color, dark='000000', light='ffffff'):
"""
Return the ideal foreground color (dark or light) for a given background color in hexadecimal RGB format.
:param dark: RBG color code for dark text
:param light: RBG color code for light text
"""
THRESHOLD = 150
bg_color = bg_color.strip('#')
r, g, b = [int(bg_color[c:c + 2], 16) for c in (0, 2, 4)]
if r * 0.299 + g * 0.587 + b * 0.114 > THRESHOLD:
return dark
else:
return light
def highlight(value, highlight, trim_pre=None, trim_post=None, trim_placeholder='...'):
"""
Highlight a string within a string and optionally trim the pre/post portions of the original string.
Args:
value: The body of text being searched against
highlight: The string of compiled regex pattern to highlight in `value`
trim_pre: Maximum length of pre-highlight text to include
trim_post: Maximum length of post-highlight text to include
trim_placeholder: String value to swap in for trimmed pre/post text
"""
# Split value on highlight string
try:
if type(highlight) is re.Pattern:
pre, match, post = highlight.split(value, maxsplit=1)
else:
highlight = re.escape(highlight)
pre, match, post = re.split(fr'({highlight})', value, maxsplit=1, flags=re.IGNORECASE)
except ValueError as e:
# Match not found
return escape(value)
# Trim pre/post sections to length
if trim_pre and len(pre) > trim_pre:
pre = trim_placeholder + pre[-trim_pre:]
if trim_post and len(post) > trim_post:
post = post[:trim_post] + trim_placeholder
return f'{escape(pre)}<mark>{escape(match)}</mark>{escape(post)}'

View File

@ -1,13 +1,16 @@
from django.apps import apps
from jinja2 import BaseLoader, TemplateNotFound
from jinja2.meta import find_referenced_templates
from jinja2.sandbox import SandboxedEnvironment
from netbox.config import get_config
__all__ = (
'ConfigTemplateLoader',
'DataFileLoader',
)
class ConfigTemplateLoader(BaseLoader):
class DataFileLoader(BaseLoader):
"""
Custom Jinja2 loader to facilitate populating template content from DataFiles.
"""
@ -35,3 +38,16 @@ class ConfigTemplateLoader(BaseLoader):
def cache_templates(self, templates):
self._template_cache.update(templates)
#
# Utility functions
#
def render_jinja2(template_code, context):
"""
Render a Jinja2 template with the provided context. Return the rendered content.
"""
environment = SandboxedEnvironment()
environment.filters.update(get_config().JINJA2_FILTERS)
return environment.from_string(source=template_code).render(**context)

View File

@ -1,5 +1,4 @@
from django.db import models
from timezone_field import TimeZoneField
from netbox.config import ConfigItem
@ -8,10 +7,6 @@ __all__ = (
)
SKIP_FIELDS = (
TimeZoneField,
)
EXEMPT_ATTRS = (
'choices',
'help_text',
@ -28,9 +23,8 @@ def custom_deconstruct(field):
name, path, args, kwargs = _deconstruct(field)
# Remove any ignored attributes
if field.__class__ not in SKIP_FIELDS:
for attr in EXEMPT_ATTRS:
kwargs.pop(attr, None)
for attr in EXEMPT_ATTRS:
kwargs.pop(attr, None)
# Ignore any field defaults which reference a ConfigItem
kwargs = {

View File

@ -0,0 +1,29 @@
from .string import title
__all__ = (
'object_type_identifier',
'object_type_name',
)
def object_type_identifier(object_type):
"""
Return a "raw" ObjectType identifier string suitable for bulk import/export (e.g. "dcim.site").
"""
return f'{object_type.app_label}.{object_type.model}'
def object_type_name(object_type, include_app=True):
"""
Return a human-friendly ObjectType name (e.g. "DCIM > Site").
"""
try:
meta = object_type.model_class()._meta
app_label = title(meta.app_config.verbose_name)
model_name = title(meta.verbose_name)
if include_app:
return f'{app_label} > {model_name}'
return model_name
except AttributeError:
# Model does not exist
return f'{object_type.app_label} > {object_type.model}'

View File

@ -7,7 +7,7 @@ __all__ = (
'permission_is_exempt',
'qs_filter_from_constraints',
'resolve_permission',
'resolve_permission_ct',
'resolve_permission_type',
)
@ -42,9 +42,9 @@ def resolve_permission(name):
return app_label, action, model_name
def resolve_permission_ct(name):
def resolve_permission_type(name):
"""
Given a permission name, return the relevant ContentType and action. For example, "dcim.view_site" returns
Given a permission name, return the relevant ObjectType and action. For example, "dcim.view_site" returns
(Site, "view").
:param name: Permission name in the format <app_label>.<action>_<model>
@ -52,7 +52,7 @@ def resolve_permission_ct(name):
from core.models import ObjectType
app_label, action, model_name = resolve_permission(name)
try:
object_type = ObjectType.objects.get(app_label=app_label, model=model_name)
object_type = ObjectType.objects.get_by_natural_key(app_label=app_label, model=model_name)
except ObjectType.DoesNotExist:
raise ValueError(_("Unknown app_label/model_name for {name}").format(name=name))

56
netbox/utilities/query.py Normal file
View File

@ -0,0 +1,56 @@
from django.db.models import Count, OuterRef, Subquery
from django.db.models.functions import Coalesce
__all__ = (
'count_related',
'dict_to_filter_params',
)
def count_related(model, field):
"""
Return a Subquery suitable for annotating a child object count.
"""
subquery = Subquery(
model.objects.filter(
**{field: OuterRef('pk')}
).order_by().values(
field
).annotate(
c=Count('*')
).values('c')
)
return Coalesce(subquery, 0)
def dict_to_filter_params(d, prefix=''):
"""
Translate a dictionary of attributes to a nested set of parameters suitable for QuerySet filtering. For example:
{
"name": "Foo",
"rack": {
"facility_id": "R101"
}
}
Becomes:
{
"name": "Foo",
"rack__facility_id": "R101"
}
And can be employed as filter parameters:
Device.objects.filter(**dict_to_filter(attrs_dict))
"""
params = {}
for key, val in d.items():
k = prefix + key
if isinstance(val, dict):
params.update(dict_to_filter_params(val, k + '__'))
else:
params[k] = val
return params

View File

@ -0,0 +1,64 @@
from urllib.parse import urlencode
from django.http import QueryDict
from django.utils.datastructures import MultiValueDict
__all__ = (
'dict_to_querydict',
'normalize_querydict',
'prepare_cloned_fields',
)
def dict_to_querydict(d, mutable=True):
"""
Create a QueryDict instance from a regular Python dictionary.
"""
qd = QueryDict(mutable=True)
for k, v in d.items():
item = MultiValueDict({k: v}) if isinstance(v, (list, tuple, set)) else {k: v}
qd.update(item)
if not mutable:
qd._mutable = False
return qd
def normalize_querydict(querydict):
"""
Convert a QueryDict to a normal, mutable dictionary, preserving list values. For example,
QueryDict('foo=1&bar=2&bar=3&baz=')
becomes:
{'foo': '1', 'bar': ['2', '3'], 'baz': ''}
This function is necessary because QueryDict does not provide any built-in mechanism which preserves multiple
values.
"""
return {
k: v if len(v) > 1 else v[0] for k, v in querydict.lists()
}
def prepare_cloned_fields(instance):
"""
Generate a QueryDict comprising attributes from an object's clone() method.
"""
# Generate the clone attributes from the instance
if not hasattr(instance, 'clone'):
return QueryDict(mutable=True)
attrs = instance.clone()
# Prepare QueryDict parameters
params = []
for key, value in attrs.items():
if type(value) in (list, tuple):
params.extend([(key, v) for v in value])
elif value not in (False, None):
params.append((key, value))
else:
params.append((key, ''))
# Return a QueryDict with the parameters
return QueryDict(urlencode(params), mutable=True)

View File

@ -0,0 +1,22 @@
from django.db.models import ManyToOneRel
__all__ = (
'get_related_models',
)
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.lower())
return related_models

View File

@ -2,11 +2,54 @@ from django.utils.translation import gettext_lazy as _
from netaddr import AddrFormatError, IPAddress
from urllib.parse import urlparse
from .constants import HTTP_REQUEST_META_SAFE_COPY
__all__ = (
'NetBoxFakeRequest',
'copy_safe_request',
'get_client_ip',
)
#
# Fake request object
#
class NetBoxFakeRequest:
"""
A fake request object which is explicitly defined at the module level so it is able to be pickled. It simply
takes what is passed to it as kwargs on init and sets them as instance variables.
"""
def __init__(self, _dict):
self.__dict__ = _dict
#
# Utility functions
#
def copy_safe_request(request):
"""
Copy selected attributes from a request object into a new fake request object. This is needed in places where
thread safe pickling of the useful request data is needed.
"""
meta = {
k: request.META[k]
for k in HTTP_REQUEST_META_SAFE_COPY
if k in request.META and isinstance(request.META[k], str)
}
return NetBoxFakeRequest({
'META': meta,
'COOKIES': request.COOKIES,
'POST': request.POST,
'GET': request.GET,
'FILES': request.FILES,
'user': request.user,
'path': request.path,
'id': getattr(request, 'id', None), # UUID assigned by middleware
})
def get_client_ip(request, additional_headers=()):
"""
Return the client (source) IP address of the given request.

View File

@ -0,0 +1,75 @@
import json
from django.contrib.contenttypes.models import ContentType
from django.core import serializers
from mptt.models import MPTTModel
from extras.utils import is_taggable
__all__ = (
'deserialize_object',
'serialize_object',
)
def serialize_object(obj, resolve_tags=True, extra=None, exclude=None):
"""
Return a generic JSON representation of an object using Django's built-in serializer. (This is used for things like
change logging, not the REST API.) Optionally include a dictionary to supplement the object data. A list of keys
can be provided to exclude them from the returned dictionary. Private fields (prefaced with an underscore) are
implicitly excluded.
Args:
obj: The object to serialize
resolve_tags: If true, any assigned tags will be represented by their names
extra: Any additional data to include in the serialized output. Keys provided in this mapping will
override object attributes.
exclude: An iterable of attributes to exclude from the serialized output
"""
json_str = serializers.serialize('json', [obj])
data = json.loads(json_str)[0]['fields']
exclude = exclude or []
# Exclude any MPTTModel fields
if issubclass(obj.__class__, MPTTModel):
for field in ['level', 'lft', 'rght', 'tree_id']:
data.pop(field)
# Include custom_field_data as "custom_fields"
if hasattr(obj, 'custom_field_data'):
data['custom_fields'] = data.pop('custom_field_data')
# Resolve any assigned tags to their names. Check for tags cached on the instance;
# fall back to using the manager.
if resolve_tags and is_taggable(obj):
tags = getattr(obj, '_tags', None) or obj.tags.all()
data['tags'] = sorted([tag.name for tag in tags])
# Skip excluded and private (prefixes with an underscore) attributes
for key in list(data.keys()):
if key in exclude or (isinstance(key, str) and key.startswith('_')):
data.pop(key)
# Append any extra data
if extra is not None:
data.update(extra)
return data
def deserialize_object(model, fields, pk=None):
"""
Instantiate an object from the given model and field data. Functions as
the complement to serialize_object().
"""
content_type = ContentType.objects.get_for_model(model)
if 'custom_fields' in fields:
fields['custom_field_data'] = fields.pop('custom_fields')
data = {
'model': '.'.join(content_type.natural_key()),
'pk': pk,
'fields': fields,
}
instance = list(serializers.deserialize('python', [data]))[0]
return instance

View File

@ -0,0 +1,10 @@
__all__ = (
'title',
)
def title(value):
"""
Improved implementation of str.title(); retains all existing uppercase letters.
"""
return ' '.join([w[0].upper() + w[1:] for w in str(value).split()])

View File

@ -11,8 +11,9 @@ from markdown import markdown
from markdown.extensions.tables import TableExtension
from netbox.config import get_config
from utilities.html import clean_html, foreground_color
from utilities.markdown import StrikethroughExtension
from utilities.utils import clean_html, foreground_color, title
from utilities.string import title
__all__ = (
'bettertitle',

View File

@ -1,8 +1,7 @@
from django import template
from django.http import QueryDict
from extras.choices import CustomFieldTypeChoices
from utilities.utils import dict_to_querydict
from utilities.querydict import dict_to_querydict
__all__ = (
'badge',

View File

@ -4,7 +4,8 @@ from django.urls import NoReverseMatch, reverse
from core.models import ObjectType
from extras.models import Bookmark, ExportTemplate
from utilities.utils import get_viewname, prepare_cloned_fields
from utilities.querydict import prepare_cloned_fields
from utilities.views import get_viewname
__all__ = (
'add_button',

View File

@ -12,7 +12,7 @@ from django.utils.safestring import mark_safe
from core.models import ObjectType
from utilities.forms import get_selected_values, TableConfigForm
from utilities.utils import get_viewname
from utilities.views import get_viewname
__all__ = (
'annotated_date',

View File

@ -4,7 +4,7 @@ from django.urls.exceptions import NoReverseMatch
from django.utils.module_loading import import_string
from netbox.registry import registry
from utilities.utils import get_viewname
from utilities.views import get_viewname
__all__ = (
'model_view_tabs',

View File

@ -12,8 +12,8 @@ from taggit.managers import TaggableManager
from core.models import ObjectType
from users.models import ObjectPermission
from utilities.permissions import resolve_permission_ct
from utilities.utils import content_type_identifier
from utilities.object_types import object_type_identifier
from utilities.permissions import resolve_permission_type
from .utils import extract_form_failures
__all__ = (
@ -44,11 +44,11 @@ class TestCase(_TestCase):
Assign a set of permissions to the test user. Accepts permission names in the form <app>.<action>_<model>.
"""
for name in names:
ct, action = resolve_permission_ct(name)
object_type, action = resolve_permission_type(name)
obj_perm = ObjectPermission(name=name, actions=[action])
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ct)
obj_perm.object_types.add(object_type)
#
# Custom assertions
@ -114,7 +114,7 @@ class ModelTestCase(TestCase):
if value and type(field) in (ManyToManyField, TaggableManager):
if field.related_model in (ContentType, ObjectType) and api:
model_dict[key] = sorted([content_type_identifier(ct) for ct in value])
model_dict[key] = sorted([object_type_identifier(ot) for ot in value])
else:
model_dict[key] = sorted([obj.pk for obj in value])
@ -122,8 +122,8 @@ class ModelTestCase(TestCase):
# Replace ContentType numeric IDs with <app_label>.<model>
if type(getattr(instance, key)) in (ContentType, ObjectType):
ct = ObjectType.objects.get(pk=value)
model_dict[key] = content_type_identifier(ct)
object_type = ObjectType.objects.get(pk=value)
model_dict[key] = object_type_identifier(object_type)
# Convert IPNetwork instances to strings
elif type(value) is IPNetwork:

View File

@ -11,9 +11,9 @@ from django.utils.translation import gettext as _
from core.models import ObjectType
from extras.choices import ObjectChangeActionChoices
from extras.models import ObjectChange
from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
from netbox.models.features import ChangeLoggingMixin
from users.models import ObjectPermission
from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
from .base import ModelTestCase
from .utils import disable_warnings, post_data

View File

@ -17,8 +17,8 @@ from ipam.filtersets import ASNFilterSet
from ipam.models import RIR, ASN
from netbox.filtersets import BaseFilterSet
from utilities.filters import (
MACAddressFilter, MultiValueCharFilter, MultiValueDateFilter, MultiValueDateTimeFilter, MultiValueNumberFilter,
MultiValueTimeFilter, TreeNodeMultipleChoiceFilter,
MultiValueCharFilter, MultiValueDateFilter, MultiValueDateTimeFilter, MultiValueMACAddressFilter,
MultiValueNumberFilter, MultiValueTimeFilter, TreeNodeMultipleChoiceFilter,
)
@ -113,7 +113,7 @@ class BaseFilterSetTest(TestCase):
class DummyFilterSet(BaseFilterSet):
charfield = django_filters.CharFilter()
numberfield = django_filters.NumberFilter()
macaddressfield = MACAddressFilter()
macaddressfield = MultiValueMACAddressFilter()
modelchoicefield = django_filters.ModelChoiceFilter(
field_name='integerfield', # We're pretending this is a ForeignKey field
queryset=Site.objects.all()
@ -198,7 +198,7 @@ class BaseFilterSetTest(TestCase):
self.assertEqual(self.filters['numberfield__empty'].exclude, False)
def test_mac_address_filter(self):
self.assertIsInstance(self.filters['macaddressfield'], MACAddressFilter)
self.assertIsInstance(self.filters['macaddressfield'], MultiValueMACAddressFilter)
self.assertEqual(self.filters['macaddressfield'].lookup_expr, 'exact')
self.assertEqual(self.filters['macaddressfield'].exclude, False)
self.assertEqual(self.filters['macaddressfield__n'].lookup_expr, 'exact')

View File

@ -1,7 +1,7 @@
from django import forms
from django.test import TestCase
from utilities.choices import ImportFormatChoices
from netbox.choices import ImportFormatChoices
from utilities.forms.bulk_import import BulkImportForm
from utilities.forms.forms import BulkRenameForm
from utilities.forms.utils import expand_alphanumeric_pattern, expand_ipaddress_pattern

Some files were not shown because too many files have changed in this diff Show More