Merge branch 'feature' into 12175-rack-start

This commit is contained in:
Arthur 2023-06-15 12:46:32 -07:00
commit 846620f24f
75 changed files with 904 additions and 225 deletions

View File

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

View File

@ -3,10 +3,13 @@ blank_issues_enabled: false
contact_links: contact_links:
- name: 📖 Contributing Policy - name: 📖 Contributing Policy
url: https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md url: https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md
about: "Please read through our contributing policy before opening an issue or pull request" about: "Please read through our contributing policy before opening an issue or pull request."
- name: ❓ Discussion - name: ❓ Discussion
url: https://github.com/netbox-community/netbox/discussions url: https://github.com/netbox-community/netbox/discussions
about: "If you're just looking for help, try starting a discussion instead" about: "If you're just looking for help, try starting a discussion instead."
- name: 💡 Plugin Idea
url: https://plugin-ideas.netbox.dev
about: "Have an idea for a plugin? Head over to the ideas board!"
- name: 💬 Community Slack - name: 💬 Community Slack
url: https://netdev.chat/ url: https://netdev.chat
about: "Join #netbox on the NetDev Community Slack for assistance with installation issues and other problems" about: "Join #netbox on the NetDev Community Slack for assistance with installation issues and other problems."

View File

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

View File

@ -1,11 +1,10 @@
<div align="center"> <div align="center">
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" /> <img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
<p>The premiere source of truth powering network automation</p>
The premiere source of truth powering network automation <img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" />
<p></p>
</div> </div>
![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master)
NetBox is the leading solution for modeling and documenting modern networks. By NetBox is the leading solution for modeling and documenting modern networks. By
combining the traditional disciplines of IP address management (IPAM) and combining the traditional disciplines of IP address management (IPAM) and
datacenter infrastructure management (DCIM) with powerful APIs and extensions, datacenter infrastructure management (DCIM) with powerful APIs and extensions,

View File

@ -84,7 +84,8 @@ feedparser
# Django wrapper for Graphene (GraphQL support) # Django wrapper for Graphene (GraphQL support)
# https://github.com/graphql-python/graphene-django/releases # https://github.com/graphql-python/graphene-django/releases
graphene_django # Pinned to v3.0.0 for GraphiQL UI issue (see #12762)
graphene_django==3.0.0
# WSGI HTTP server # WSGI HTTP server
# https://docs.gunicorn.org/en/latest/news.html # https://docs.gunicorn.org/en/latest/news.html

View File

@ -204,3 +204,25 @@ This parameter defines the URL of the repository that will be checked for new Ne
Default: `300` Default: `300`
The maximum execution time of a background task (such as running a custom script), in seconds. The maximum execution time of a background task (such as running a custom script), in seconds.
---
## RQ_RETRY_INTERVAL
!!! note
This parameter was added in NetBox v3.5.
Default: `60`
This parameter controls how frequently a failed job is retried, up to the maximum number of times specified by `RQ_RETRY_MAX`. This must be either an integer specifying the number of seconds to wait between successive attempts, or a list of such values. For example, `[60, 300, 3600]` will retry the task after 1 minute, 5 minutes, and 1 hour.
---
## RQ_RETRY_MAX
!!! note
This parameter was added in NetBox v3.5.
Default: `0` (retries disabled)
The maximum number of times a background task will be retried before being marked as failed.

View File

@ -63,7 +63,7 @@ Each attribute of the IP address is expressed as an attribute of the JSON object
## Interactive Documentation ## Interactive Documentation
Comprehensive, interactive documentation of all REST API endpoints is available on a running NetBox instance at `/api/docs/`. This interface provides a convenient sandbox for researching and experimenting with specific endpoints and request types. The API itself can also be explored using a web browser by navigating to its root at `/api/`. Comprehensive, interactive documentation of all REST API endpoints is available on a running NetBox instance at `/api/schema/swagger-ui/`. This interface provides a convenient sandbox for researching and experimenting with specific endpoints and request types. The API itself can also be explored using a web browser by navigating to its root at `/api/`.
## Endpoint Hierarchy ## Endpoint Hierarchy

View File

@ -61,6 +61,10 @@ If installed in a rack, this field indicates the base rack unit in which the dev
!!! tip !!! tip
Devices with a height of more than one rack unit should be set to the lowest-numbered rack unit that they occupy. Devices with a height of more than one rack unit should be set to the lowest-numbered rack unit that they occupy.
### Latitude & Longitude
GPS coordinates of the device for geolocation.
### Status ### Status
The device's operational status. The device's operational status.

View File

@ -69,10 +69,11 @@ Defines how filters are evaluated against custom field values.
Controls how and whether the custom field is displayed within the NetBox user interface. Controls how and whether the custom field is displayed within the NetBox user interface.
| Option | Description | | Option | Description |
|------------|--------------------------------------| |-------------------|--------------------------------------------------|
| Read/write | Display and permit editing (default) | | Read/write | Display and permit editing (default) |
| Read-only | Display field but disallow editing | | Read-only | Display field but disallow editing |
| Hidden | Do not display field in the UI | | Hidden | Do not display field in the UI |
| Hidden (if unset) | Display in the UI only when a value has been set |
### Default ### Default

View File

