mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-15 20:18:17 -06:00
Merge branch 'feature' into 15154-uwsgi
This commit is contained in:
commit
fa4a31a52e
@ -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')
|
||||
```
|
||||
|
@ -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',
|
||||
|
@ -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 *
|
||||
|
@ -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:
|
||||
|
@ -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 *
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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 *
|
||||
|
@ -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',
|
||||
|
@ -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
|
||||
|
@ -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__ = (
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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()
|
||||
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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()
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
||||
#
|
||||
|
@ -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')
|
||||
]
|
||||
|
||||
|
||||
|
@ -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')
|
||||
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
})
|
||||
|
@ -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',
|
||||
|
@ -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__ = (
|
||||
|
@ -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',
|
||||
|
@ -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__ = (
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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):
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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
162
netbox/netbox/choices.py
Normal 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')),
|
||||
]
|
@ -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)
|
||||
|
@ -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__ = (
|
||||
|
@ -1,8 +1,6 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Sequence, Optional
|
||||
|
||||
from utilities.choices import ButtonColorChoices
|
||||
|
||||
|
||||
__all__ = (
|
||||
'get_model_item',
|
||||
|
@ -1,7 +1,6 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from netbox.registry import registry
|
||||
from utilities.choices import ButtonColorChoices
|
||||
from . import *
|
||||
|
||||
#
|
||||
|
@ -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',
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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')
|
||||
|
||||
|
@ -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)
|
||||
])
|
||||
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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 #}
|
||||
|
@ -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">
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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 %}
|
||||
|
@ -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 #}
|
||||
|
@ -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 *
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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',
|
||||
|
@ -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__ = (
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
|
@ -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')),
|
||||
]
|
||||
|
66
netbox/utilities/conversion.py
Normal file
66
netbox/utilities/conversion.py
Normal 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
115
netbox/utilities/data.py
Normal 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
|
13
netbox/utilities/datetime.py
Normal file
13
netbox/utilities/datetime.py
Normal 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())
|
@ -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)
|
||||
|
@ -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())
|
@ -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)
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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',
|
||||
|
@ -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
72
netbox/utilities/html.py
Normal 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)}'
|
@ -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)
|
||||
|
@ -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 = {
|
||||
|
29
netbox/utilities/object_types.py
Normal file
29
netbox/utilities/object_types.py
Normal 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}'
|
@ -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
56
netbox/utilities/query.py
Normal 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
|
64
netbox/utilities/querydict.py
Normal file
64
netbox/utilities/querydict.py
Normal 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)
|
22
netbox/utilities/relations.py
Normal file
22
netbox/utilities/relations.py
Normal 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
|
@ -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.
|
||||
|
75
netbox/utilities/serialization.py
Normal file
75
netbox/utilities/serialization.py
Normal 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
|
10
netbox/utilities/string.py
Normal file
10
netbox/utilities/string.py
Normal 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()])
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
||||
|
@ -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')
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user