@ -1,10 +1,60 @@
# NetBox v3.5 # NetBox v3.5
## v3.5.2 (FUTURE) ## v3.5.4 (FUTURE)
### Enhancements
* [#12847](https://github.com/netbox-community/netbox/issues/12847) - Include "add" button on all device & virtual machine component list views
* [#12862](https://github.com/netbox-community/netbox/issues/12862) - Add menu navigation button to add wireless links directly
### Bug Fixes
* [#12622](https://github.com/netbox-community/netbox/issues/12622) - Permit the assignment of non-site VLANs to prefixes assigned to a site
* [#12682](https://github.com/netbox-community/netbox/issues/12682) - Correct OpenAPI schema for connected device API endpoint
* [#12687](https://github.com/netbox-community/netbox/issues/12687) - Allow the assignment of all /31 IP addresses to interfaces
* [#12818](https://github.com/netbox-community/netbox/issues/12818) - Fix permissions evaluation when queuing a data sync job
* [#12822](https://github.com/netbox-community/netbox/issues/12822) - Fix encoding of whitespace in custom link URLs
* [#12838](https://github.com/netbox-community/netbox/issues/12838) - Correct rounding of rack power utilization values
* [#12850](https://github.com/netbox-community/netbox/issues/12850) - Fix table configuration modal for the contact assignments list
---
## v3.5.3 (2023-06-02)
### Enhancements
* [#9876](https://github.com/netbox-community/netbox/issues/9876) - Improve support for matching tags in conditional rules
* [#12015](https://github.com/netbox-community/netbox/issues/12015) - Add device type & role filters for device components
* [#12470](https://github.com/netbox-community/netbox/issues/12470) - Collapse context data by default when viewing a rendered device configuration
* [#12562](https://github.com/netbox-community/netbox/issues/12562) - Record client IP address when logging authentication failures
* [#12597](https://github.com/netbox-community/netbox/issues/12597) - Add an option to hide custom fields only if unset
* [#12599](https://github.com/netbox-community/netbox/issues/12599) - Apply filter parameters to links in object count dashboard widgets
### Bug Fixes
* [#7503](https://github.com/netbox-community/netbox/issues/7503) - Improve rack space validation when creating multiple devices via REST API
* [#11539](https://github.com/netbox-community/netbox/issues/11539) - Fix exception when applying "empty" filter lookup with invalid value
* [#11934](https://github.com/netbox-community/netbox/issues/11934) - Prevent reassignment of an IP address designated as primary for its parent object
* [#12538](https://github.com/netbox-community/netbox/issues/12538) - Redirect user to originating view after editing/deleting an image attachment
* [#12627](https://github.com/netbox-community/netbox/issues/12627) - Restore hover preview for embedded image attachment tables
* [#12694](https://github.com/netbox-community/netbox/issues/12694) - Strip leading & trailing whitespace from custom link URL & text
* [#12702](https://github.com/netbox-community/netbox/issues/12702) - Fix sizing of rear port selection widget on front port template creation form
* [#12715](https://github.com/netbox-community/netbox/issues/12715) - Use contact assignments table to display the contacts assigned to an object
* [#12730](https://github.com/netbox-community/netbox/issues/12730) - Fix extraneous contacts listed in object contact assignments view
* [#12742](https://github.com/netbox-community/netbox/issues/12742) - Object counts dashboard widget should support URL-compatible query filters
* [#12762](https://github.com/netbox-community/netbox/issues/12762) - Fix GraphiQL UI by reverting graphene-django to earlier version
* [#12745](https://github.com/netbox-community/netbox/issues/12745) - Escape display text in API-backed selection widgets
* [#12779](https://github.com/netbox-community/netbox/issues/12779) - Correct arithmetic for converting inches to meters
---
## v3.5.2 (2023-05-22)
### Enhancements ### Enhancements
* [#7671](https://github.com/netbox-community/netbox/issues/7671) - Introduce `REMOTE_AUTH_AUTO_CREATE_GROUPS` config parameter to enable the automatic creation of new groups when remote authentication is in use * [#7671](https://github.com/netbox-community/netbox/issues/7671) - Introduce `REMOTE_AUTH_AUTO_CREATE_GROUPS` config parameter to enable the automatic creation of new groups when remote authentication is in use
* [#9068](https://github.com/netbox-community/netbox/issues/9068) - Disallow the assignment of network/broadcast IP addresses to interfaces
* [#11017](https://github.com/netbox-community/netbox/issues/11017) - Increase the maximum values for allocated and maximum power draws
* [#11233](https://github.com/netbox-community/netbox/issues/11233) - Intercept and cleanly report errors upon attempted database writes when maintenance mode is enabled * [#11233](https://github.com/netbox-community/netbox/issues/11233) - Intercept and cleanly report errors upon attempted database writes when maintenance mode is enabled
* [#11599](https://github.com/netbox-community/netbox/issues/11599) - Move contacts panels to separate tabs under object views * [#11599](https://github.com/netbox-community/netbox/issues/11599) - Move contacts panels to separate tabs under object views
* [#11670](https://github.com/netbox-community/netbox/issues/11670) - Enable setting device type & module type weight via bulk import * [#11670](https://github.com/netbox-community/netbox/issues/11670) - Enable setting device type & module type weight via bulk import
@ -14,14 +64,23 @@
* [#12233](https://github.com/netbox-community/netbox/issues/12233) - Move related IP addresses table to a separate tab * [#12233](https://github.com/netbox-community/netbox/issues/12233) - Move related IP addresses table to a separate tab
* [#12286](https://github.com/netbox-community/netbox/issues/12286) - Show height and total weight under device view * [#12286](https://github.com/netbox-community/netbox/issues/12286) - Show height and total weight under device view
* [#12323](https://github.com/netbox-community/netbox/issues/12323) - Add 100GE CXP interface type * [#12323](https://github.com/netbox-community/netbox/issues/12323) - Add 100GE CXP interface type
* [#12327](https://github.com/netbox-community/netbox/issues/12327) - Introduce the ability to automatically retry failed background jobs
* [#12498](https://github.com/netbox-community/netbox/issues/12498) - Hide map button if `MAPS_URL` is empty * [#12498](https://github.com/netbox-community/netbox/issues/12498) - Hide map button if `MAPS_URL` is empty
* [#12548](https://github.com/netbox-community/netbox/issues/12548) - Optimize REST API performance when retrieving interfaces with L2VPN assignments
* [#12554](https://github.com/netbox-community/netbox/issues/12554) - Allow customization or disabling of the maintenance mode banner * [#12554](https://github.com/netbox-community/netbox/issues/12554) - Allow customization or disabling of the maintenance mode banner
* [#12605](https://github.com/netbox-community/netbox/issues/12605) - Add LX.5 port types
* [#12629](https://github.com/netbox-community/netbox/issues/12629) - Add 400GE CDFP and CFP8 interface types
* [#12678](https://github.com/netbox-community/netbox/issues/12678) - Add 200GE QSFP-DD interface type
### Bug Fixes ### Bug Fixes
* [#10686](https://github.com/netbox-community/netbox/issues/10686) - Enable specifying termination object by virtual chassis master when importing cables * [#10686](https://github.com/netbox-community/netbox/issues/10686) - Enable specifying termination object by virtual chassis master when importing cables
* [#11619](https://github.com/netbox-community/netbox/issues/11619) - Enable assigning VLANs without a site to interfaces during bulk edit
* [#12468](https://github.com/netbox-community/netbox/issues/12468) - Custom field names should not permit double underscores
* [#12550](https://github.com/netbox-community/netbox/issues/12550) - Fix rear port selection widget under front port creation form * [#12550](https://github.com/netbox-community/netbox/issues/12550) - Fix rear port selection widget under front port creation form
* [#12570](https://github.com/netbox-community/netbox/issues/12570) - Disable ordering of synchronized object tables by the "synced" attribute * [#12570](https://github.com/netbox-community/netbox/issues/12570) - Disable ordering of synchronized object tables by the "synced" attribute
* [#12594](https://github.com/netbox-community/netbox/issues/12594) - Enable selecting config context as object type in object counts dashboard widget
* [#12642](https://github.com/netbox-community/netbox/issues/12642) - Fix bulk tenant assignment via cluster import form
--- ---

View File

@ -33,7 +33,7 @@ class DataSourceViewSet(NetBoxModelViewSet):
""" """
Enqueue a job to synchronize the DataSource. Enqueue a job to synchronize the DataSource.
""" """
if not request.user.has_perm('extras.sync_datasource'): if not request.user.has_perm('core.sync_datasource'):
raise PermissionDenied("Syncing data sources requires the core.sync_datasource permission.") raise PermissionDenied("Syncing data sources requires the core.sync_datasource permission.")
datasource = get_object_or_404(DataSource, pk=pk) datasource = get_object_or_404(DataSource, pk=pk)

View File

@ -200,6 +200,7 @@ class DataSource(JobsMixin, PrimaryModel):
# Emit the post_sync signal # Emit the post_sync signal
post_sync.send(sender=self.__class__, instance=self) post_sync.send(sender=self.__class__, instance=self)
sync.alters_data = True
def _walk(self, root): def _walk(self, root):
""" """

View File

@ -16,7 +16,7 @@ from extras.utils import FeatureQuery
from netbox.config import get_config from netbox.config import get_config
from netbox.constants import RQ_QUEUE_DEFAULT from netbox.constants import RQ_QUEUE_DEFAULT
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.rqworker import get_queue_for_model from utilities.rqworker import get_queue_for_model, get_rq_retry
__all__ = ( __all__ = (
'Job', 'Job',
@ -219,5 +219,6 @@ class Job(models.Model):
event=event, event=event,
data=self.data, data=self.data,
timestamp=str(timezone.now()), timestamp=str(timezone.now()),
username=self.user.username username=self.user.username,
retry=get_rq_retry()
) )

View File

@ -673,9 +673,10 @@ class DeviceSerializer(NetBoxModelSerializer):
model = Device model = Device
fields = [ fields = [
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', 'airflow',
'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority',
'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'description', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created',
'last_updated',
] ]
@extend_schema_field(NestedDeviceSerializer) @extend_schema_field(NestedDeviceSerializer)

View File

@ -1,12 +1,12 @@
from django.http import Http404, HttpResponse from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, OpenApiParameter
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.renderers import JSONRenderer from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.status import HTTP_400_BAD_REQUEST
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from rest_framework.status import HTTP_400_BAD_REQUEST
from rest_framework.viewsets import ViewSet from rest_framework.viewsets import ViewSet
from circuits.models import Circuit from circuits.models import Circuit
@ -14,7 +14,6 @@ from dcim import filtersets
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
from dcim.models import * from dcim.models import *
from dcim.svg import CableTraceSVG from dcim.svg import CableTraceSVG
from extras.api.nested_serializers import NestedConfigTemplateSerializer
from extras.api.mixins import ConfigContextQuerySetMixin, ConfigTemplateRenderMixin from extras.api.mixins import ConfigContextQuerySetMixin, ConfigTemplateRenderMixin
from ipam.models import Prefix, VLAN from ipam.models import Prefix, VLAN
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
@ -22,6 +21,7 @@ from netbox.api.metadata import ContentTypeMetadata
from netbox.api.pagination import StripCountAnnotationsPaginator from netbox.api.pagination import StripCountAnnotationsPaginator
from netbox.api.renderers import TextRenderer from netbox.api.renderers import TextRenderer
from netbox.api.viewsets import NetBoxModelViewSet from netbox.api.viewsets import NetBoxModelViewSet
from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
from netbox.constants import NESTED_SERIALIZER_PREFIX from netbox.constants import NESTED_SERIALIZER_PREFIX
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
from utilities.utils import count_related from utilities.utils import count_related
@ -386,7 +386,12 @@ class PlatformViewSet(NetBoxModelViewSet):
# Devices/modules # Devices/modules
# #
class DeviceViewSet(ConfigContextQuerySetMixin, ConfigTemplateRenderMixin, NetBoxModelViewSet): class DeviceViewSet(
SequentialBulkCreatesMixin,
ConfigContextQuerySetMixin,
ConfigTemplateRenderMixin,
NetBoxModelViewSet
):
queryset = Device.objects.prefetch_related( queryset = Device.objects.prefetch_related(
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay', 'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay',
'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'config_template', 'tags', 'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'config_template', 'tags',
@ -493,7 +498,8 @@ class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet): class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = Interface.objects.prefetch_related( queryset = Interface.objects.prefetch_related(
'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path', 'cable__terminations', 'wireless_lans', 'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path', 'cable__terminations', 'wireless_lans',
'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags' 'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags', 'l2vpn_terminations',
'vdcs',
) )
serializer_class = serializers.InterfaceSerializer serializer_class = serializers.InterfaceSerializer
filterset_class = filtersets.InterfaceFilterSet filterset_class = filtersets.InterfaceFilterSet
@ -640,7 +646,10 @@ class ConnectedDeviceViewSet(ViewSet):
def get_view_name(self): def get_view_name(self):
return "Connected Device Locator" return "Connected Device Locator"
@extend_schema(responses={200: OpenApiTypes.OBJECT}) @extend_schema(
parameters=[_device_param, _interface_param],
responses={200: serializers.DeviceSerializer}
)
def list(self, request): def list(self, request):
peer_device_name = request.query_params.get(self._device_param.name) peer_device_name = request.query_params.get(self._device_param.name)

View File

@ -812,8 +812,11 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_100GE_QSFP28 = '100gbase-x-qsfp28' TYPE_100GE_QSFP28 = '100gbase-x-qsfp28'
TYPE_200GE_CFP2 = '200gbase-x-cfp2' TYPE_200GE_CFP2 = '200gbase-x-cfp2'
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56' TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd' TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
TYPE_400GE_OSFP = '400gbase-x-osfp' TYPE_400GE_OSFP = '400gbase-x-osfp'
TYPE_400GE_CDFP = '400gbase-x-cdfp'
TYPE_400GE_CFP8 = '400gbase-x-cfp8'
TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd' TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
TYPE_800GE_OSFP = '800gbase-x-osfp' TYPE_800GE_OSFP = '800gbase-x-osfp'
@ -957,8 +960,11 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'), (TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'),
(TYPE_100GE_QSFP28, 'QSFP28 (100GE)'), (TYPE_100GE_QSFP28, 'QSFP28 (100GE)'),
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'), (TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
(TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'), (TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
(TYPE_400GE_OSFP, 'OSFP (400GE)'), (TYPE_400GE_OSFP, 'OSFP (400GE)'),
(TYPE_400GE_CDFP, 'CDFP (400GE)'),
(TYPE_400GE_CFP8, 'CPF8 (400GE)'),
(TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'), (TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),
(TYPE_800GE_OSFP, 'OSFP (800GE)'), (TYPE_800GE_OSFP, 'OSFP (800GE)'),
) )
@ -1223,6 +1229,10 @@ class PortTypeChoices(ChoiceSet):
TYPE_LSH_PC = 'lsh-pc' TYPE_LSH_PC = 'lsh-pc'
TYPE_LSH_UPC = 'lsh-upc' TYPE_LSH_UPC = 'lsh-upc'
TYPE_LSH_APC = 'lsh-apc' TYPE_LSH_APC = 'lsh-apc'
TYPE_LX5 = 'lx5'
TYPE_LX5_PC = 'lx5-pc'
TYPE_LX5_UPC = 'lx5-upc'
TYPE_LX5_APC = 'lx5-apc'
TYPE_SPLICE = 'splice' TYPE_SPLICE = 'splice'
TYPE_CS = 'cs' TYPE_CS = 'cs'
TYPE_SN = 'sn' TYPE_SN = 'sn'
@ -1269,6 +1279,10 @@ class PortTypeChoices(ChoiceSet):
(TYPE_LSH_PC, 'LSH/PC'), (TYPE_LSH_PC, 'LSH/PC'),
(TYPE_LSH_UPC, 'LSH/UPC'), (TYPE_LSH_UPC, 'LSH/UPC'),
(TYPE_LSH_APC, 'LSH/APC'), (TYPE_LSH_APC, 'LSH/APC'),
(TYPE_LX5, 'LX.5'),
(TYPE_LX5_PC, 'LX.5/PC'),
(TYPE_LX5_UPC, 'LX.5/UPC'),
(TYPE_LX5_APC, 'LX.5/APC'),
(TYPE_MPO, 'MPO'), (TYPE_MPO, 'MPO'),
(TYPE_MTRJ, 'MTRJ'), (TYPE_MTRJ, 'MTRJ'),
(TYPE_SC, 'SC'), (TYPE_SC, 'SC'),

View File

@ -999,7 +999,7 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
class Meta: class Meta:
model = Device model = Device
fields = ['id', 'asset_tag', 'face', 'position', 'airflow', 'vc_position', 'vc_priority'] fields = ['id', 'asset_tag', 'face', 'position', 'latitude', 'longitude', 'airflow', 'vc_position', 'vc_priority']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
@ -1219,6 +1219,28 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
to_field_name='name', to_field_name='name',
label=_('Device (name)'), label=_('Device (name)'),
) )
device_type_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__device_type',
queryset=DeviceType.objects.all(),
label=_('Device type (ID)'),
)
device_type = django_filters.ModelMultipleChoiceFilter(
field_name='device__device_type__model',
queryset=DeviceType.objects.all(),
to_field_name='model',
label=_('Device type (model)'),
)
device_role_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__device_role',
queryset=DeviceRole.objects.all(),
label=_('Device role (ID)'),
)
device_role = django_filters.ModelMultipleChoiceFilter(
field_name='device__device_role__slug',
queryset=DeviceRole.objects.all(),
to_field_name='slug',
label=_('Device role (slug)'),
)
virtual_chassis_id = django_filters.ModelMultipleChoiceFilter( virtual_chassis_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__virtual_chassis', field_name='device__virtual_chassis',
queryset=VirtualChassis.objects.all(), queryset=VirtualChassis.objects.all(),

View File

@ -1,4 +1,5 @@
from django import forms from django import forms
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from timezone_field import TimeZoneFormField from timezone_field import TimeZoneFormField
@ -1288,8 +1289,13 @@ class InterfaceBulkEditForm(
break break
if site is not None: if site is not None:
self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk) # Query for VLANs assigned to the same site and VLANs with no site assigned (null).
self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk) self.fields['untagged_vlan'].widget.add_query_param(
'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE]
)
self.fields['tagged_vlans'].widget.add_query_param(
'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE]
)
self.fields['parent'].choices = () self.fields['parent'].choices = ()
self.fields['parent'].widget.attrs['disabled'] = True self.fields['parent'].widget.attrs['disabled'] = True

View File

@ -478,8 +478,9 @@ class DeviceImportForm(BaseDeviceImportForm):
class Meta(BaseDeviceImportForm.Meta): class Meta(BaseDeviceImportForm.Meta):
fields = [ fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
'site', 'location', 'rack', 'position', 'face', 'parent', 'device_bay', 'airflow', 'virtual_chassis', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent', 'device_bay', 'airflow',
'vc_position', 'vc_priority', 'cluster', 'description', 'config_template', 'comments', 'tags', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'description', 'config_template', 'comments',
'tags',
] ]
def __init__(self, data=None, *args, **kwargs): def __init__(self, data=None, *args, **kwargs):

View File

@ -102,13 +102,25 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
required=False, required=False,
label=_('Virtual Chassis') label=_('Virtual Chassis')
) )
device_type_id = DynamicModelMultipleChoiceField(
queryset=DeviceType.objects.all(),
required=False,
label=_('Device type')
)
device_role_id = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(),
required=False,
label=_('Device role')
)
device_id = DynamicModelMultipleChoiceField( device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
required=False, required=False,
query_params={ query_params={
'site_id': '$site_id', 'site_id': '$site_id',
'location_id': '$location_id', 'location_id': '$location_id',
'virtual_chassis_id': '$virtual_chassis_id' 'virtual_chassis_id': '$virtual_chassis_id',
'device_type_id': '$device_type_id',
'role_id': '$device_role_id'
}, },
label=_('Device') label=_('Device')
) )
@ -1070,7 +1082,8 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type', 'speed')), ('Attributes', ('name', 'label', 'type', 'speed')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
('Connection', ('cabled', 'connected', 'occupied')), ('Connection', ('cabled', 'connected', 'occupied')),
) )
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
@ -1089,7 +1102,8 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type', 'speed')), ('Attributes', ('name', 'label', 'type', 'speed')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
('Connection', ('cabled', 'connected', 'occupied')), ('Connection', ('cabled', 'connected', 'occupied')),
) )
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
@ -1108,7 +1122,8 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type')), ('Attributes', ('name', 'label', 'type')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
('Connection', ('cabled', 'connected', 'occupied')), ('Connection', ('cabled', 'connected', 'occupied')),
) )
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
@ -1123,7 +1138,8 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type')), ('Attributes', ('name', 'label', 'type')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
('Connection', ('cabled', 'connected', 'occupied')), ('Connection', ('cabled', 'connected', 'occupied')),
) )
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
@ -1141,8 +1157,8 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
('Addressing', ('vrf_id', 'l2vpn_id', 'mac_address', 'wwn')), ('Addressing', ('vrf_id', 'l2vpn_id', 'mac_address', 'wwn')),
('PoE', ('poe_mode', 'poe_type')), ('PoE', ('poe_mode', 'poe_type')),
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
'device_id', 'vdc_id')), ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')),
('Connection', ('cabled', 'connected', 'occupied')), ('Connection', ('cabled', 'connected', 'occupied')),
) )
vdc_id = DynamicModelMultipleChoiceField( vdc_id = DynamicModelMultipleChoiceField(
@ -1242,7 +1258,8 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type', 'color')), ('Attributes', ('name', 'label', 'type', 'color')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
('Cable', ('cabled', 'occupied')), ('Cable', ('cabled', 'occupied')),
) )
model = FrontPort model = FrontPort
@ -1261,7 +1278,8 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type', 'color')), ('Attributes', ('name', 'label', 'type', 'color')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
('Cable', ('cabled', 'occupied')), ('Cable', ('cabled', 'occupied')),
) )
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
@ -1279,7 +1297,8 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'position')), ('Attributes', ('name', 'label', 'position')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
) )
tag = TagFilterField(model) tag = TagFilterField(model)
position = forms.CharField( position = forms.CharField(
@ -1292,7 +1311,8 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label')), ('Attributes', ('name', 'label')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
) )
tag = TagFilterField(model) tag = TagFilterField(model)
@ -1302,7 +1322,8 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')), ('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
) )
role_id = DynamicModelMultipleChoiceField( role_id = DynamicModelMultipleChoiceField(
queryset=InventoryItemRole.objects.all(), queryset=InventoryItemRole.objects.all(),

View File

@ -449,9 +449,9 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
model = Device model = Device
fields = [ fields = [
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'location', 'position', 'face', 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'location', 'position', 'face',
'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'cluster', 'tenant_group', 'tenant', 'latitude', 'longitude', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'cluster',
'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'comments', 'tags', 'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template',
'local_context_data' 'comments', 'tags', 'local_context_data'
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):

View File

@ -101,6 +101,7 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp
choices=[], choices=[],
label=_('Rear ports'), label=_('Rear ports'),
help_text=_('Select one rear port assignment for each front port being created.'), help_text=_('Select one rear port assignment for each front port being created.'),
widget=forms.SelectMultiple(attrs={'size': 6})
) )
# Override fieldsets from FrontPortTemplateForm to omit rear_port_position # Override fieldsets from FrontPortTemplateForm to omit rear_port_position

View File

@ -0,0 +1,22 @@
# Generated by Django 4.1.9 on 2023-05-31 22:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0173_remove_napalm_fields'),
]
operations = [
migrations.AddField(
model_name='device',
name='latitude',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=8, null=True),
),
migrations.AddField(
model_name='device',
name='longitude',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True),
),
]

View File

@ -359,6 +359,7 @@ class CableTermination(ChangeLoggedModel):
# Circuit terminations # Circuit terminations
elif getattr(self.termination, 'site', None): elif getattr(self.termination, 'site', None):
self._site = self.termination.site self._site = self.termination.site
cache_related_objects.alters_data = True
def to_objectchange(self, action): def to_objectchange(self, action):
objectchange = super().to_objectchange(action) objectchange = super().to_objectchange(action)
@ -637,6 +638,7 @@ class CablePath(models.Model):
self.save() self.save()
else: else:
self.delete() self.delete()
retrace.alters_data = True
def _get_path(self): def _get_path(self):
""" """

View File

@ -213,6 +213,7 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
type=self.type, type=self.type,
**kwargs **kwargs
) )
instantiate.do_not_call_in_templates = True
def to_yaml(self): def to_yaml(self):
return { return {
@ -256,6 +257,7 @@ class PowerPortTemplate(ModularComponentTemplateModel):
allocated_draw=self.allocated_draw, allocated_draw=self.allocated_draw,
**kwargs **kwargs
) )
instantiate.do_not_call_in_templates = True
def clean(self): def clean(self):
super().clean() super().clean()
@ -330,6 +332,7 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
feed_leg=self.feed_leg, feed_leg=self.feed_leg,
**kwargs **kwargs
) )
instantiate.do_not_call_in_templates = True
def to_yaml(self): def to_yaml(self):
return { return {
@ -413,6 +416,7 @@ class InterfaceTemplate(ModularComponentTemplateModel):
poe_type=self.poe_type, poe_type=self.poe_type,
**kwargs **kwargs
) )
instantiate.do_not_call_in_templates = True
def to_yaml(self): def to_yaml(self):
return { return {
@ -507,6 +511,7 @@ class FrontPortTemplate(ModularComponentTemplateModel):
rear_port_position=self.rear_port_position, rear_port_position=self.rear_port_position,
**kwargs **kwargs
) )
instantiate.do_not_call_in_templates = True
def to_yaml(self): def to_yaml(self):
return { return {
@ -550,6 +555,7 @@ class RearPortTemplate(ModularComponentTemplateModel):
positions=self.positions, positions=self.positions,
**kwargs **kwargs
) )
instantiate.do_not_call_in_templates = True
def to_yaml(self): def to_yaml(self):
return { return {
@ -581,6 +587,7 @@ class ModuleBayTemplate(ComponentTemplateModel):
label=self.label, label=self.label,
position=self.position position=self.position
) )
instantiate.do_not_call_in_templates = True
def to_yaml(self): def to_yaml(self):
return { return {
@ -603,6 +610,7 @@ class DeviceBayTemplate(ComponentTemplateModel):
name=self.name, name=self.name,
label=self.label label=self.label
) )
instantiate.do_not_call_in_templates = True
def clean(self): def clean(self):
if self.device_type and self.device_type.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT: if self.device_type and self.device_type.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT:
@ -696,3 +704,4 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
part_id=self.part_id, part_id=self.part_id,
**kwargs **kwargs
) )
instantiate.do_not_call_in_templates = True

View File

@ -624,6 +624,20 @@ class Device(PrimaryModel, ConfigContextModel):
blank=True, blank=True,
null=True null=True
) )
latitude = models.DecimalField(
max_digits=8,
decimal_places=6,
blank=True,
null=True,
help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
)
longitude = models.DecimalField(
max_digits=9,
decimal_places=6,
blank=True,
null=True,
help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
)
# Generic relations # Generic relations
contacts = GenericRelation( contacts = GenericRelation(

View File

@ -484,7 +484,7 @@ class Rack(PrimaryModel, WeightMixin):
powerport.get_power_draw()['allocated'] for powerport in powerports powerport.get_power_draw()['allocated'] for powerport in powerports
]) ])
return int(allocated_draw / available_power_total * 100) return round(allocated_draw / available_power_total * 100, 1)
@cached_property @cached_property
def total_weight(self): def total_weight(self):

View File

@ -236,9 +236,9 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
fields = ( fields = (
'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type', 'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type',
'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device', 'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device',
'device_bay_position', 'position', 'face', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'device_bay_position', 'position', 'face', 'latitude', 'longitude', 'airflow', 'primary_ip', 'primary_ip4',
'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'comments', 'contacts', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template',
'tags', 'created', 'last_updated', 'comments', 'contacts', 'tags', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',

View File

@ -1115,7 +1115,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
device_types = ( device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', u_height=2),
) )
DeviceType.objects.bulk_create(device_types) DeviceType.objects.bulk_create(device_types)
@ -1229,6 +1229,39 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
def test_rack_fit(self):
"""
Check that creating multiple devices with overlapping position fails.
"""
device = Device.objects.first()
device_type = DeviceType.objects.all()[1]
data = [
{
'device_type': device_type.pk,
'device_role': device.device_role.pk,
'site': device.site.pk,
'name': 'Test Device 7',
'rack': device.rack.pk,
'face': 'front',
'position': 1
},
{
'device_type': device_type.pk,
'device_role': device.device_role.pk,
'site': device.site.pk,
'name': 'Test Device 8',
'rack': device.rack.pk,
'face': 'front',
'position': 2
}
]
self.add_permissions('dcim.add_device')
url = reverse('dcim-api:device-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
class ModuleTest(APIViewTestCases.APIViewTestCase): class ModuleTest(APIViewTestCases.APIViewTestCase):
model = Module model = Module

View File

@ -12,6 +12,23 @@ from virtualization.models import Cluster, ClusterType
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
class DeviceComponentFilterSetTests:
def test_device_type(self):
device_types = DeviceType.objects.all()[:2]
params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'device_type': [device_types[0].model, device_types[1].model]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_device_role(self):
device_role = DeviceRole.objects.all()[:2]
params = {'device_role_id': [device_role[0].pk, device_role[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'device_role': [device_role[0].slug, device_role[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class RegionTestCase(TestCase, ChangeLoggedFilterSetTests): class RegionTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Region.objects.all() queryset = Region.objects.all()
filterset = RegionFilterSet filterset = RegionFilterSet
@ -1621,9 +1638,9 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants) Tenant.objects.bulk_create(tenants)
devices = ( devices = (
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], tenant=tenants[0], serial='ABC', asset_tag='1001', site=sites[0], location=locations[0], rack=racks[0], position=1, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_ACTIVE, cluster=clusters[0], local_context_data={"foo": 123}), Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], tenant=tenants[0], serial='ABC', asset_tag='1001', site=sites[0], location=locations[0], rack=racks[0], position=1, face=DeviceFaceChoices.FACE_FRONT, latitude=10, longitude=10, status=DeviceStatusChoices.STATUS_ACTIVE, cluster=clusters[0], local_context_data={"foo": 123}),
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], tenant=tenants[1], serial='DEF', asset_tag='1002', site=sites[1], location=locations[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_STAGED, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR, cluster=clusters[1]), Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], tenant=tenants[1], serial='DEF', asset_tag='1002', site=sites[1], location=locations[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, latitude=20, longitude=20, status=DeviceStatusChoices.STATUS_STAGED, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR, cluster=clusters[1]),
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], tenant=tenants[2], serial='GHI', asset_tag='1003', site=sites[2], location=locations[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, status=DeviceStatusChoices.STATUS_FAILED, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT, cluster=clusters[2]), Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], tenant=tenants[2], serial='GHI', asset_tag='1003', site=sites[2], location=locations[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, latitude=30, longitude=30, status=DeviceStatusChoices.STATUS_FAILED, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT, cluster=clusters[2]),
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
@ -1704,6 +1721,14 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'position': [1, 2]} params = {'position': [1, 2]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_latitude(self):
params = {'latitude': [10, 20]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_longitude(self):
params = {'longitude': [10, 20]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_vc_position(self): def test_vc_position(self):
params = {'vc_position': [1, 2]} params = {'vc_position': [1, 2]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@ -1994,7 +2019,7 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests): class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = ConsolePort.objects.all() queryset = ConsolePort.objects.all()
filterset = ConsolePortFilterSet filterset = ConsolePortFilterSet
@ -2023,10 +2048,23 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'), Site(name='Site X', slug='site-x'),
)) ))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = ( locations = (
Location(name='Location 1', slug='location-1', site=sites[0]), Location(name='Location 1', slug='location-1', site=sites[0]),
@ -2044,10 +2082,10 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections Device(name=None, device_type=device_types[0], device_role=device_roles[0], site=sites[3]), # For cable connections
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
@ -2161,7 +2199,7 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests): class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = ConsoleServerPort.objects.all() queryset = ConsoleServerPort.objects.all()
filterset = ConsoleServerPortFilterSet filterset = ConsoleServerPortFilterSet
@ -2190,10 +2228,23 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'), Site(name='Site X', slug='site-x'),
)) ))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = ( locations = (
Location(name='Location 1', slug='location-1', site=sites[0]), Location(name='Location 1', slug='location-1', site=sites[0]),
@ -2211,10 +2262,10 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
@ -2328,7 +2379,7 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests): class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = PowerPort.objects.all() queryset = PowerPort.objects.all()
filterset = PowerPortFilterSet filterset = PowerPortFilterSet
@ -2357,10 +2408,23 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'), Site(name='Site X', slug='site-x'),
)) ))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = ( locations = (
Location(name='Location 1', slug='location-1', site=sites[0]), Location(name='Location 1', slug='location-1', site=sites[0]),
@ -2378,10 +2442,10 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
@ -2503,7 +2567,7 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests): class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = PowerOutlet.objects.all() queryset = PowerOutlet.objects.all()
filterset = PowerOutletFilterSet filterset = PowerOutletFilterSet
@ -2532,10 +2596,23 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'), Site(name='Site X', slug='site-x'),
)) ))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = ( locations = (
Location(name='Location 1', slug='location-1', site=sites[0]), Location(name='Location 1', slug='location-1', site=sites[0]),
@ -2553,10 +2630,10 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
@ -2674,7 +2751,7 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = Interface.objects.all() queryset = Interface.objects.all()
filterset = InterfaceFilterSet filterset = InterfaceFilterSet
@ -2703,10 +2780,23 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'), Site(name='Site X', slug='site-x'),
)) ))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = ( locations = (
Location(name='Location 1', slug='location-1', site=sites[0]), Location(name='Location 1', slug='location-1', site=sites[0]),
@ -2724,10 +2814,10 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
@ -3097,7 +3187,7 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests): class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = FrontPort.objects.all() queryset = FrontPort.objects.all()
filterset = FrontPortFilterSet filterset = FrontPortFilterSet
@ -3126,10 +3216,23 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'), Site(name='Site X', slug='site-x'),
)) ))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = ( locations = (
Location(name='Location 1', slug='location-1', site=sites[0]), Location(name='Location 1', slug='location-1', site=sites[0]),
@ -3147,10 +3250,10 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
@ -3273,7 +3376,7 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests): class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = RearPort.objects.all() queryset = RearPort.objects.all()
filterset = RearPortFilterSet filterset = RearPortFilterSet
@ -3302,10 +3405,23 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'), Site(name='Site X', slug='site-x'),
)) ))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = ( locations = (
Location(name='Location 1', slug='location-1', site=sites[0]), Location(name='Location 1', slug='location-1', site=sites[0]),
@ -3323,10 +3439,10 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
@ -3443,7 +3559,7 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests): class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = ModuleBay.objects.all() queryset = ModuleBay.objects.all()
filterset = ModuleBayFilterSet filterset = ModuleBayFilterSet
@ -3472,9 +3588,21 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'), Site(name='Site X', slug='site-x'),
)) ))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1') device_types = (
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = ( locations = (
Location(name='Location 1', slug='location-1', site=sites[0]), Location(name='Location 1', slug='location-1', site=sites[0]),
@ -3492,9 +3620,9 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
@ -3560,7 +3688,7 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests): class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = DeviceBay.objects.all() queryset = DeviceBay.objects.all()
filterset = DeviceBayFilterSet filterset = DeviceBayFilterSet
@ -3589,9 +3717,21 @@ class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'), Site(name='Site X', slug='site-x'),
)) ))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1') device_types = (
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = ( locations = (
Location(name='Location 1', slug='location-1', site=sites[0]), Location(name='Location 1', slug='location-1', site=sites[0]),
@ -3609,9 +3749,9 @@ class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
@ -3690,8 +3830,19 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
) )
Manufacturer.objects.bulk_create(manufacturers) Manufacturer.objects.bulk_create(manufacturers)
device_type = DeviceType.objects.create(manufacturer=manufacturers[0], model='Model 1', slug='model-1') device_types = (
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') DeviceType(manufacturer=manufacturers[0], model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturers[0], model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturers[0], model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
regions = ( regions = (
Region(name='Region 1', slug='region-1'), Region(name='Region 1', slug='region-1'),
@ -3732,9 +3883,9 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
@ -3825,6 +3976,20 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'rack': [racks[0].name, racks[1].name]} params = {'rack': [racks[0].name, racks[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_device_type(self):
device_types = DeviceType.objects.all()[:2]
params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'device_type': [device_types[0].model, device_types[1].model]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_device_role(self):
device_role = DeviceRole.objects.all()[:2]
params = {'device_role_id': [device_role[0].pk, device_role[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'device_role': [device_role[0].slug, device_role[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_device(self): def test_device(self):
devices = Device.objects.all()[:2] devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]} params = {'device_id': [devices[0].pk, devices[1].pk]}

View File

@ -1696,6 +1696,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'rack': racks[1].pk, 'rack': racks[1].pk,
'position': 1, 'position': 1,
'face': DeviceFaceChoices.FACE_FRONT, 'face': DeviceFaceChoices.FACE_FRONT,
'latitude': Decimal('35.780000'),
'longitude': Decimal('-78.642000'),
'status': DeviceStatusChoices.STATUS_PLANNED, 'status': DeviceStatusChoices.STATUS_PLANNED,
'primary_ip4': None, 'primary_ip4': None,
'primary_ip6': None, 'primary_ip6': None,

View File

@ -2193,7 +2193,6 @@ class ConsolePortListView(generic.ObjectListView):
filterset = filtersets.ConsolePortFilterSet filterset = filtersets.ConsolePortFilterSet
filterset_form = forms.ConsolePortFilterForm filterset_form = forms.ConsolePortFilterForm
table = tables.ConsolePortTable table = tables.ConsolePortTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(ConsolePort) @register_model_view(ConsolePort)
@ -2257,7 +2256,6 @@ class ConsoleServerPortListView(generic.ObjectListView):
filterset = filtersets.ConsoleServerPortFilterSet filterset = filtersets.ConsoleServerPortFilterSet
filterset_form = forms.ConsoleServerPortFilterForm filterset_form = forms.ConsoleServerPortFilterForm
table = tables.ConsoleServerPortTable table = tables.ConsoleServerPortTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(ConsoleServerPort) @register_model_view(ConsoleServerPort)
@ -2321,7 +2319,6 @@ class PowerPortListView(generic.ObjectListView):
filterset = filtersets.PowerPortFilterSet filterset = filtersets.PowerPortFilterSet
filterset_form = forms.PowerPortFilterForm filterset_form = forms.PowerPortFilterForm
table = tables.PowerPortTable table = tables.PowerPortTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(PowerPort) @register_model_view(PowerPort)
@ -2385,7 +2382,6 @@ class PowerOutletListView(generic.ObjectListView):
filterset = filtersets.PowerOutletFilterSet filterset = filtersets.PowerOutletFilterSet
filterset_form = forms.PowerOutletFilterForm filterset_form = forms.PowerOutletFilterForm
table = tables.PowerOutletTable table = tables.PowerOutletTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(PowerOutlet) @register_model_view(PowerOutlet)
@ -2449,7 +2445,6 @@ class InterfaceListView(generic.ObjectListView):
filterset = filtersets.InterfaceFilterSet filterset = filtersets.InterfaceFilterSet
filterset_form = forms.InterfaceFilterForm filterset_form = forms.InterfaceFilterForm
table = tables.InterfaceTable table = tables.InterfaceTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(Interface) @register_model_view(Interface)
@ -2559,7 +2554,6 @@ class FrontPortListView(generic.ObjectListView):
filterset = filtersets.FrontPortFilterSet filterset = filtersets.FrontPortFilterSet
filterset_form = forms.FrontPortFilterForm filterset_form = forms.FrontPortFilterForm
table = tables.FrontPortTable table = tables.FrontPortTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(FrontPort) @register_model_view(FrontPort)
@ -2623,7 +2617,6 @@ class RearPortListView(generic.ObjectListView):
filterset = filtersets.RearPortFilterSet filterset = filtersets.RearPortFilterSet
filterset_form = forms.RearPortFilterForm filterset_form = forms.RearPortFilterForm
table = tables.RearPortTable table = tables.RearPortTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(RearPort) @register_model_view(RearPort)
@ -2687,7 +2680,6 @@ class ModuleBayListView(generic.ObjectListView):
filterset = filtersets.ModuleBayFilterSet filterset = filtersets.ModuleBayFilterSet
filterset_form = forms.ModuleBayFilterForm filterset_form = forms.ModuleBayFilterForm
table = tables.ModuleBayTable table = tables.ModuleBayTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(ModuleBay) @register_model_view(ModuleBay)
@ -2743,7 +2735,6 @@ class DeviceBayListView(generic.ObjectListView):
filterset = filtersets.DeviceBayFilterSet filterset = filtersets.DeviceBayFilterSet
filterset_form = forms.DeviceBayFilterForm filterset_form = forms.DeviceBayFilterForm
table = tables.DeviceBayTable table = tables.DeviceBayTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(DeviceBay) @register_model_view(DeviceBay)
@ -2868,7 +2859,6 @@ class InventoryItemListView(generic.ObjectListView):
filterset = filtersets.InventoryItemFilterSet filterset = filtersets.InventoryItemFilterSet
filterset_form = forms.InventoryItemFilterForm filterset_form = forms.InventoryItemFilterForm
table = tables.InventoryItemTable table = tables.InventoryItemTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(InventoryItem) @register_model_view(InventoryItem)

View File

@ -56,11 +56,13 @@ class CustomFieldVisibilityChoices(ChoiceSet):
VISIBILITY_READ_WRITE = 'read-write' VISIBILITY_READ_WRITE = 'read-write'
VISIBILITY_READ_ONLY = 'read-only' VISIBILITY_READ_ONLY = 'read-only'
VISIBILITY_HIDDEN = 'hidden' VISIBILITY_HIDDEN = 'hidden'
VISIBILITY_HIDDEN_IFUNSET = 'hidden-ifunset'
CHOICES = ( CHOICES = (
(VISIBILITY_READ_WRITE, 'Read/Write'), (VISIBILITY_READ_WRITE, 'Read/Write'),
(VISIBILITY_READ_ONLY, 'Read-only'), (VISIBILITY_READ_ONLY, 'Read-only'),
(VISIBILITY_HIDDEN, 'Hidden'), (VISIBILITY_HIDDEN, 'Hidden'),
(VISIBILITY_HIDDEN_IFUNSET, 'Hidden (if unset)'),
) )

View File

@ -65,8 +65,14 @@ class Condition:
""" """
Evaluate the provided data to determine whether it matches the condition. Evaluate the provided data to determine whether it matches the condition.
""" """
def _get(obj, key):
if isinstance(obj, list):
return [dict.get(i, key) for i in obj]
return dict.get(obj, key)
try: try:
value = functools.reduce(dict.get, self.attr.split('.'), data) value = functools.reduce(_get, self.attr.split('.'), data)
except TypeError: except TypeError:
# Invalid key path # Invalid key path
value = None value = None

View File

@ -10,8 +10,9 @@ from django.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache from django.core.cache import cache
from django.db.models import Q from django.db.models import Q
from django.http import QueryDict
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.urls import NoReverseMatch, reverse from django.urls import NoReverseMatch, resolve, reverse
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from extras.utils import FeatureQuery from extras.utils import FeatureQuery
@ -35,7 +36,8 @@ def get_content_type_labels():
return [ return [
(content_type_identifier(ct), content_type_name(ct)) (content_type_identifier(ct), content_type_name(ct))
for ct in ContentType.objects.filter( for ct in ContentType.objects.filter(
FeatureQuery('export_templates').get_query() | Q(app_label='extras', model='objectchange') FeatureQuery('export_templates').get_query() | Q(app_label='extras', model='objectchange') |
Q(app_label='extras', model='configcontext')
).order_by('app_label', 'model') ).order_by('app_label', 'model')
] ]
@ -148,7 +150,7 @@ class ObjectCountsWidget(DashboardWidget):
filters = forms.JSONField( filters = forms.JSONField(
required=False, required=False,
label='Object filters', label='Object filters',
help_text=_("Only objects matching the specified filters will be counted") help_text=_("Filters to apply when counting the number of objects")
) )
def clean_filters(self): def clean_filters(self):
@ -157,13 +159,6 @@ class ObjectCountsWidget(DashboardWidget):
dict(data) dict(data)
except TypeError: except TypeError:
raise forms.ValidationError("Invalid format. Object filters must be passed as a dictionary.") raise forms.ValidationError("Invalid format. Object filters must be passed as a dictionary.")
for model in get_models_from_content_types(self.cleaned_data.get('models')):
try:
# Validate the filters by creating a QuerySet
model.objects.filter(**data).none()
except Exception:
model_name = model._meta.verbose_name_plural
raise forms.ValidationError(f"Invalid filter specification for {model_name}.")
return data return data
def render(self, request): def render(self, request):
@ -171,13 +166,19 @@ class ObjectCountsWidget(DashboardWidget):
for model in get_models_from_content_types(self.config['models']): for model in get_models_from_content_types(self.config['models']):
permission = get_permission_for_model(model, 'view') permission = get_permission_for_model(model, 'view')
if request.user.has_perm(permission): if request.user.has_perm(permission):
url = reverse(get_viewname(model, 'list'))
qs = model.objects.restrict(request.user, 'view') qs = model.objects.restrict(request.user, 'view')
# Apply any specified filters
if filters := self.config.get('filters'): if filters := self.config.get('filters'):
qs = qs.filter(**filters) params = QueryDict(mutable=True)
params.update(filters)
filterset = getattr(resolve(url).func.view_class, 'filterset', None)
qs = filterset(params, qs).qs
url = f'{url}?{params.urlencode()}'
object_count = qs.count object_count = qs.count
counts.append((model, object_count)) counts.append((model, object_count, url))
else: else:
counts.append((model, None)) counts.append((model, None, None))
return render_to_string(self.template_name, { return render_to_string(self.template_name, {
'counts': counts, 'counts': counts,

View File

@ -7,12 +7,14 @@ class Empty(Lookup):
Filter on whether a string is empty. Filter on whether a string is empty.
""" """
lookup_name = 'empty' lookup_name = 'empty'
prepare_rhs = False
def as_sql(self, qn, connection): def as_sql(self, compiler, connection):
lhs, lhs_params = self.process_lhs(qn, connection) sql, params = compiler.compile(self.lhs)
rhs, rhs_params = self.process_rhs(qn, connection) if self.rhs:
params = lhs_params + rhs_params return f"CAST(LENGTH({sql}) AS BOOLEAN) IS NOT TRUE", params
return 'CAST(LENGTH(%s) AS BOOLEAN) != %s' % (lhs, rhs), params else:
return f"CAST(LENGTH({sql}) AS BOOLEAN) IS TRUE", params
class NetContainsOrEquals(Lookup): class NetContainsOrEquals(Lookup):

View File

@ -146,6 +146,7 @@ class ConfigContext(SyncedDataMixin, CloningMixin, ChangeLoggedModel):
Synchronize context data from the designated DataFile (if any). Synchronize context data from the designated DataFile (if any).
""" """
self.data = self.data_file.get_data() self.data = self.data_file.get_data()
sync_data.alters_data = True
class ConfigContextModel(models.Model): class ConfigContextModel(models.Model):
@ -236,6 +237,7 @@ class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLog
Synchronize template content from the designated DataFile (if any). Synchronize template content from the designated DataFile (if any).
""" """
self.template_code = self.data_file.data_as_string self.template_code = self.data_file.data_as_string
sync_data.alters_data = True
def render(self, context=None): def render(self, context=None):
""" """

View File

@ -274,10 +274,10 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
:param context: The context passed to Jinja2 :param context: The context passed to Jinja2
""" """
text = render_jinja2(self.link_text, context) text = render_jinja2(self.link_text, context).strip()
if not text: if not text:
return {} return {}
link = render_jinja2(self.link_url, context) link = render_jinja2(self.link_url, context).strip()
link_target = ' target="_blank"' if self.new_window else '' link_target = ' target="_blank"' if self.new_window else ''
# Sanitize link text # Sanitize link text
@ -285,7 +285,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
text = clean_html(text, allowed_schemes) text = clean_html(text, allowed_schemes)
# Sanitize link # Sanitize link
link = urllib.parse.quote_plus(link, safe='/:?&=%+[]@#') link = urllib.parse.quote(link, safe='/:?&=%+[]@#')
# Verify link scheme is allowed # Verify link scheme is allowed
result = urllib.parse.urlparse(link) result = urllib.parse.urlparse(link)
@ -362,6 +362,7 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
Synchronize template content from the designated DataFile (if any). Synchronize template content from the designated DataFile (if any).
""" """
self.template_code = self.data_file.data_as_string self.template_code = self.data_file.data_as_string
sync_data.alters_data = True
def render(self, queryset): def render(self, queryset):
""" """
@ -625,6 +626,7 @@ class ConfigRevision(models.Model):
""" """
cache.set('config', self.data, None) cache.set('config', self.data, None)
cache.set('config_version', self.pk, None) cache.set('config_version', self.pk, None)
activate.alters_data = True
@admin.display(boolean=True) @admin.display(boolean=True)
def is_active(self): def is_active(self):

View File

@ -112,3 +112,4 @@ class StagedChange(ChangeLoggedModel):
instance = self.model.objects.get(pk=self.object_id) instance = self.model.objects.get(pk=self.object_id)
logger.info(f'Deleting {self.model._meta.verbose_name} {instance}') logger.info(f'Deleting {self.model._meta.verbose_name} {instance}')
instance.delete() instance.delete()
apply.alters_data = True

View File

@ -22,6 +22,14 @@ __all__ = (
'WebhookTable', 'WebhookTable',
) )
IMAGEATTACHMENT_IMAGE = '''
{% if record.image %}
<a class="image-preview" href="{{ record.image.url }}" target="_blank">{{ record }}</a>
{% else %}
&mdash;
{% endif %}
'''
class CustomFieldTable(NetBoxTable): class CustomFieldTable(NetBoxTable):
name = tables.Column( name = tables.Column(
@ -96,6 +104,9 @@ class ImageAttachmentTable(NetBoxTable):
parent = tables.Column( parent = tables.Column(
linkify=True linkify=True
) )
image = tables.TemplateColumn(
template_code=IMAGEATTACHMENT_IMAGE,
)
size = tables.Column( size = tables.Column(
orderable=False, orderable=False,
verbose_name='Size (bytes)' verbose_name='Size (bytes)'

View File

@ -9,6 +9,7 @@ from netbox.config import get_config
from netbox.constants import RQ_QUEUE_DEFAULT from netbox.constants import RQ_QUEUE_DEFAULT
from netbox.registry import registry from netbox.registry import registry
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
from utilities.rqworker import get_rq_retry
from utilities.utils import serialize_object from utilities.utils import serialize_object
from .choices import * from .choices import *
from .models import Webhook from .models import Webhook
@ -116,5 +117,6 @@ def flush_webhooks(queue):
snapshots=data['snapshots'], snapshots=data['snapshots'],
timestamp=str(timezone.now()), timestamp=str(timezone.now()),
username=data['username'], username=data['username'],
request_id=data['request_id'] request_id=data['request_id'],
retry=get_rq_retry()
) )

View File

@ -1,6 +1,7 @@
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Q
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from dcim.models import Device, Interface, Site from dcim.models import Device, Interface, Site
@ -181,16 +182,31 @@ class PrefixImportForm(NetBoxModelImportForm):
def __init__(self, data=None, *args, **kwargs): def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs) super().__init__(data, *args, **kwargs)
if data: if not data:
return
site = data.get('site')
vlan_group = data.get('vlan_group')
# Limit VLAN queryset by assigned site and/or group (if specified) # Limit VLAN queryset by assigned site and/or group (if specified)
params = {} query = Q()
if data.get('site'):
params[f"site__{self.fields['site'].to_field_name}"] = data.get('site') if site:
if data.get('vlan_group'): query |= Q(**{
params[f"group__{self.fields['vlan_group'].to_field_name}"] = data.get('vlan_group') f"site__{self.fields['site'].to_field_name}": site
if params: })
self.fields['vlan'].queryset = self.fields['vlan'].queryset.filter(**params) # Don't Forget to include VLANs without a site in the filter
query |= Q(**{
f"site__{self.fields['site'].to_field_name}__isnull": True
})
if vlan_group:
query &= Q(**{
f"group__{self.fields['vlan_group'].to_field_name}": vlan_group
})
queryset = self.fields['vlan'].queryset.filter(query)
self.fields['vlan'].queryset = queryset
class IPRangeImportForm(NetBoxModelImportForm): class IPRangeImportForm(NetBoxModelImportForm):

View File

@ -211,10 +211,8 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
vlan = DynamicModelChoiceField( vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
required=False, required=False,
selector=True,
label=_('VLAN'), label=_('VLAN'),
query_params={
'site_id': '$site',
}
) )
role = DynamicModelChoiceField( role = DynamicModelChoiceField(
queryset=Role.objects.all(), queryset=Role.objects.all(),
@ -328,6 +326,12 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
): ):
self.initial['primary_for_parent'] = True self.initial['primary_for_parent'] = True
# Disable object assignment fields if the IP address is designated as primary
if self.initial.get('primary_for_parent'):
self.fields['interface'].disabled = True
self.fields['vminterface'].disabled = True
self.fields['fhrpgroup'].disabled = True
def clean(self): def clean(self):
super().clean() super().clean()
@ -340,7 +344,12 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
selected_objects[1]: "An IP address can only be assigned to a single object." selected_objects[1]: "An IP address can only be assigned to a single object."
}) })
elif selected_objects: elif selected_objects:
self.instance.assigned_object = self.cleaned_data[selected_objects[0]] assigned_object = self.cleaned_data[selected_objects[0]]
if self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object:
raise ValidationError(
"Cannot reassign IP address while it is designated as the primary IP for the parent object"
)
self.instance.assigned_object = assigned_object
else: else:
self.instance.assigned_object = None self.instance.assigned_object = None
@ -351,6 +360,18 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs." 'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs."
) )
# Do not allow assigning a network ID or broadcast address to an interface.
if interface and (address := self.cleaned_data.get('address')):
if address.ip == address.network:
msg = f"{address} is a network ID, which may not be assigned to an interface."
if address.version == 4 and address.prefixlen not in (31, 32):
raise ValidationError(msg)
if address.version == 6 and address.prefixlen not in (127, 128):
raise ValidationError(msg)
if address.version == 4 and address.ip == address.broadcast and address.prefixlen not in (31, 32):
msg = f"{address} is a broadcast address, which may not be assigned to an interface."
raise ValidationError(msg)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
ipaddress = super().save(*args, **kwargs) ipaddress = super().save(*args, **kwargs)

View File

@ -495,6 +495,65 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk}) url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk})
self.assertHttpStatus(self.client.get(url), 200) self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_prefix_import(self):
"""
Custom import test for YAML-based imports (versus CSV)
"""
IMPORT_DATA = """
prefix: 10.1.1.0/24
status: active
vlan: 101
site: Site 1
"""
# Note, a site is not tied to the VLAN to verify the fix for #12622
VLAN.objects.create(vid=101, name='VLAN101')
# Add all required permissions to the test user
self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
form_data = {
'data': IMPORT_DATA,
'format': 'yaml'
}
response = self.client.post(reverse('ipam:prefix_import'), data=form_data, follow=True)
self.assertHttpStatus(response, 200)
prefix = Prefix.objects.get(prefix='10.1.1.0/24')
self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE)
self.assertEqual(prefix.vlan.vid, 101)
self.assertEqual(prefix.site.name, "Site 1")
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_prefix_import_with_vlan_group(self):
"""
This test covers a unique import edge case where VLAN group is specified during the import.
"""
IMPORT_DATA = """
prefix: 10.1.2.0/24
status: active
vlan: 102
site: Site 1
vlan_group: Group 1
"""
vlan_group = VLANGroup.objects.create(name='Group 1', slug='group-1', scope=Site.objects.get(name="Site 1"))
VLAN.objects.create(vid=102, name='VLAN102', group=vlan_group)
# Add all required permissions to the test user
self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
form_data = {
'data': IMPORT_DATA,
'format': 'yaml'
}
response = self.client.post(reverse('ipam:prefix_import'), data=form_data, follow=True)
self.assertHttpStatus(response, 200)
prefix = Prefix.objects.get(prefix='10.1.2.0/24')
self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE)
self.assertEqual(prefix.vlan.vid, 102)
self.assertEqual(prefix.site.name, "Site 1")
class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase): class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = IPRange model = IPRange

View File

@ -15,11 +15,12 @@ from utilities.api import get_serializer_for_model
__all__ = ( __all__ = (
'BriefModeMixin', 'BriefModeMixin',
'BulkDestroyModelMixin',
'BulkUpdateModelMixin', 'BulkUpdateModelMixin',
'CustomFieldsMixin', 'CustomFieldsMixin',
'ExportTemplatesMixin', 'ExportTemplatesMixin',
'BulkDestroyModelMixin',
'ObjectValidationMixin', 'ObjectValidationMixin',
'SequentialBulkCreatesMixin',
) )
@ -94,6 +95,30 @@ class ExportTemplatesMixin:
return super().list(request, *args, **kwargs) return super().list(request, *args, **kwargs)
class SequentialBulkCreatesMixin:
"""
Perform bulk creation of new objects sequentially, rather than all at once. This ensures that any validation
which depends on the evaluation of existing objects (such as checking for free space within a rack) functions
appropriately.
"""
@transaction.atomic
def create(self, request, *args, **kwargs):
if not isinstance(request.data, list):
# Creating a single object
return super().create(request, *args, **kwargs)
return_data = []
for data in request.data:
serializer = self.get_serializer(data=data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
return_data.append(serializer.data)
headers = self.get_success_headers(serializer.data)
return Response(return_data, status=status.HTTP_201_CREATED, headers=headers)
class BulkUpdateModelMixin: class BulkUpdateModelMixin:
""" """
Support bulk modification of objects using the list endpoint for a model. Accepts a PATCH action with a list of one Support bulk modification of objects using the list endpoint for a model. Accepts a PATCH action with a list of one

View File

@ -177,7 +177,8 @@ class BaseFilterSet(django_filters.FilterSet):
# create the new filter with the same type because there is no guarantee the defined type # create the new filter with the same type because there is no guarantee the defined type
# is the same as the default type for the field # is the same as the default type for the field
resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid
new_filter = type(existing_filter)( filter_cls = django_filters.BooleanFilter if lookup_expr == 'empty' else type(existing_filter)
new_filter = filter_cls(
field_name=field_name, field_name=field_name,
lookup_expr=lookup_expr, lookup_expr=lookup_expr,
label=existing_filter.label, label=existing_filter.label,
@ -224,6 +225,14 @@ class BaseFilterSet(django_filters.FilterSet):
return filters return filters
@classmethod
def filter_for_lookup(cls, field, lookup_type):
if lookup_type == 'empty':
return django_filters.BooleanFilter, {}
return super().filter_for_lookup(field, lookup_type)
class ChangeLoggedModelFilterSet(BaseFilterSet): class ChangeLoggedModelFilterSet(BaseFilterSet):
""" """

View File

@ -181,19 +181,23 @@ class MaintenanceModeMiddleware:
def __call__(self, request): def __call__(self, request):
if get_config().MAINTENANCE_MODE: if get_config().MAINTENANCE_MODE:
self._prevent_db_write_operations() self._set_session_type(
allow_write=request.path_info.startswith(settings.MAINTENANCE_EXEMPT_PATHS)
)
return self.get_response(request) return self.get_response(request)
@staticmethod @staticmethod
def _prevent_db_write_operations(): def _set_session_type(allow_write):
""" """
Prevent any write-related database operations. Prevent any write-related database operations.
Args:
allow_write (bool): If True, write operations will be permitted.
""" """
with connection.cursor() as cursor: with connection.cursor() as cursor:
cursor.execute( mode = 'READ WRITE' if allow_write else 'READ ONLY'
'SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY;' cursor.execute(f'SET SESSION CHARACTERISTICS AS TRANSACTION {mode};')
)
def process_exception(self, request, exception): def process_exception(self, request, exception):
""" """

View File

@ -71,6 +71,7 @@ class ChangeLoggingMixin(models.Model):
`_prechange_snapshot` on the instance. `_prechange_snapshot` on the instance.
""" """
self._prechange_snapshot = self.serialize_object() self._prechange_snapshot = self.serialize_object()
snapshot.alters_data = True
def to_objectchange(self, action): def to_objectchange(self, action):
""" """
@ -197,11 +198,15 @@ class CustomFieldsMixin(models.Model):
data = {} data = {}
for field in CustomField.objects.get_for_model(self): for field in CustomField.objects.get_for_model(self):
value = self.custom_field_data.get(field.name)
# Skip fields that are hidden if 'omit_hidden' is set # Skip fields that are hidden if 'omit_hidden' is set
if omit_hidden and field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN: if omit_hidden:
if field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN:
continue
if field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET and not value:
continue continue
value = self.custom_field_data.get(field.name)
data[field] = field.deserialize(value) data[field] = field.deserialize(value)
return data return data
@ -227,6 +232,8 @@ class CustomFieldsMixin(models.Model):
for cf in visible_custom_fields: for cf in visible_custom_fields:
value = self.custom_field_data.get(cf.name) value = self.custom_field_data.get(cf.name)
if value in (None, []) and cf.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET:
continue
value = cf.deserialize(value) value = cf.deserialize(value)
groups[cf.group_name][cf] = value groups[cf.group_name][cf] = value
@ -238,6 +245,7 @@ class CustomFieldsMixin(models.Model):
""" """
for cf in self.custom_fields: for cf in self.custom_fields:
self.custom_field_data[cf.name] = cf.default self.custom_field_data[cf.name] = cf.default
populate_custom_field_defaults.alters_data = True
def clean(self): def clean(self):
super().clean() super().clean()
@ -413,6 +421,7 @@ class SyncedDataMixin(models.Model):
self.data_synced = None self.data_synced = None
super().clean() super().clean()
clean.alters_data = True
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
from core.models import AutoSyncRecord from core.models import AutoSyncRecord
@ -460,6 +469,7 @@ class SyncedDataMixin(models.Model):
self.data_synced = timezone.now() self.data_synced = timezone.now()
if save: if save:
self.save() self.save()
sync.alters_data = True
def sync_data(self): def sync_data(self):
""" """

View File

@ -102,7 +102,7 @@ CONNECTIONS_MENU = Menu(
label=_('Connections'), label=_('Connections'),
items=( items=(
get_model_item('dcim', 'cable', _('Cables'), actions=['import']), get_model_item('dcim', 'cable', _('Cables'), actions=['import']),
get_model_item('wireless', 'wirelesslink', _('Wireless Links'), actions=['import']), get_model_item('wireless', 'wirelesslink', _('Wireless Links')),
MenuItem( MenuItem(
link='dcim:interface_connections_list', link='dcim:interface_connections_list',
link_text=_('Interface Connections'), link_text=_('Interface Connections'),

View File

@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
# Environment setup # Environment setup
# #
VERSION = '3.5.2-dev' VERSION = '3.5.4-dev'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()
@ -140,6 +140,8 @@ REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', [])
REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|') REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|')
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300) RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
RQ_RETRY_INTERVAL = getattr(configuration, 'RQ_RETRY_INTERVAL', 60)
RQ_RETRY_MAX = getattr(configuration, 'RQ_RETRY_MAX', 0)
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/') SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend') SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend')
SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False) SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False)
@ -478,6 +480,11 @@ AUTH_EXEMPT_PATHS = (
f'/{BASE_PATH}metrics', f'/{BASE_PATH}metrics',
) )
# All URLs starting with a string listed here are exempt from maintenance mode enforcement
MAINTENANCE_EXEMPT_PATHS = (
f'/{BASE_PATH}admin/',
)
SERIALIZATION_MODULES = { SERIALIZATION_MODULES = {
'json': 'utilities.serializers.json', 'json': 'utilities.serializers.json',
} }

View File

@ -234,8 +234,12 @@ class ActionsColumn(tables.Column):
return '' return ''
model = table.Meta.model model = table.Meta.model
request = getattr(table, 'context', {}).get('request') if request := getattr(table, 'context', {}).get('request'):
url_appendix = f'?return_url={quote(request.get_full_path())}' if request else '' return_url = request.GET.get('return_url', request.get_full_path())
url_appendix = f'?return_url={quote(return_url)}'
else:
url_appendix = ''
html = '' html = ''
# Compile actions menu # Compile actions menu

Binary file not shown.

Binary file not shown.

View File

@ -30,6 +30,7 @@
"dayjs": "^1.11.5", "dayjs": "^1.11.5",
"flatpickr": "4.6.13", "flatpickr": "4.6.13",
"gridstack": "^7.2.3", "gridstack": "^7.2.3",
"html-entities": "^2.3.3",
"htmx.org": "^1.8.0", "htmx.org": "^1.8.0",
"just-debounce-it": "^3.1.1", "just-debounce-it": "^3.1.1",
"query-string": "^7.1.1", "query-string": "^7.1.1",

View File

@ -2,9 +2,10 @@ import { getElements, isTruthy } from './util';
import { initButtons } from './buttons'; import { initButtons } from './buttons';
import { initSelect } from './select'; import { initSelect } from './select';
import { initObjectSelector } from './objectSelector'; import { initObjectSelector } from './objectSelector';
import { initBootstrap } from './bs';
function initDepedencies(): void { function initDepedencies(): void {
for (const init of [initButtons, initSelect, initObjectSelector]) { for (const init of [initButtons, initSelect, initObjectSelector, initBootstrap]) {
init(); init();
} }
} }
@ -22,4 +23,8 @@ export function initHtmx(): void {
} }
} }
} }
for (const element of getElements('[hx-trigger=load]')) {
element.addEventListener('htmx:afterSettle', initDepedencies);
}
} }

View File

@ -1,5 +1,6 @@
import { readableColor } from 'color2k'; import { readableColor } from 'color2k';
import debounce from 'just-debounce-it'; import debounce from 'just-debounce-it';
import { encode } from 'html-entities';
import queryString from 'query-string'; import queryString from 'query-string';
import SlimSelect from 'slim-select'; import SlimSelect from 'slim-select';
import { createToast } from '../../bs'; import { createToast } from '../../bs';
@ -446,7 +447,7 @@ export class APISelect {
// Build SlimSelect options from all already-selected options. // Build SlimSelect options from all already-selected options.
const preSelectedOptions = preSelected.map(option => ({ const preSelectedOptions = preSelected.map(option => ({
value: option.value, value: option.value,
text: option.innerText, text: encode(option.innerText),
selected: true, selected: true,
disabled: false, disabled: false,
})) as Option[]; })) as Option[];
@ -454,7 +455,7 @@ export class APISelect {
let options = [] as Option[]; let options = [] as Option[];
for (const result of data.results) { for (const result of data.results) {
let text = result.display; let text = encode(result.display);
if (typeof result._depth === 'number' && result._depth > 0) { if (typeof result._depth === 'number' && result._depth > 0) {
// If the object has a `_depth` property, indent its display text. // If the object has a `_depth` property, indent its display text.

View File

@ -1818,6 +1818,11 @@ has@^1.0.3:
dependencies: dependencies:
function-bind "^1.1.1" function-bind "^1.1.1"
html-entities@^2.3.3:
version "2.3.3"
resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.3.3.tgz#117d7626bece327fc8baace8868fa6f5ef856e46"
integrity sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==
htmx.org@^1.8.0: htmx.org@^1.8.0:
version "1.8.0" version "1.8.0"
resolved "https://registry.yarnpkg.com/htmx.org/-/htmx.org-1.8.0.tgz#f3a2f681f3e2b6357b5a29bba24a2572a8e48fd3" resolved "https://registry.yarnpkg.com/htmx.org/-/htmx.org-1.8.0.tgz#f3a2f681f3e2b6357b5a29bba24a2572a8e48fd3"

View File

@ -76,6 +76,23 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr>
<th scope="row">GPS Coordinates</th>
<td class="position-relative">
{% if object.latitude and object.longitude %}
{% if config.MAPS_URL %}
<div class="position-absolute top-50 end-0 translate-middle-y noprint">
<a href="{{ config.MAPS_URL }}{{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary btn-sm">
<i class="mdi mdi-map-marker"></i> Map It
</a>
</div>
{% endif %}
<span>{{ object.latitude }}, {{ object.longitude }}</span>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr> <tr>
<th scope="row">Tenant</th> <th scope="row">Tenant</th>
<td> <td>

View File

@ -28,11 +28,25 @@
</div> </div>
<div class="col-7"> <div class="col-7">
<div class="card"> <div class="card">
<h5 class="card-header">Context Data</h5> <div class="accordion accordion-flush" id="renderConfig">
<div class="card-body">
<div class="accordion-item">
<h2 class="accordion-header" id="renderConfigHeading">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapsedRenderConfig" aria-expanded="false" aria-controls="collapsedRenderConfig">
Context Data
</button>
</h2>
<div id="collapsedRenderConfig" class="accordion-collapse collapse" aria-labelledby="renderConfigHeading" data-bs-parent="#renderConfig">
<div class="accordion-body">
<pre class="card-body">{{ context_data|pprint }}</pre> <pre class="card-body">{{ context_data|pprint }}</pre>
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
</div>
</div>
</div>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<div class="card"> <div class="card">

View File

@ -53,6 +53,8 @@
{% else %} {% else %}
{% render_field form.face %} {% render_field form.face %}
{% render_field form.position %} {% render_field form.position %}
{% render_field form.latitude %}
{% render_field form.longitude %}
{% endif %} {% endif %}
</div> </div>

View File

@ -123,11 +123,11 @@
<table class="table table-hover"> <table class="table table-hover">
<tr> <tr>
<th scope="row">MAC Address</th> <th scope="row">MAC Address</th>
<td><span class="text-monospace">{{ object.mac_address|placeholder }}</span></td> <td><span class="font-monospace">{{ object.mac_address|placeholder }}</span></td>
</tr> </tr>
<tr> <tr>
<th scope="row">WWN</th> <th scope="row">WWN</th>
<td><span class="text-monospace">{{ object.wwn|placeholder }}</span></td> <td><span class="font-monospace">{{ object.wwn|placeholder }}</span></td>
</tr> </tr>
<tr> <tr>
<th scope="row">VRF</th> <th scope="row">VRF</th>

View File

@ -1,10 +1,8 @@
{% load helpers %}
{% if counts %} {% if counts %}
<div class="list-group list-group-flush"> <div class="list-group list-group-flush">
{% for model, count in counts %} {% for model, count, url in counts %}
{% if count != None %} {% if count != None %}
<a href="{% url model|viewname:"list" %}" class="list-group-item list-group-item-action"> <a href="{{ url }}" class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between align-items-center"> <div class="d-flex w-100 justify-content-between align-items-center">
{{ model|meta:"verbose_name_plural"|bettertitle }} {{ model|meta:"verbose_name_plural"|bettertitle }}
<h6 class="mb-1">{{ count }}</h6> <h6 class="mb-1">{{ count }}</h6>

View File

@ -1,12 +1,8 @@
{% load helpers %} {% load helpers %}
<div class="card"> <div class="card">
<h5 class="card-header"> <h5 class="card-header">Images</h5>
Images {% htmx_table 'extras:imageattachment_list' content_type_id=object|content_type_id object_id=object.pk %}
</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'extras:imageattachment_list' %}?content_type_id={{ object|content_type_id }}&object_id={{ object.pk }}"
hx-trigger="load"></div>
{% if perms.extras.add_imageattachment %} {% if perms.extras.add_imageattachment %}
<div class="card-footer text-end noprint"> <div class="card-footer text-end noprint">
<a href="{% url 'extras:imageattachment_add' %}?content_type={{ object|content_type_id }}&object_id={{ object.pk }}" class="btn btn-primary btn-sm"> <a href="{% url 'extras:imageattachment_add' %}?content_type={{ object|content_type_id }}&object_id={{ object.pk }}" class="btn btn-primary btn-sm">

View File

@ -10,7 +10,7 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="ContactTable_config" %} {% include 'inc/table_controls_htmx.html' with table_modal="ContactAssignmentTable_config" %}
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
<div class="card"> <div class="card">

View File

@ -59,7 +59,7 @@
</tr> </tr>
<tr> <tr>
<th scope="row">MAC Address</th> <th scope="row">MAC Address</th>
<td><span class="text-monospace">{{ object.mac_address|placeholder }}</span></td> <td><span class="font-monospace">{{ object.mac_address|placeholder }}</span></td>
</tr> </tr>
<tr> <tr>
<th scope="row">802.1Q Mode</th> <th scope="row">802.1Q Mode</th>

View File

@ -15,21 +15,31 @@ from .models import *
class ObjectContactsView(generic.ObjectChildrenView): class ObjectContactsView(generic.ObjectChildrenView):
child_model = Contact child_model = ContactAssignment
table = tables.ContactTable table = tables.ContactAssignmentTable
filterset = filtersets.ContactFilterSet filterset = filtersets.ContactAssignmentFilterSet
template_name = 'tenancy/object_contacts.html' template_name = 'tenancy/object_contacts.html'
tab = ViewTab( tab = ViewTab(
label=_('Contacts'), label=_('Contacts'),
badge=lambda obj: obj.contacts.count(), badge=lambda obj: obj.contacts.count(),
permission='tenancy.view_contact', permission='tenancy.view_contactassignment',
weight=5000 weight=5000
) )
def get_children(self, request, parent): def get_children(self, request, parent):
return Contact.objects.annotate( return ContactAssignment.objects.restrict(request.user, 'view').filter(
assignment_count=count_related(ContactAssignment, 'contact') content_type=ContentType.objects.get_for_model(parent),
).restrict(request.user, 'view').filter(assignments__object_id=parent.pk) object_id=parent.pk
)
def get_table(self, *args, **kwargs):
table = super().get_table(*args, **kwargs)
# Hide object columns
table.columns.hide('content_type')
table.columns.hide('object')
return table
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
return { return {

View File

@ -1,10 +1,18 @@
import logging import logging
from django.dispatch import receiver from django.dispatch import receiver
from django.contrib.auth.signals import user_login_failed from django.contrib.auth.signals import user_login_failed
from utilities.request import get_client_ip
@receiver(user_login_failed) @receiver(user_login_failed)
def log_user_login_failed(sender, credentials, request, **kwargs): def log_user_login_failed(sender, credentials, request, **kwargs):
logger = logging.getLogger('netbox.auth.login') logger = logging.getLogger('netbox.auth.login')
username = credentials.get("username") username = credentials.get("username")
if client_ip := get_client_ip(request):
logger.info(f"Failed login attempt for username: {username} from {client_ip}")
else:
logger.warning(
"Client IP address could not be determined for validation. Check that the HTTP server is properly "
"configured to pass the required header(s)."
)
logger.info(f"Failed login attempt for username: {username}") logger.info(f"Failed login attempt for username: {username}")

View File

@ -1,11 +1,12 @@
from django_rq.queues import get_connection from django_rq.queues import get_connection
from rq import Worker from rq import Retry, Worker
from netbox.config import get_config from netbox.config import get_config
from netbox.constants import RQ_QUEUE_DEFAULT from netbox.constants import RQ_QUEUE_DEFAULT
__all__ = ( __all__ = (
'get_queue_for_model', 'get_queue_for_model',
'get_rq_retry',
'get_workers_for_queue', 'get_workers_for_queue',
) )
@ -22,3 +23,14 @@ def get_workers_for_queue(queue_name):
Returns True if a worker process is currently servicing the specified queue. Returns True if a worker process is currently servicing the specified queue.
""" """
return Worker.count(get_connection(queue_name)) return Worker.count(get_connection(queue_name))
def get_rq_retry():
"""
If RQ_RETRY_MAX is defined and greater than zero, instantiate and return a Retry object to be
used when queuing a job. Otherwise, return None.
"""
retry_max = get_config().RQ_RETRY_MAX
retry_interval = get_config().RQ_RETRY_INTERVAL
if retry_max:
return Retry(max=retry_max, interval=retry_interval)

View File

@ -0,0 +1,4 @@
<div class="card-body htmx-container table-responsive"
hx-get="{% url viewname %}{% if url_params %}?{{ url_params.urlencode }}{% endif %}"
hx-trigger="load"
></div>

View File

@ -1,4 +1,5 @@
from django import template from django import template
from django.http import QueryDict
__all__ = ( __all__ = (
'badge', 'badge',
@ -74,3 +75,22 @@ def checkmark(value, show_false=True, true='Yes', false='No'):
'true_label': true, 'true_label': true,
'false_label': false, 'false_label': false,
} }
@register.inclusion_tag('builtins/htmx_table.html', takes_context=True)
def htmx_table(context, viewname, return_url=None, **kwargs):
"""
Embed an object list table retrieved using HTMX. Any extra keyword arguments are passed as URL query parameters.
Args:
context: The current request context
viewname: The name of the view to use for the HTMX request (e.g. `dcim:site_list`)
return_url: The URL to pass as the `return_url`. If not provided, the current request's path will be used.
"""
url_params = QueryDict(mutable=True)
url_params.update(kwargs)
url_params['return_url'] = return_url or context['request'].path
return {
'viewname': viewname,
'url_params': url_params,
}

View File

@ -302,7 +302,7 @@ def to_meters(length, unit):
if unit == CableLengthUnitChoices.UNIT_FOOT: if unit == CableLengthUnitChoices.UNIT_FOOT:
return length * Decimal(0.3048) return length * Decimal(0.3048)
if unit == CableLengthUnitChoices.UNIT_INCH: if unit == CableLengthUnitChoices.UNIT_INCH:
return length * Decimal(0.3048) * 12 return length * Decimal(0.0254)
raise ValueError(f"Unknown unit {unit}. Must be 'km', 'm', 'cm', 'mi', 'ft', or 'in'.") raise ValueError(f"Unknown unit {unit}. Must be 'km', 'm', 'cm', 'mi', 'ft', or 'in'.")

View File

@ -178,7 +178,7 @@ def register_model_view(model, name='', path=None, kwargs=None):
This decorator can be used to "attach" a view to any model in NetBox. This is typically used to inject This decorator can be used to "attach" a view to any model in NetBox. This is typically used to inject
additional tabs within a model's detail view. For example, to add a custom tab to NetBox's dcim.Site model: additional tabs within a model's detail view. For example, to add a custom tab to NetBox's dcim.Site model:
@netbox_model_view(Site, 'myview', path='my-custom-view') @register_model_view(Site, 'myview', path='my-custom-view')
class MyView(ObjectView): class MyView(ObjectView):
... ...

View File

@ -65,7 +65,7 @@ class ClusterImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = Cluster model = Cluster
fields = ('name', 'type', 'group', 'status', 'site', 'description', 'comments', 'tags') fields = ('name', 'type', 'group', 'status', 'site', 'tenant', 'description', 'comments', 'tags')
class VirtualMachineImportForm(NetBoxModelImportForm): class VirtualMachineImportForm(NetBoxModelImportForm):

View File

@ -415,7 +415,6 @@ class VMInterfaceListView(generic.ObjectListView):
filterset = filtersets.VMInterfaceFilterSet filterset = filtersets.VMInterfaceFilterSet
filterset_form = forms.VMInterfaceFilterForm filterset_form = forms.VMInterfaceFilterForm
table = tables.VMInterfaceTable table = tables.VMInterfaceTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(VMInterface) @register_model_view(VMInterface)

View File

@ -1,8 +1,8 @@
bleach==6.0.0 bleach==6.0.0
boto3==1.26.127 boto3==1.26.145
Django==4.1.9 Django==4.1.9
django-cors-headers==3.14.0 django-cors-headers==4.0.0
django-debug-toolbar==4.0.0 django-debug-toolbar==4.1.0
django-filter==23.2 django-filter==23.2
django-graphiql-debug-toolbar==0.2.0 django-graphiql-debug-toolbar==0.2.0
django-mptt==0.14 django-mptt==0.14
@ -10,26 +10,26 @@ django-pglocks==1.0.4
django-prometheus==2.3.1 django-prometheus==2.3.1
django-redis==5.2.0 django-redis==5.2.0
django-rich==1.5.0 django-rich==1.5.0
django-rq==2.8.0 django-rq==2.8.1
django-tables2==2.5.3 django-tables2==2.5.3
django-taggit==4.0.0 django-taggit==4.0.0
django-timezone-field==5.0 django-timezone-field==5.0
djangorestframework==3.14.0 djangorestframework==3.14.0
drf-spectacular==0.26.2 drf-spectacular==0.26.2
drf-spectacular-sidecar==2023.5.1 drf-spectacular-sidecar==2023.6.1
dulwich==0.21.5 dulwich==0.21.5
feedparser==6.0.10 feedparser==6.0.10
graphene-django==3.0.0 graphene-django==3.0.0
gunicorn==20.1.0 gunicorn==20.1.0
Jinja2==3.1.2 Jinja2==3.1.2
Markdown==3.3.7 Markdown==3.3.7
mkdocs-material==9.1.9 mkdocs-material==9.1.15
mkdocstrings[python-legacy]==0.21.2 mkdocstrings[python-legacy]==0.22.0
netaddr==0.8.0 netaddr==0.8.0
Pillow==9.5.0 Pillow==9.5.0
psycopg2-binary==2.9.6 psycopg2-binary==2.9.6
PyYAML==6.0 PyYAML==6.0
sentry-sdk==1.22.1 sentry-sdk==1.25.0
social-auth-app-django==5.2.0 social-auth-app-django==5.2.0
social-auth-core[openidconnect]==4.4.2 social-auth-core[openidconnect]==4.4.2
svgwrite==1.4.3 svgwrite==1.4.3