Merge branch 'netbox-community:develop' into virtualmachine-render-config

This commit is contained in:
Pavel Korovin 2023-11-16 16:19:46 +03:00 committed by GitHub
commit e8c5932e91
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
103 changed files with 1647 additions and 363 deletions

View File

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

View File

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

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

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

View File

@ -31,15 +31,15 @@ jobs:
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@v2 uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2 uses: actions/setup-python@v4
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2 uses: actions/setup-node@v3
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
@ -47,7 +47,7 @@ jobs:
run: npm install -g yarn run: npm install -g yarn
- name: Setup Node.js with Yarn Caching - name: Setup Node.js with Yarn Caching
uses: actions/setup-node@v2 uses: actions/setup-node@v3
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: yarn cache: yarn

View File

@ -14,7 +14,7 @@ jobs:
lock: lock:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: dessant/lock-threads@v3 - uses: dessant/lock-threads@v4
with: with:
issue-inactive-days: 90 issue-inactive-days: 90
pr-inactive-days: 30 pr-inactive-days: 30

View File

@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@v6 - uses: actions/stale@v8
with: with:
close-issue-message: > close-issue-message: >
This issue has been automatically closed due to lack of activity. In an This issue has been automatically closed due to lack of activity. In an

View File

@ -1,6 +1,6 @@
<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> <p>The premier source of truth powering network automation</p>
<img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" /> <img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" />
<p></p> <p></p>
</div> </div>

View File

@ -23,8 +23,9 @@ django-filter
django-graphiql-debug-toolbar django-graphiql-debug-toolbar
# Modified Preorder Tree Traversal (recursive nesting of objects) # Modified Preorder Tree Traversal (recursive nesting of objects)
# Pinned to 0.14.0; 0.15.0 requires Python 3.9+
# https://github.com/django-mptt/django-mptt/blob/main/CHANGELOG.rst # https://github.com/django-mptt/django-mptt/blob/main/CHANGELOG.rst
django-mptt django-mptt==0.14.0
# Context managers for PostgreSQL advisory locks # Context managers for PostgreSQL advisory locks
# https://github.com/Xof/django-pglocks/blob/master/CHANGES.txt # https://github.com/Xof/django-pglocks/blob/master/CHANGES.txt
@ -52,7 +53,8 @@ django-tables2
# User-defined tags for objects # User-defined tags for objects
# https://github.com/jazzband/django-taggit/blob/master/CHANGELOG.rst # https://github.com/jazzband/django-taggit/blob/master/CHANGELOG.rst
django-taggit # TODO: Upgrade to v5.0 for NetBox v3.7 beta
django-taggit<5.0
# A Django field for representing time zones # A Django field for representing time zones
# https://github.com/mfogel/django-timezone-field/ # https://github.com/mfogel/django-timezone-field/
@ -120,6 +122,10 @@ psycopg[binary,pool]
# https://github.com/yaml/pyyaml/blob/master/CHANGES # https://github.com/yaml/pyyaml/blob/master/CHANGES
PyYAML PyYAML
# Requests
# https://github.com/psf/requests/blob/main/HISTORY.md
requests
# Sentry SDK # Sentry SDK
# https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md # https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md
sentry-sdk sentry-sdk

View File

@ -342,8 +342,10 @@
"100gbase-x-qsfpdd", "100gbase-x-qsfpdd",
"200gbase-x-qsfp56", "200gbase-x-qsfp56",
"200gbase-x-qsfpdd", "200gbase-x-qsfpdd",
"400gbase-x-qsfp112",
"400gbase-x-qsfpdd", "400gbase-x-qsfpdd",
"400gbase-x-osfp", "400gbase-x-osfp",
"400gbase-x-osfp-rhs",
"400gbase-x-cdfp", "400gbase-x-cdfp",
"400gbase-x-cfp8", "400gbase-x-cfp8",
"800gbase-x-qsfpdd", "800gbase-x-qsfpdd",

View File

@ -20,7 +20,7 @@ DEFAULT_DASHBOARD = [
{ {
'widget': 'extras.ObjectCountsWidget', 'widget': 'extras.ObjectCountsWidget',
'width': 4, 'width': 4,
'height': 2, 'height': 3,
'title': 'Organization', 'title': 'Organization',
'config': { 'config': {
'models': [ 'models': [
@ -32,6 +32,8 @@ DEFAULT_DASHBOARD = [
}, },
{ {
'widget': 'extras.ObjectCountsWidget', 'widget': 'extras.ObjectCountsWidget',
'width': 4,
'height': 3,
'title': 'IPAM', 'title': 'IPAM',
'color': 'blue', 'color': 'blue',
'config': { 'config': {

View File

@ -80,6 +80,14 @@ changes in the database indefinitely.
--- ---
## DATA_UPLOAD_MAX_MEMORY_SIZE
Default: `2621440` (2.5 MB)
The maximum size (in bytes) of an incoming HTTP request (i.e. `GET` or `POST` data). Requests which exceed this size will raise a `RequestDataTooBig` exception.
---
## ENFORCE_GLOBAL_UNIQUE ## ENFORCE_GLOBAL_UNIQUE
!!! tip "Dynamic Configuration Parameter" !!! tip "Dynamic Configuration Parameter"
@ -90,9 +98,9 @@ By default, NetBox will permit users to create duplicate prefixes and IP address
--- ---
## `FILE_UPLOAD_MAX_MEMORY_SIZE` ## FILE_UPLOAD_MAX_MEMORY_SIZE
Default: `2621440` (2.5 MB). Default: `2621440` (2.5 MB)
The maximum amount (in bytes) of uploaded data that will be held in memory before being written to the filesystem. Changing this setting can be useful for example to be able to upload files bigger than 2.5MB to custom scripts for processing. The maximum amount (in bytes) of uploaded data that will be held in memory before being written to the filesystem. Changing this setting can be useful for example to be able to upload files bigger than 2.5MB to custom scripts for processing.

View File

@ -288,7 +288,7 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a
## Running Custom Scripts ## Running Custom Scripts
!!! note !!! note
To run a custom script, a user must be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below. To run a custom script, a user must be assigned via permissions for `Extras > Script`, `Extras > ScriptModule`, and `Core > ManagedFile` objects. They must also be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below.
![Adding the run action to a permission](../media/admin_ui_run_permission.png) ![Adding the run action to a permission](../media/admin_ui_run_permission.png)

View File

@ -132,7 +132,7 @@ Once you have created a report, it will appear in the reports list. Initially, r
## Running Reports ## Running Reports
!!! note !!! note
To run a report, a user must be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below. To run a report, a user must be assigned via permissions for `Extras > Report`, `Extras > ReportModule`, and `Core > ManagedFile` objects. They must also be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below.
![Adding the run action to a permission](../media/admin_ui_run_permission.png) ![Adding the run action to a permission](../media/admin_ui_run_permission.png)

View File

@ -1,6 +1,6 @@
![NetBox](netbox_logo.svg "NetBox logo"){style="height: 100px; margin-bottom: 3em"} ![NetBox](netbox_logo.svg "NetBox logo"){style="height: 100px; margin-bottom: 3em"}
# The Premiere Network Source of Truth # The Premier Network Source of Truth
NetBox is the leading solution for modeling and documenting modern networks. By combining the traditional disciplines of IP address management (IPAM) and datacenter infrastructure management (DCIM) with powerful APIs and extensions, NetBox provides the ideal "source of truth" to power network automation. Read on to discover why thousands of organizations worldwide put NetBox at the heart of their infrastructure. NetBox is the leading solution for modeling and documenting modern networks. By combining the traditional disciplines of IP address management (IPAM) and datacenter infrastructure management (DCIM) with powerful APIs and extensions, NetBox provides the ideal "source of truth" to power network automation. Read on to discover why thousands of organizations worldwide put NetBox at the heart of their infrastructure.

View File

@ -1,5 +1,8 @@
# Installation # Installation
!!! info "NetBox Cloud"
The instructions below are for installing NetBox as a standalone, self-hosted application. For a Cloud-delivered solution, check out [NetBox Cloud](https://netboxlabs.com/netbox-cloud/) by NetBox Labs.
The installation instructions provided here have been tested to work on Ubuntu 22.04 and CentOS 8.3. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors. The installation instructions provided here have been tested to work on Ubuntu 22.04 and CentOS 8.3. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
<iframe width="560" height="315" src="https://www.youtube.com/embed/_y5JRiW_PLM" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe> <iframe width="560" height="315" src="https://www.youtube.com/embed/_y5JRiW_PLM" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

View File

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

View File

@ -1,25 +1,121 @@
# NetBox v3.6 # NetBox v3.6
## v3.6.2 (FUTURE) ## v3.6.6 (FUTURE)
---
## v3.6.5 (2023-11-09)
### Enhancements ### Enhancements
* [#12741](https://github.com/netbox-community/netbox/issues/12741) - Add selector widget to platform field on device & virtual machine forms
* [#13022](https://github.com/netbox-community/netbox/issues/13022) - Introduce support for assigning IP addresses when bulk importing services
* [#13587](https://github.com/netbox-community/netbox/issues/13587) - Annotate units of measurement on power port table columns
* [#13669](https://github.com/netbox-community/netbox/issues/13669) - Add bulk import button to contact assignments list view
* [#13723](https://github.com/netbox-community/netbox/issues/13723) - Add inventory items column to interfaces table
* [#13743](https://github.com/netbox-community/netbox/issues/13743) - Add site column to power feeds table
* [#13936](https://github.com/netbox-community/netbox/issues/13936) - Add primary IPv4 and IPv6 filters for virtual machines and VDCs
* [#13951](https://github.com/netbox-community/netbox/issues/13951) - Add device & virtual machine fields to service filter form
* [#14085](https://github.com/netbox-community/netbox/issues/14085) - Strip trailing port number from value returned by `get_client_ip()`
* [#14101](https://github.com/netbox-community/netbox/issues/14101) - Add greater/less than mask length filters for IP addresses
* [#14112](https://github.com/netbox-community/netbox/issues/14112) - Add tab listing child items under inventory item view
* [#14113](https://github.com/netbox-community/netbox/issues/14113) - Add optional parent column to inventory items table
* [#14220](https://github.com/netbox-community/netbox/issues/14220) - Order available columns alphabetically in table configuration form
* [#14221](https://github.com/netbox-community/netbox/issues/14221) - Add contact group column on contact assignments table
### Bug Fixes
* [#14033](https://github.com/netbox-community/netbox/issues/14033) - Avoid exception when attempting to connect both ends of a cable to the same object
* [#14117](https://github.com/netbox-community/netbox/issues/14117) - Check that enough rear port positions have been selected to accommodate the number of front ports being created
* [#14166](https://github.com/netbox-community/netbox/issues/14166) - Permit user login when maintenance mode is enabled
* [#14182](https://github.com/netbox-community/netbox/issues/14182) - Ensure the active configuration is restored upon clearing cache
* [#14195](https://github.com/netbox-community/netbox/issues/14195) - Correct permissions evaluation for ASN range child ASNs view
* [#14223](https://github.com/netbox-community/netbox/issues/14223) - Disable ordering of jobs by assigned object
---
## v3.6.4 (2023-10-17)
### Enhancements
* [#12831](https://github.com/netbox-community/netbox/issues/12831) - Include circuit description in cable trace SVG image
* [#12872](https://github.com/netbox-community/netbox/issues/12872) - Introduce the `DATA_UPLOAD_MAX_MEMORY_SIZE` configuration parameter
* [#13950](https://github.com/netbox-community/netbox/issues/13950) - Display custom choice field labels rather than values in UI
* [#13957](https://github.com/netbox-community/netbox/issues/13957) - Add DNS name filter on IP addresses list
* [#13962](https://github.com/netbox-community/netbox/issues/13962) - Add a copy-to-clipboard button for API tokens
* [#13972](https://github.com/netbox-community/netbox/issues/13972) - Introduce a filter to find unterminated cables
### Bug Fixes
* [#11987](https://github.com/netbox-community/netbox/issues/11987) - Fix validation of bulk cable updates via bulk import form
* [#12328](https://github.com/netbox-community/netbox/issues/12328) - Ensure generic foreign key relationships are populated in REST API serializations of objects
* [#12336](https://github.com/netbox-community/netbox/issues/12336) - Employ PostgreSQL advisory locks to avoid duplicate MPTT tree IDs when bulk creating objects
* [#13064](https://github.com/netbox-community/netbox/issues/13064) - Fix resetting of checkbox fields triggered by HTMX form re-rendering
* [#13440](https://github.com/netbox-community/netbox/issues/13440) - Fix support for assigning a tenant when creating "next available" VLANs via the REST API
* [#13746](https://github.com/netbox-community/netbox/issues/13746) - Fix support for setting custom field values when creating "next available" IP addresses via the REST API
* [#13872](https://github.com/netbox-community/netbox/issues/13872) - Add CSV delimiter field to file upload tab under bulk object upload views
* [#13876](https://github.com/netbox-community/netbox/issues/13876) - Fix support for assigning an interface when creating "next available" IP addresses via the REST API
* [#13910](https://github.com/netbox-community/netbox/issues/13910) - Correct "add device" button link under platform view
* [#13944](https://github.com/netbox-community/netbox/issues/13944) - Correct serialization of several report attributes in the REST API
* [#13966](https://github.com/netbox-community/netbox/issues/13966) - Restore "last login" column on users table
* [#14013](https://github.com/netbox-community/netbox/issues/14013) - Fix device role filter choices under inventory items list filters
* [#14023](https://github.com/netbox-community/netbox/issues/14023) - Fix exception when bulk disconnecting interfaces connected to the same cable
* [#14025](https://github.com/netbox-community/netbox/issues/14025) - Fix exception when viewing a script that begins with the same name as another
* [#14026](https://github.com/netbox-community/netbox/issues/14026) - Optimize the automatic creation of available IP addresses for large prefixes
* [#14042](https://github.com/netbox-community/netbox/issues/14042) - Fix duplicated child object count decrements when removing objects in bulk
---
## v3.6.3 (2023-09-26)
### Enhancements
* [#12732](https://github.com/netbox-community/netbox/issues/12732) - Add toggle to hide disconnected interfaces under device view
### Bug Fixes
* [#11079](https://github.com/netbox-community/netbox/issues/11079) - Enable tracing cable paths across multiple cables in parallel
* [#11901](https://github.com/netbox-community/netbox/issues/11901) - Fix `IndexError` exception when manipulating terminations for existing cables via REST API
* [#13506](https://github.com/netbox-community/netbox/issues/13506) - Enable creating a config template which references a data file via the REST API
* [#13666](https://github.com/netbox-community/netbox/issues/13666) - Cleanly handle reports without any test methods defined
* [#13839](https://github.com/netbox-community/netbox/issues/13839) - Restore original text color for HTML code elements
* [#13843](https://github.com/netbox-community/netbox/issues/13843) - Fix assignment of VLAN group scope during bulk edit
* [#13845](https://github.com/netbox-community/netbox/issues/13845) - Fix `AttributeError` exception when attaching front/rear images to a device type
* [#13849](https://github.com/netbox-community/netbox/issues/13849) - Fix `KeyError` exception when deleting an object which references a configured choice value that has been removed
* [#13859](https://github.com/netbox-community/netbox/issues/13859) - Fix invalid response when searching for custom choice field values returns no matches
* [#13864](https://github.com/netbox-community/netbox/issues/13864) - Correct default background color for dashboard widget headers
* [#13871](https://github.com/netbox-community/netbox/issues/13871) - Fix rack filtering for empty location during device bulk import
* [#13891](https://github.com/netbox-community/netbox/issues/13891) - Allow designating an IP address as primary for device/VM while assigning it to an interface
---
## v3.6.2 (2023-09-20)
### Enhancements
* [#13245](https://github.com/netbox-community/netbox/issues/13245) - Add interface types for QSFP112 and OSFP-RHS
* [#13563](https://github.com/netbox-community/netbox/issues/13563) - Add support for other delimiting characters when using CSV import * [#13563](https://github.com/netbox-community/netbox/issues/13563) - Add support for other delimiting characters when using CSV import
### Bug Fixes ### Bug Fixes
* [#11209](https://github.com/netbox-community/netbox/issues/11209) - Hide available IP/VLAN listing when sorting under a parent prefix or VLAN range * [#11209](https://github.com/netbox-community/netbox/issues/11209) - Hide available IP/VLAN listing when sorting under a parent prefix or VLAN range
* [#11617](https://github.com/netbox-community/netbox/issues/11617) - Raise validation error on the presence of an unknown CSV header during bulk import
* [#12219](https://github.com/netbox-community/netbox/issues/12219) - Fix dashboard widget heading contrast under dark mode * [#12219](https://github.com/netbox-community/netbox/issues/12219) - Fix dashboard widget heading contrast under dark mode
* [#12685](https://github.com/netbox-community/netbox/issues/12685) - Render Markdown in custom field help text on object edit forms
* [#13653](https://github.com/netbox-community/netbox/issues/13653) - Tweak color of error text to improve legibility
* [#13701](https://github.com/netbox-community/netbox/issues/13701) - Correct display of power feed legs under device view * [#13701](https://github.com/netbox-community/netbox/issues/13701) - Correct display of power feed legs under device view
* [#13706](https://github.com/netbox-community/netbox/issues/13706) - Restore extra filters dropdown on device interfaces list * [#13706](https://github.com/netbox-community/netbox/issues/13706) - Restore extra filters dropdown on device interfaces list
* [#13721](https://github.com/netbox-community/netbox/issues/13721) - Filter VLAN choices by selected site (if any) when creating a prefix * [#13721](https://github.com/netbox-community/netbox/issues/13721) - Filter VLAN choices by selected site (if any) when creating a prefix
* [#13727](https://github.com/netbox-community/netbox/issues/13727) - Fix exception when viewing rendered config for VM without a role assigned * [#13727](https://github.com/netbox-community/netbox/issues/13727) - Fix exception when viewing rendered config for VM without a role assigned
* [#13741](https://github.com/netbox-community/netbox/issues/13741) - Enforce unique names for inventory items with no parent item
* [#13745](https://github.com/netbox-community/netbox/issues/13745) - Optimize counter field migrations for large databases * [#13745](https://github.com/netbox-community/netbox/issues/13745) - Optimize counter field migrations for large databases
* [#13756](https://github.com/netbox-community/netbox/issues/13756) - Fix exception when sorting module bay list by installed module status * [#13756](https://github.com/netbox-community/netbox/issues/13756) - Fix exception when sorting module bay list by installed module status
* [#13757](https://github.com/netbox-community/netbox/issues/13757) - Fix RecursionError exception when assigning config context to a device type
* [#13767](https://github.com/netbox-community/netbox/issues/13767) - Fix support for comments when creating a new service via web UI * [#13767](https://github.com/netbox-community/netbox/issues/13767) - Fix support for comments when creating a new service via web UI
* [#13782](https://github.com/netbox-community/netbox/issues/13782) - Fix tag exclusion support for contact assignments * [#13782](https://github.com/netbox-community/netbox/issues/13782) - Fix tag exclusion support for contact assignments
* [#13791](https://github.com/netbox-community/netbox/issues/13791) - Preserve whitespace in values when performing bulk rename of objects via web UI * [#13791](https://github.com/netbox-community/netbox/issues/13791) - Preserve whitespace in values when performing bulk rename of objects via web UI
* [#13809](https://github.com/netbox-community/netbox/issues/13809) - Avoid TypeError exception when editing active configuration with statically defined `CUSTOM_VALIDATORS`
* [#13813](https://github.com/netbox-community/netbox/issues/13813) - Fix member count for newly created virtual chassis
* [#13818](https://github.com/netbox-community/netbox/issues/13818) - Restore missing tags field on L2VPN termination edit form
--- ---

View File

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

View File

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

View File

@ -20,7 +20,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.metadata import ContentTypeMetadata from netbox.api.metadata import ContentTypeMetadata
from netbox.api.pagination import StripCountAnnotationsPaginator from netbox.api.pagination import StripCountAnnotationsPaginator
from netbox.api.renderers import TextRenderer from netbox.api.renderers import TextRenderer
from netbox.api.viewsets import NetBoxModelViewSet from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
from netbox.constants import NESTED_SERIALIZER_PREFIX from netbox.constants import NESTED_SERIALIZER_PREFIX
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
@ -98,7 +98,7 @@ class PassThroughPortMixin(object):
# Regions # Regions
# #
class RegionViewSet(NetBoxModelViewSet): class RegionViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = Region.objects.add_related_count( queryset = Region.objects.add_related_count(
Region.objects.all(), Region.objects.all(),
Site, Site,
@ -114,7 +114,7 @@ class RegionViewSet(NetBoxModelViewSet):
# Site groups # Site groups
# #
class SiteGroupViewSet(NetBoxModelViewSet): class SiteGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = SiteGroup.objects.add_related_count( queryset = SiteGroup.objects.add_related_count(
SiteGroup.objects.all(), SiteGroup.objects.all(),
Site, Site,
@ -149,7 +149,7 @@ class SiteViewSet(NetBoxModelViewSet):
# Locations # Locations
# #
class LocationViewSet(NetBoxModelViewSet): class LocationViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = Location.objects.add_related_count( queryset = Location.objects.add_related_count(
Location.objects.add_related_count( Location.objects.add_related_count(
Location.objects.all(), Location.objects.all(),
@ -350,7 +350,7 @@ class DeviceBayTemplateViewSet(NetBoxModelViewSet):
filterset_class = filtersets.DeviceBayTemplateFilterSet filterset_class = filtersets.DeviceBayTemplateFilterSet
class InventoryItemTemplateViewSet(NetBoxModelViewSet): class InventoryItemTemplateViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = InventoryItemTemplate.objects.prefetch_related('device_type__manufacturer', 'role') queryset = InventoryItemTemplate.objects.prefetch_related('device_type__manufacturer', 'role')
serializer_class = serializers.InventoryItemTemplateSerializer serializer_class = serializers.InventoryItemTemplateSerializer
filterset_class = filtersets.InventoryItemTemplateFilterSet filterset_class = filtersets.InventoryItemTemplateFilterSet
@ -538,7 +538,7 @@ class DeviceBayViewSet(NetBoxModelViewSet):
brief_prefetch_fields = ['device'] brief_prefetch_fields = ['device']
class InventoryItemViewSet(NetBoxModelViewSet): class InventoryItemViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'tags') queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'tags')
serializer_class = serializers.InventoryItemSerializer serializer_class = serializers.InventoryItemSerializer
filterset_class = filtersets.InventoryItemFilterSet filterset_class = filtersets.InventoryItemFilterSet

View File

@ -837,8 +837,10 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56' TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd' TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
TYPE_400GE_CFP2 = '400gbase-x-cfp2' TYPE_400GE_CFP2 = '400gbase-x-cfp2'
TYPE_400GE_QSFP112 = '400gbase-x-qsfp112'
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_OSFP_RHS = '400gbase-x-osfp-rhs'
TYPE_400GE_CDFP = '400gbase-x-cdfp' TYPE_400GE_CDFP = '400gbase-x-cdfp'
TYPE_400GE_CFP8 = '400gbase-x-cfp8' TYPE_400GE_CFP8 = '400gbase-x-cfp8'
TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd' TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
@ -989,8 +991,10 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_100GE_QSFP_DD, 'QSFP-DD (100GE)'), (TYPE_100GE_QSFP_DD, 'QSFP-DD (100GE)'),
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'), (TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
(TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'), (TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
(TYPE_400GE_QSFP112, 'QSFP112 (400GE)'),
(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_OSFP_RHS, 'OSFP-RHS (400GE)'),
(TYPE_400GE_CDFP, 'CDFP (400GE)'), (TYPE_400GE_CDFP, 'CDFP (400GE)'),
(TYPE_400GE_CFP8, 'CPF8 (400GE)'), (TYPE_400GE_CFP8, 'CPF8 (400GE)'),
(TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'), (TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),

View File

@ -4,6 +4,7 @@ from django.utils.translation import gettext as _
from extras.filtersets import LocalConfigContextFilterSet from extras.filtersets import LocalConfigContextFilterSet
from extras.models import ConfigTemplate from extras.models import ConfigTemplate
from ipam.filtersets import PrimaryIPFilterSet
from ipam.models import ASN, L2VPN, IPAddress, VRF from ipam.models import ASN, L2VPN, IPAddress, VRF
from netbox.filtersets import ( from netbox.filtersets import (
BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet, BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
@ -817,7 +818,13 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
fields = ['id', 'name', 'slug', 'description'] fields = ['id', 'name', 'slug', 'description']
class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet): class DeviceFilterSet(
NetBoxModelFilterSet,
TenancyFilterSet,
ContactModelFilterSet,
LocalConfigContextFilterSet,
PrimaryIPFilterSet,
):
manufacturer_id = django_filters.ModelMultipleChoiceFilter( manufacturer_id = django_filters.ModelMultipleChoiceFilter(
field_name='device_type__manufacturer', field_name='device_type__manufacturer',
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
@ -993,16 +1000,6 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
method='_device_bays', method='_device_bays',
label=_('Has device bays'), label=_('Has device bays'),
) )
primary_ip4_id = django_filters.ModelMultipleChoiceFilter(
field_name='primary_ip4',
queryset=IPAddress.objects.all(),
label=_('Primary IPv4 (ID)'),
)
primary_ip6_id = django_filters.ModelMultipleChoiceFilter(
field_name='primary_ip6',
queryset=IPAddress.objects.all(),
label=_('Primary IPv6 (ID)'),
)
oob_ip_id = django_filters.ModelMultipleChoiceFilter( oob_ip_id = django_filters.ModelMultipleChoiceFilter(
field_name='oob_ip', field_name='oob_ip',
queryset=IPAddress.objects.all(), queryset=IPAddress.objects.all(),
@ -1069,7 +1066,7 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
return queryset.exclude(devicebays__isnull=value) return queryset.exclude(devicebays__isnull=value)
class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet, PrimaryIPFilterSet):
device_id = django_filters.ModelMultipleChoiceFilter( device_id = django_filters.ModelMultipleChoiceFilter(
field_name='device', field_name='device',
queryset=Device.objects.all(), queryset=Device.objects.all(),
@ -1745,6 +1742,10 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
method='filter_by_cable_end_b', method='filter_by_cable_end_b',
field_name='terminations__termination_id' field_name='terminations__termination_id'
) )
unterminated = django_filters.BooleanFilter(
method='_unterminated',
label=_('Unterminated'),
)
type = django_filters.MultipleChoiceFilter( type = django_filters.MultipleChoiceFilter(
choices=CableTypeChoices choices=CableTypeChoices
) )
@ -1812,6 +1813,19 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
# Filter by termination id and cable_end type # Filter by termination id and cable_end type
return self.filter_by_cable_end(queryset, name, value, CableEndChoices.SIDE_B) return self.filter_by_cable_end(queryset, name, value, CableEndChoices.SIDE_B)
def _unterminated(self, queryset, name, value):
if value:
terminated_ids = (
queryset.filter(terminations__cable_end=CableEndChoices.SIDE_A)
.filter(terminations__cable_end=CableEndChoices.SIDE_B)
.values("id")
)
return queryset.exclude(id__in=terminated_ids)
else:
return queryset.filter(terminations__cable_end=CableEndChoices.SIDE_A).filter(
terminations__cable_end=CableEndChoices.SIDE_B
)
class CableTerminationFilterSet(BaseFilterSet): class CableTerminationFilterSet(BaseFilterSet):
termination_type = ContentTypeFilter() termination_type = ContentTypeFilter()

View File

@ -549,9 +549,9 @@ class DeviceImportForm(BaseDeviceImportForm):
params = { params = {
f"site__{self.fields['site'].to_field_name}": data.get('site'), f"site__{self.fields['site'].to_field_name}": data.get('site'),
} }
if 'location' in data: if location := data.get('location'):
params.update({ params.update({
f"location__{self.fields['location'].to_field_name}": data.get('location'), f"location__{self.fields['location'].to_field_name}": location,
}) })
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
@ -1192,7 +1192,7 @@ class CableImportForm(NetBoxModelImportForm):
termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name) termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name)
else: else:
termination_object = model.objects.get(device=device, name=name) termination_object = model.objects.get(device=device, name=name)
if termination_object.cable is not None: if termination_object.cable is not None and termination_object.cable != self.instance:
raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected") raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
except ObjectDoesNotExist: except ObjectDoesNotExist:
raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}") raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")

View File

@ -109,7 +109,7 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
required=False, required=False,
label=_('Device type') label=_('Device type')
) )
role_id = DynamicModelMultipleChoiceField( device_role_id = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(), queryset=DeviceRole.objects.all(),
required=False, required=False,
label=_('Device role') label=_('Device role')
@ -910,7 +910,7 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
(_('Location'), ('site_id', 'location_id', 'rack_id', 'device_id')), (_('Location'), ('site_id', 'location_id', 'rack_id', 'device_id')),
(_('Attributes'), ('type', 'status', 'color', 'length', 'length_unit')), (_('Attributes'), ('type', 'status', 'color', 'length', 'length_unit', 'unterminated')),
(_('Tenant'), ('tenant_group_id', 'tenant_id')), (_('Tenant'), ('tenant_group_id', 'tenant_id')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
@ -979,6 +979,13 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
choices=add_blank_choice(CableLengthUnitChoices), choices=add_blank_choice(CableLengthUnitChoices),
required=False required=False
) )
unterminated = forms.NullBooleanField(
label=_('Unterminated'),
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
tag = TagFilterField(model) tag = TagFilterField(model)
@ -1136,7 +1143,7 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
(_('Attributes'), ('name', 'label', 'type', 'speed')), (_('Attributes'), ('name', 'label', 'type', 'speed')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_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(
@ -1158,7 +1165,7 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
(_('Attributes'), ('name', 'label', 'type', 'speed')), (_('Attributes'), ('name', 'label', 'type', 'speed')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_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(
@ -1180,7 +1187,7 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
(_('Attributes'), ('name', 'label', 'type')), (_('Attributes'), ('name', 'label', 'type')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_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(
@ -1197,7 +1204,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
(_('Attributes'), ('name', 'label', 'type')), (_('Attributes'), ('name', 'label', 'type')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_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(
@ -1217,7 +1224,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
(_('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')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_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(
@ -1324,7 +1331,7 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
(_('Attributes'), ('name', 'label', 'type', 'color')), (_('Attributes'), ('name', 'label', 'type', 'color')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')), (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
(_('Cable'), ('cabled', 'occupied')), (_('Cable'), ('cabled', 'occupied')),
) )
model = FrontPort model = FrontPort
@ -1346,7 +1353,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
(_('Attributes'), ('name', 'label', 'type', 'color')), (_('Attributes'), ('name', 'label', 'type', 'color')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_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(
@ -1367,7 +1374,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
(_('Attributes'), ('name', 'label', 'position')), (_('Attributes'), ('name', 'label', 'position')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_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(
@ -1382,7 +1389,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
(_('Attributes'), ('name', 'label')), (_('Attributes'), ('name', 'label')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')), (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
) )
tag = TagFilterField(model) tag = TagFilterField(model)
@ -1393,7 +1400,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
(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')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_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

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

View File

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

View File

@ -1,17 +0,0 @@
# Generated by Django 4.2.5 on 2023-09-15 14:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0181_rename_device_role_device_role'),
]
operations = [
migrations.AddConstraint(
model_name='inventoryitem',
constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('device', 'name'), name='dcim_inventoryitem_unique_device_name'),
),
]

View File

@ -20,7 +20,7 @@ from utilities.fields import ColorField
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.utils import to_meters from utilities.utils import to_meters
from wireless.models import WirelessLink from wireless.models import WirelessLink
from .device_components import FrontPort, RearPort from .device_components import FrontPort, RearPort, PathEndpoint
__all__ = ( __all__ = (
'Cable', 'Cable',
@ -98,10 +98,10 @@ class Cable(PrimaryModel):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# A copy of the PK to be used by __str__ in case the object is deleted # A copy of the PK to be used by __str__ in case the object is deleted
self._pk = self.pk self._pk = self.__dict__.get('id')
# Cache the original status so we can check later if it's been changed # Cache the original status so we can check later if it's been changed
self._orig_status = self.status self._orig_status = self.__dict__.get('status')
self._terminations_modified = False self._terminations_modified = False
@ -180,6 +180,17 @@ class Cable(PrimaryModel):
if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type): if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type):
raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}") raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}")
if a_type == b_type:
# can't directly use self.a_terminations here as possible they
# don't have pk yet
a_pks = set(obj.pk for obj in self.a_terminations if obj.pk)
b_pks = set(obj.pk for obj in self.b_terminations if obj.pk)
if (a_pks & b_pks):
raise ValidationError(
_("A and B terminations cannot connect to the same object.")
)
# Run clean() on any new CableTerminations # Run clean() on any new CableTerminations
for termination in self.a_terminations: for termination in self.a_terminations:
CableTermination(cable=self, cable_end='A', termination=termination).clean() CableTermination(cable=self, cable_end='A', termination=termination).clean()
@ -518,9 +529,16 @@ class CablePath(models.Model):
# Terminations must all be of the same type # Terminations must all be of the same type
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:]) assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
# All mid-span terminations must all be attached to the same device
if not isinstance(terminations[0], PathEndpoint):
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
assert all(t.parent_object == terminations[0].parent_object for t in terminations[1:])
# Check for a split path (e.g. rear port fanning out to multiple front ports with # Check for a split path (e.g. rear port fanning out to multiple front ports with
# different cables attached) # different cables attached)
if len(set(t.link for t in terminations)) > 1: if len(set(t.link for t in terminations)) > 1 and (
position_stack and len(terminations) != len(position_stack[-1])
):
is_split = True is_split = True
break break
@ -529,46 +547,68 @@ class CablePath(models.Model):
object_to_path_node(t) for t in terminations object_to_path_node(t) for t in terminations
]) ])
# Step 2: Determine the attached link (Cable or WirelessLink), if any # Step 2: Determine the attached links (Cable or WirelessLink), if any
link = terminations[0].link links = [termination.link for termination in terminations if termination.link is not None]
if link is None and len(path) == 1: if len(links) == 0:
# If this is the start of the path and no link exists, return None if len(path) == 1:
return None # If this is the start of the path and no link exists, return None
elif link is None: return None
# Otherwise, halt the trace if no link exists # Otherwise, halt the trace if no link exists
break break
assert type(link) in (Cable, WirelessLink) assert all(type(link) in (Cable, WirelessLink) for link in links)
assert all(isinstance(link, type(links[0])) for link in links)
# Step 3: Record the link and update path status if not "connected" # Step 3: Record asymmetric paths as split
path.append([object_to_path_node(link)]) not_connected_terminations = [termination.link for termination in terminations if termination.link is None]
if hasattr(link, 'status') and link.status != LinkStatusChoices.STATUS_CONNECTED: if len(not_connected_terminations) > 0:
is_complete = False
is_split = True
# Step 4: Record the links, keeping cables in order to allow for SVG rendering
cables = []
for link in links:
if object_to_path_node(link) not in cables:
cables.append(object_to_path_node(link))
path.append(cables)
# Step 5: Update the path status if a link is not connected
links_status = [link.status for link in links if link.status != LinkStatusChoices.STATUS_CONNECTED]
if any([status != LinkStatusChoices.STATUS_CONNECTED for status in links_status]):
is_active = False is_active = False
# Step 4: Determine the far-end terminations # Step 6: Determine the far-end terminations
if isinstance(link, Cable): if isinstance(links[0], Cable):
termination_type = ContentType.objects.get_for_model(terminations[0]) termination_type = ContentType.objects.get_for_model(terminations[0])
local_cable_terminations = CableTermination.objects.filter( local_cable_terminations = CableTermination.objects.filter(
termination_type=termination_type, termination_type=termination_type,
termination_id__in=[t.pk for t in terminations] termination_id__in=[t.pk for t in terminations]
) )
# Terminations must all belong to same end of Cable
local_cable_end = local_cable_terminations[0].cable_end q_filter = Q()
assert all(ct.cable_end == local_cable_end for ct in local_cable_terminations[1:]) for lct in local_cable_terminations:
remote_cable_terminations = CableTermination.objects.filter( cable_end = 'A' if lct.cable_end == 'B' else 'B'
cable=link, q_filter |= Q(cable=lct.cable, cable_end=cable_end)
cable_end='A' if local_cable_end == 'B' else 'B'
) remote_cable_terminations = CableTermination.objects.filter(q_filter)
remote_terminations = [ct.termination for ct in remote_cable_terminations] remote_terminations = [ct.termination for ct in remote_cable_terminations]
else: else:
# WirelessLink # WirelessLink
remote_terminations = [link.interface_b] if link.interface_a is terminations[0] else [link.interface_a] remote_terminations = [
link.interface_b if link.interface_a is terminations[0] else link.interface_a for link in links
]
# Step 5: Record the far-end termination object(s) # Remote Terminations must all be of the same type, otherwise return a split path
if not all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]):
is_complete = False
is_split = True
break
# Step 7: Record the far-end termination object(s)
path.append([ path.append([
object_to_path_node(t) for t in remote_terminations if t is not None object_to_path_node(t) for t in remote_terminations if t is not None
]) ])
# Step 6: Determine the "next hop" terminations, if applicable # Step 8: Determine the "next hop" terminations, if applicable
if not remote_terminations: if not remote_terminations:
break break
@ -577,20 +617,32 @@ class CablePath(models.Model):
rear_ports = RearPort.objects.filter( rear_ports = RearPort.objects.filter(
pk__in=[t.rear_port_id for t in remote_terminations] pk__in=[t.rear_port_id for t in remote_terminations]
) )
if len(rear_ports) > 1: if len(rear_ports) > 1 or rear_ports[0].positions > 1:
assert all(rp.positions == 1 for rp in rear_ports)
elif rear_ports[0].positions > 1:
position_stack.append([fp.rear_port_position for fp in remote_terminations]) position_stack.append([fp.rear_port_position for fp in remote_terminations])
terminations = rear_ports terminations = rear_ports
elif isinstance(remote_terminations[0], RearPort): elif isinstance(remote_terminations[0], RearPort):
if len(remote_terminations) == 1 and remote_terminations[0].positions == 1:
if len(remote_terminations) > 1 or remote_terminations[0].positions == 1:
front_ports = FrontPort.objects.filter( front_ports = FrontPort.objects.filter(
rear_port_id__in=[rp.pk for rp in remote_terminations], rear_port_id__in=[rp.pk for rp in remote_terminations],
rear_port_position=1 rear_port_position=1
) )
# Obtain the individual front ports based on the termination and all positions
elif len(remote_terminations) > 1 and position_stack:
positions = position_stack.pop()
# Ensure we have a number of positions equal to the amount of remote terminations
assert len(remote_terminations) == len(positions)
# Get our front ports
q_filter = Q()
for rt in remote_terminations:
position = positions.pop()
q_filter |= Q(rear_port_id=rt.pk, rear_port_position=position)
assert q_filter is not Q()
front_ports = FrontPort.objects.filter(q_filter)
# Obtain the individual front ports based on the termination and position
elif position_stack: elif position_stack:
front_ports = FrontPort.objects.filter( front_ports = FrontPort.objects.filter(
rear_port_id=remote_terminations[0].pk, rear_port_id=remote_terminations[0].pk,
@ -632,9 +684,16 @@ class CablePath(models.Model):
terminations = [circuit_termination] terminations = [circuit_termination]
# Anything else marks the end of the path
else: else:
is_complete = True # Check for non-symmetric path
if all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]):
is_complete = True
elif len(remote_terminations) == 0:
is_complete = False
else:
# Unsupported topology, mark as split and exit
is_complete = False
is_split = True
break break
return cls( return cls(
@ -740,3 +799,15 @@ class CablePath(models.Model):
return [ return [
ct.get_peer_termination() for ct in nodes ct.get_peer_termination() for ct in nodes
] ]
def get_asymmetric_nodes(self):
"""
Return all available next segments in a split cable path.
"""
from circuits.models import CircuitTermination
asymmetric_nodes = []
for nodes in self.path_objects:
if type(nodes[0]) in [RearPort, FrontPort, CircuitTermination]:
asymmetric_nodes.extend([node for node in nodes if node.link is None])
return asymmetric_nodes

View File

@ -89,7 +89,7 @@ class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Cache the original DeviceType ID for reference under clean() # Cache the original DeviceType ID for reference under clean()
self._original_device_type = self.device_type_id self._original_device_type = self.__dict__.get('device_type_id')
def to_objectchange(self, action): def to_objectchange(self, action):
objectchange = super().to_objectchange(action) objectchange = super().to_objectchange(action)

View File

@ -86,7 +86,7 @@ class ComponentModel(NetBoxModel):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Cache the original Device ID for reference under clean() # Cache the original Device ID for reference under clean()
self._original_device = self.device_id self._original_device = self.__dict__.get('device_id')
def __str__(self): def __str__(self):
if self.label: if self.label:
@ -1246,11 +1246,6 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
fields=('device', 'parent', 'name'), fields=('device', 'parent', 'name'),
name='%(app_label)s_%(class)s_unique_device_parent_name' name='%(app_label)s_%(class)s_unique_device_parent_name'
), ),
models.UniqueConstraint(
fields=('device', 'name'),
name='%(app_label)s_%(class)s_unique_device_name',
condition=Q(parent__isnull=True)
),
) )
verbose_name = _('inventory item') verbose_name = _('inventory item')
verbose_name_plural = _('inventory items') verbose_name_plural = _('inventory items')

View File

@ -4,6 +4,7 @@ import yaml
from functools import cached_property from functools import cached_property
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.db.models import F, ProtectedError from django.db.models import F, ProtectedError
@ -205,11 +206,11 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Save a copy of u_height for validation in clean() # Save a copy of u_height for validation in clean()
self._original_u_height = self.u_height self._original_u_height = self.__dict__.get('u_height')
# Save references to the original front/rear images # Save references to the original front/rear images
self._original_front_image = self.front_image self._original_front_image = self.__dict__.get('front_image')
self._original_rear_image = self.rear_image self._original_rear_image = self.__dict__.get('rear_image')
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:devicetype', args=[self.pk]) return reverse('dcim:devicetype', args=[self.pk])
@ -332,10 +333,10 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
ret = super().save(*args, **kwargs) ret = super().save(*args, **kwargs)
# Delete any previously uploaded image files that are no longer in use # Delete any previously uploaded image files that are no longer in use
if self.front_image != self._original_front_image: if self._original_front_image and self.front_image != self._original_front_image:
self._original_front_image.delete(save=False) default_storage.delete(self._original_front_image)
if self.rear_image != self._original_rear_image: if self._original_rear_image and self.rear_image != self._original_rear_image:
self._original_rear_image.delete(save=False) default_storage.delete(self._original_rear_image)
return ret return ret

View File

@ -32,11 +32,18 @@ class Node(Hyperlink):
color: Box fill color (RRGGBB format) color: Box fill color (RRGGBB format)
labels: An iterable of text strings. Each label will render on a new line within the box. labels: An iterable of text strings. Each label will render on a new line within the box.
radius: Box corner radius, for rounded corners (default: 10) radius: Box corner radius, for rounded corners (default: 10)
object: A copy of the object to allow reference when drawing cables to determine which cables are connected to
which terminations.
""" """
def __init__(self, position, width, url, color, labels, radius=10, **extra): object = None
def __init__(self, position, width, url, color, labels, radius=10, object=object, **extra):
super(Node, self).__init__(href=url, target='_parent', **extra) super(Node, self).__init__(href=url, target='_parent', **extra)
# Save object for reference by cable systems
self.object = object
x, y = position x, y = position
# Add the box # Add the box
@ -77,7 +84,7 @@ class Connector(Group):
labels: Iterable of text labels labels: Iterable of text labels
""" """
def __init__(self, start, url, color, labels=[], **extra): def __init__(self, start, url, color, labels=[], description=[], **extra):
super().__init__(class_='connector', **extra) super().__init__(class_='connector', **extra)
self.start = start self.start = start
@ -104,6 +111,8 @@ class Connector(Group):
text_coords = (start[0] + PADDING * 2, cursor - LINE_HEIGHT / 2) text_coords = (start[0] + PADDING * 2, cursor - LINE_HEIGHT / 2)
text = Text(label, insert=text_coords, class_='bold' if not i else []) text = Text(label, insert=text_coords, class_='bold' if not i else [])
link.add(text) link.add(text)
if len(description) > 0:
link.set_desc("\n".join(description))
self.add(link) self.add(link)
@ -151,6 +160,8 @@ class CableTraceSVG:
elif instance._meta.model_name == 'circuit': elif instance._meta.model_name == 'circuit':
labels[0] = f'Circuit {instance}' labels[0] = f'Circuit {instance}'
labels.append(instance.provider) labels.append(instance.provider)
if instance.description:
labels.append(instance.description)
elif instance._meta.model_name == 'circuittermination': elif instance._meta.model_name == 'circuittermination':
if instance.xconnect_id: if instance.xconnect_id:
labels.append(f'{instance.xconnect_id}') labels.append(f'{instance.xconnect_id}')
@ -206,7 +217,8 @@ class CableTraceSVG:
url=f'{self.base_url}{term.get_absolute_url()}', url=f'{self.base_url}{term.get_absolute_url()}',
color=self._get_color(term), color=self._get_color(term),
labels=self._get_labels(term), labels=self._get_labels(term),
radius=5 radius=5,
object=term
) )
nodes_height = max(nodes_height, node.box['height']) nodes_height = max(nodes_height, node.box['height'])
nodes.append(node) nodes.append(node)
@ -238,22 +250,65 @@ class CableTraceSVG:
Polyline(points=points, style=f'stroke: #{connector.color}'), Polyline(points=points, style=f'stroke: #{connector.color}'),
)) ))
def draw_cable(self, cable): def draw_cable(self, cable, terminations, cable_count=0):
labels = [ """
f'Cable {cable}', Draw a single cable. Terminations and cable count are passed for determining position and padding
cable.get_status_display()
] :param cable: The cable to draw
if cable.type: :param terminations: List of terminations to build positioning data off of
labels.append(cable.get_type_display()) :param cable_count: Count of all cables on this layer for determining whether to collapse description into a
if cable.length and cable.length_unit: tooltip.
labels.append(f'{cable.length} {cable.get_length_unit_display()}') """
# If the cable count is higher than 2, collapse the description into a tooltip
if cable_count > 2:
# Use the cable __str__ function to denote the cable
labels = [f'{cable}']
# Include the label and the status description in the tooltip
description = [
f'Cable {cable}',
cable.get_status_display()
]
if cable.type:
# Include the cable type in the tooltip
description.append(cable.get_type_display())
if cable.length and cable.length_unit:
# Include the cable length in the tooltip
description.append(f'{cable.length} {cable.get_length_unit_display()}')
else:
labels = [
f'Cable {cable}',
cable.get_status_display()
]
description = []
if cable.type:
labels.append(cable.get_type_display())
if cable.length and cable.length_unit:
# Include the cable length in the tooltip
labels.append(f'{cable.length} {cable.get_length_unit_display()}')
# If there is only one termination, center on that termination
# Otherwise average the center across the terminations
if len(terminations) == 1:
center = terminations[0].bottom_center[0]
else:
# Get a list of termination centers
termination_centers = [term.bottom_center[0] for term in terminations]
# Average the centers
center = sum(termination_centers) / len(termination_centers)
# Create the connector
connector = Connector( connector = Connector(
start=(self.center + OFFSET, self.cursor), start=(center, self.cursor),
color=cable.color or '000000', color=cable.color or '000000',
url=f'{self.base_url}{cable.get_absolute_url()}', url=f'{self.base_url}{cable.get_absolute_url()}',
labels=labels labels=labels,
description=description
) )
# Set the cursor position
self.cursor += connector.height self.cursor += connector.height
return connector return connector
@ -334,34 +389,52 @@ class CableTraceSVG:
# Connector (a Cable or WirelessLink) # Connector (a Cable or WirelessLink)
if links: if links:
link = links[0] # Remove Cable from list link_cables = {}
fanin = False
fanout = False
# Cable # Determine if we have fanins or fanouts
if type(link) is Cable: if len(near_ends) > len(set(links)):
self.cursor += FANOUT_HEIGHT
fanin = True
if len(far_ends) > len(set(links)):
fanout = True
cursor = self.cursor
for link in links:
# Cable
if type(link) is Cable and not link_cables.get(link.pk):
# Reset cursor
self.cursor = cursor
# Generate a list of terminations connected to this cable
near_end_link_terminations = [term for term in terminations if term.object.cable == link]
# Draw the cable
cable = self.draw_cable(link, near_end_link_terminations, cable_count=len(links))
# Add cable to the list of cables
link_cables.update({link.pk: cable})
# Add cable to drawing
self.connectors.append(cable)
# Account for fan-ins height # Draw fan-ins
if len(near_ends) > 1: if len(near_ends) > 1 and fanin:
self.cursor += FANOUT_HEIGHT for term in terminations:
if term.object.cable == link:
self.draw_fanin(term, cable)
cable = self.draw_cable(link) # WirelessLink
self.connectors.append(cable) elif type(link) is WirelessLink:
wirelesslink = self.draw_wirelesslink(link)
# Draw fan-ins self.connectors.append(wirelesslink)
if len(near_ends) > 1:
for term in terminations:
self.draw_fanin(term, cable)
# WirelessLink
elif type(link) is WirelessLink:
wirelesslink = self.draw_wirelesslink(link)
self.connectors.append(wirelesslink)
# Far end termination(s) # Far end termination(s)
if len(far_ends) > 1: if len(far_ends) > 1:
self.cursor += FANOUT_HEIGHT if fanout:
terminations = self.draw_terminations(far_ends) self.cursor += FANOUT_HEIGHT
for term in terminations: terminations = self.draw_terminations(far_ends)
self.draw_fanout(term, cable) for term in terminations:
if hasattr(term.object, 'cable') and link_cables.get(term.object.cable.pk):
self.draw_fanout(term, link_cables.get(term.object.cable.pk))
else:
self.draw_terminations(far_ends)
elif far_ends: elif far_ends:
self.draw_terminations(far_ends) self.draw_terminations(far_ends)
else: else:

View File

@ -64,9 +64,19 @@ def get_interface_state_attribute(record):
Get interface enabled state as string to attach to <tr/> DOM element. Get interface enabled state as string to attach to <tr/> DOM element.
""" """
if record.enabled: if record.enabled:
return "enabled" return 'enabled'
else: else:
return "disabled" return 'disabled'
def get_interface_connected_attribute(record):
"""
Get interface disconnected state as string to attach to <tr/> DOM element.
"""
if record.mark_connected or record.cable:
return 'connected'
else:
return 'disconnected'
# #
@ -456,6 +466,12 @@ class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable):
'args': [Accessor('device_id')], 'args': [Accessor('device_id')],
} }
) )
maximum_draw = tables.Column(
verbose_name=_('Maximum draw (W)')
)
allocated_draw = tables.Column(
verbose_name=_('Allocated draw (W)')
)
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='dcim:powerport_list' url_name='dcim:powerport_list'
) )
@ -615,6 +631,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
verbose_name=_('VRF'), verbose_name=_('VRF'),
linkify=True linkify=True
) )
inventory_items = tables.ManyToManyColumn(
linkify_item=True,
verbose_name=_('Inventory Items'),
)
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='dcim:interface_list' url_name='dcim:interface_list'
) )
@ -626,7 +646,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
'speed', 'speed_formatted', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel', 'speed', 'speed_formatted', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel',
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable',
'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn',
'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'inventory_items', 'created', 'last_updated',
) )
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
@ -674,6 +694,7 @@ class DeviceInterfaceTable(InterfaceTable):
'data-name': lambda record: record.name, 'data-name': lambda record: record.name,
'data-enabled': get_interface_state_attribute, 'data-enabled': get_interface_state_attribute,
'data-type': lambda record: record.type, 'data-type': lambda record: record.type,
'data-connected': get_interface_connected_attribute
} }
@ -922,6 +943,10 @@ class InventoryItemTable(DeviceComponentTable):
discovered = columns.BooleanColumn( discovered = columns.BooleanColumn(
verbose_name=_('Discovered'), verbose_name=_('Discovered'),
) )
parent = tables.Column(
linkify=True,
verbose_name=_('Parent'),
)
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='dcim:inventoryitem_list' url_name='dcim:inventoryitem_list'
) )
@ -930,7 +955,7 @@ class InventoryItemTable(DeviceComponentTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = models.InventoryItem model = models.InventoryItem
fields = ( fields = (
'pk', 'id', 'name', 'device', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'pk', 'id', 'name', 'device', 'parent', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial',
'asset_tag', 'description', 'discovered', 'tags', 'created', 'last_updated', 'asset_tag', 'description', 'discovered', 'tags', 'created', 'last_updated',
) )
default_columns = ( default_columns = (

View File

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

View File

@ -15,6 +15,7 @@ class CablePathTestCase(TestCase):
1XX: Test direct connections between different endpoint types 1XX: Test direct connections between different endpoint types
2XX: Test different cable topologies 2XX: Test different cable topologies
3XX: Test responses to changes in existing objects 3XX: Test responses to changes in existing objects
4XX: Test to exclude specific cable topologies
""" """
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -33,12 +34,11 @@ class CablePathTestCase(TestCase):
circuit_type = CircuitType.objects.create(name='Circuit Type', slug='circuit-type') circuit_type = CircuitType.objects.create(name='Circuit Type', slug='circuit-type')
cls.circuit = Circuit.objects.create(provider=provider, type=circuit_type, cid='Circuit 1') cls.circuit = Circuit.objects.create(provider=provider, type=circuit_type, cid='Circuit 1')
def assertPathExists(self, nodes, **kwargs): def _get_cablepath(self, nodes, **kwargs):
""" """
Assert that a CablePath from origin to destination with a specific intermediate path exists. Return a given cable path
:param nodes: Iterable of steps, with each step being either a single node or a list of nodes :param nodes: Iterable of steps, with each step being either a single node or a list of nodes
:param is_active: Boolean indicating whether the end-to-end path is complete and active (optional)
:return: The matching CablePath (if any) :return: The matching CablePath (if any)
""" """
@ -48,12 +48,29 @@ class CablePathTestCase(TestCase):
path.append([object_to_path_node(node) for node in step]) path.append([object_to_path_node(node) for node in step])
else: else:
path.append([object_to_path_node(step)]) path.append([object_to_path_node(step)])
return CablePath.objects.filter(path=path, **kwargs).first()
cablepath = CablePath.objects.filter(path=path, **kwargs).first() def assertPathExists(self, nodes, **kwargs):
"""
Assert that a CablePath from origin to destination with a specific intermediate path exists. Returns the
first matching CablePath, if found.
:param nodes: Iterable of steps, with each step being either a single node or a list of nodes
"""
cablepath = self._get_cablepath(nodes, **kwargs)
self.assertIsNotNone(cablepath, msg='CablePath not found') self.assertIsNotNone(cablepath, msg='CablePath not found')
return cablepath return cablepath
def assertPathDoesNotExist(self, nodes, **kwargs):
"""
Assert that a specific CablePath does *not* exist.
:param nodes: Iterable of steps, with each step being either a single node or a list of nodes
"""
cablepath = self._get_cablepath(nodes, **kwargs)
self.assertIsNone(cablepath, msg='Unexpected CablePath found')
def assertPathIsSet(self, origin, cablepath, msg=None): def assertPathIsSet(self, origin, cablepath, msg=None):
""" """
Assert that a specific CablePath instance is set as the path on the origin. Assert that a specific CablePath instance is set as the path on the origin.
@ -1695,6 +1712,291 @@ class CablePathTestCase(TestCase):
self.assertPathIsSet(interface3, path3) self.assertPathIsSet(interface3, path3)
self.assertPathIsSet(interface4, path4) self.assertPathIsSet(interface4, path4)
def test_219_interface_to_interface_duplex_via_multiple_rearports(self):
"""
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]
[FP3] [RP3] --C4-- [RP4] [FP4]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1)
rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1)
frontport1 = FrontPort.objects.create(
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
)
frontport2 = FrontPort.objects.create(
device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
)
frontport3 = FrontPort.objects.create(
device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1
)
frontport4 = FrontPort.objects.create(
device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1
)
cable2 = Cable(
a_terminations=[rearport1],
b_terminations=[rearport2]
)
cable2.save()
cable4 = Cable(
a_terminations=[rearport3],
b_terminations=[rearport4]
)
cable4.save()
self.assertEqual(CablePath.objects.count(), 0)
# Create cable1
cable1 = Cable(
a_terminations=[interface1],
b_terminations=[frontport1, frontport3]
)
cable1.save()
self.assertPathExists(
(interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4), (rearport2, rearport4), (frontport2, frontport4)),
is_complete=False
)
self.assertEqual(CablePath.objects.count(), 1)
# Create cable 3
cable3 = Cable(
a_terminations=[frontport2, frontport4],
b_terminations=[interface2]
)
cable3.save()
self.assertPathExists(
(
interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4),
(rearport2, rearport4), (frontport2, frontport4), cable3, interface2
),
is_complete=True,
is_active=True
)
self.assertPathExists(
(
interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4),
(rearport1, rearport3), (frontport1, frontport3), cable1, interface1
),
is_complete=True,
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)
def test_220_interface_to_interface_duplex_via_multiple_front_and_rear_ports(self):
"""
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]
[IF2] --C5-- [FP3] [RP3] --C4-- [RP4] [FP4]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
interface3 = Interface.objects.create(device=self.device, name='Interface 3')
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1)
rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1)
frontport1 = FrontPort.objects.create(
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
)
frontport2 = FrontPort.objects.create(
device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
)
frontport3 = FrontPort.objects.create(
device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1
)
frontport4 = FrontPort.objects.create(
device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1
)
cable2 = Cable(
a_terminations=[rearport1],
b_terminations=[rearport2]
)
cable2.save()
cable4 = Cable(
a_terminations=[rearport3],
b_terminations=[rearport4]
)
cable4.save()
self.assertEqual(CablePath.objects.count(), 0)
# Create cable1
cable1 = Cable(
a_terminations=[interface1],
b_terminations=[frontport1]
)
cable1.save()
self.assertPathExists(
(
interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2
),
is_complete=False
)
# Create cable1
cable5 = Cable(
a_terminations=[interface3],
b_terminations=[frontport3]
)
cable5.save()
self.assertPathExists(
(
interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4
),
is_complete=False
)
self.assertEqual(CablePath.objects.count(), 2)
# Create cable 3
cable3 = Cable(
a_terminations=[frontport2, frontport4],
b_terminations=[interface2]
)
cable3.save()
self.assertPathExists(
(
interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4),
(rearport1, rearport3), (frontport1, frontport3), (cable1, cable5), (interface1, interface3)
),
is_complete=True,
is_active=True
)
self.assertPathExists(
(
interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2, cable3, interface2
),
is_complete=True,
is_active=True
)
self.assertPathExists(
(
interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4, cable3, interface2
),
is_complete=True,
is_active=True
)
self.assertEqual(CablePath.objects.count(), 3)
def test_221_non_symmetric_paths(self):
"""
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- -------------------------------------- [IF2]
[IF2] --C5-- [FP3] [RP3] --C4-- [RP4] [FP4] --C6-- [FP5] [RP5] --C7-- [RP6] [FP6] --C3---/
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
interface3 = Interface.objects.create(device=self.device, name='Interface 3')
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1)
rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1)
rearport5 = RearPort.objects.create(device=self.device, name='Rear Port 5', positions=1)
rearport6 = RearPort.objects.create(device=self.device, name='Rear Port 6', positions=1)
frontport1 = FrontPort.objects.create(
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
)
frontport2 = FrontPort.objects.create(
device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
)
frontport3 = FrontPort.objects.create(
device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1
)
frontport4 = FrontPort.objects.create(
device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1
)
frontport5 = FrontPort.objects.create(
device=self.device, name='Front Port 5', rear_port=rearport5, rear_port_position=1
)
frontport6 = FrontPort.objects.create(
device=self.device, name='Front Port 6', rear_port=rearport6, rear_port_position=1
)
cable2 = Cable(
a_terminations=[rearport1],
b_terminations=[rearport2],
label='C2'
)
cable2.save()
cable4 = Cable(
a_terminations=[rearport3],
b_terminations=[rearport4],
label='C4'
)
cable4.save()
cable6 = Cable(
a_terminations=[frontport4],
b_terminations=[frontport5],
label='C6'
)
cable6.save()
cable7 = Cable(
a_terminations=[rearport5],
b_terminations=[rearport6],
label='C7'
)
cable7.save()
self.assertEqual(CablePath.objects.count(), 0)
# Create cable1
cable1 = Cable(
a_terminations=[interface1],
b_terminations=[frontport1],
label='C1'
)
cable1.save()
self.assertPathExists(
(
interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2
),
is_complete=False
)
# Create cable1
cable5 = Cable(
a_terminations=[interface3],
b_terminations=[frontport3],
label='C5'
)
cable5.save()
self.assertPathExists(
(
interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4, cable6, frontport5, rearport5,
cable7, rearport6, frontport6
),
is_complete=False
)
self.assertEqual(CablePath.objects.count(), 2)
# Create cable 3
cable3 = Cable(
a_terminations=[frontport2, frontport6],
b_terminations=[interface2],
label='C3'
)
cable3.save()
self.assertPathExists(
(
interface2, cable3, (frontport2, frontport6), (rearport2, rearport6), (cable2, cable7),
(rearport1, rearport5), (frontport1, frontport5), (cable1, cable6)
),
is_complete=False,
is_split=True
)
self.assertPathExists(
(
interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2, cable3, interface2
),
is_complete=True,
is_active=True
)
self.assertPathExists(
(
interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4, cable6, frontport5, rearport5,
cable7, rearport6, frontport6, cable3, interface2
),
is_complete=True,
is_active=True
)
self.assertEqual(CablePath.objects.count(), 3)
def test_301_create_path_via_existing_cable(self): def test_301_create_path_via_existing_cable(self):
""" """
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]
@ -1845,3 +2147,93 @@ class CablePathTestCase(TestCase):
is_complete=True, is_complete=True,
is_active=True is_active=True
) )
def test_401_exclude_midspan_devices(self):
"""
[IF1] --C1-- [FP1][Test Device][RP1] --C2-- [RP2][Test Device][FP2] --C3-- [IF2]
[FP3][Test mid-span Device][RP3] --C4-- [RP4][Test mid-span Device][FP4] /
"""
device = Device.objects.create(
site=self.site,
device_type=self.device.device_type,
device_role=self.device.device_role,
name='Test mid-span Device'
)
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
rearport3 = RearPort.objects.create(device=device, name='Rear Port 3', positions=1)
rearport4 = RearPort.objects.create(device=device, name='Rear Port 4', positions=1)
frontport1 = FrontPort.objects.create(
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
)
frontport2 = FrontPort.objects.create(
device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
)
frontport3 = FrontPort.objects.create(
device=device, name='Front Port 3', rear_port=rearport3, rear_port_position=1
)
frontport4 = FrontPort.objects.create(
device=device, name='Front Port 4', rear_port=rearport4, rear_port_position=1
)
cable2 = Cable(
a_terminations=[rearport1],
b_terminations=[rearport2],
label='C2'
)
cable2.save()
cable4 = Cable(
a_terminations=[rearport3],
b_terminations=[rearport4],
label='C4'
)
cable4.save()
self.assertEqual(CablePath.objects.count(), 0)
# Create cable1
cable1 = Cable(
a_terminations=[interface1],
b_terminations=[frontport1, frontport3],
label='C1'
)
with self.assertRaises(AssertionError):
cable1.save()
self.assertPathDoesNotExist(
(
interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4),
(rearport2, rearport4), (frontport2, frontport4)
),
is_complete=False
)
self.assertEqual(CablePath.objects.count(), 0)
# Create cable 3
cable3 = Cable(
a_terminations=[frontport2, frontport4],
b_terminations=[interface2],
label='C3'
)
with self.assertRaises(AssertionError):
cable3.save()
self.assertPathDoesNotExist(
(
interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4),
(rearport1, rearport3), (frontport1, frontport2), cable1, interface1
),
is_complete=True,
is_active=True
)
self.assertPathDoesNotExist(
(
interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4),
(rearport2, rearport4), (frontport2, frontport4), cable3, interface2
),
is_complete=True,
is_active=True
)
self.assertEqual(CablePath.objects.count(), 0)

View File

@ -4275,6 +4275,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
Interface(device=devices[4], name='Interface 10', type=InterfaceTypeChoices.TYPE_1GE_FIXED), Interface(device=devices[4], name='Interface 10', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[5], name='Interface 11', type=InterfaceTypeChoices.TYPE_1GE_FIXED), Interface(device=devices[5], name='Interface 11', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[5], name='Interface 12', type=InterfaceTypeChoices.TYPE_1GE_FIXED), Interface(device=devices[5], name='Interface 12', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[5], name='Interface 13', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
) )
Interface.objects.bulk_create(interfaces) Interface.objects.bulk_create(interfaces)
@ -4290,6 +4291,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
Cable(a_terminations=[interfaces[11]], b_terminations=[interfaces[0]], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save() Cable(a_terminations=[interfaces[11]], b_terminations=[interfaces[0]], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save()
Cable(a_terminations=[console_port], b_terminations=[console_server_port], label='Cable 7').save() Cable(a_terminations=[console_port], b_terminations=[console_server_port], label='Cable 7').save()
# Cable for unterminated test
Cable(a_terminations=[interfaces[12]], label='Cable 8', type=CableTypeChoices.TYPE_CAT6, status=LinkStatusChoices.STATUS_DECOMMISSIONING).save()
def test_label(self): def test_label(self):
params = {'label': ['Cable 1', 'Cable 2']} params = {'label': ['Cable 1', 'Cable 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@ -4368,6 +4372,12 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
} }
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_unterminated(self):
params = {'unterminated': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'unterminated': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)
class PowerPanelTestCase(TestCase, ChangeLoggedFilterSetTests): class PowerPanelTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = PowerPanel.objects.all() queryset = PowerPanel.objects.all()
@ -4702,12 +4712,18 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
addresses = ( addresses = (
IPAddress(assigned_object=interfaces[0], address='10.1.1.1/24'), IPAddress(assigned_object=interfaces[0], address='10.1.1.1/24'),
IPAddress(assigned_object=interfaces[1], address='10.1.1.2/24'), IPAddress(assigned_object=interfaces[1], address='10.1.1.2/24'),
IPAddress(assigned_object=None, address='10.1.1.3/24'),
IPAddress(assigned_object=interfaces[0], address='2001:db8::1/64'),
IPAddress(assigned_object=interfaces[1], address='2001:db8::2/64'),
IPAddress(assigned_object=None, address='2001:db8::3/64'),
) )
IPAddress.objects.bulk_create(addresses) IPAddress.objects.bulk_create(addresses)
vdcs[0].primary_ip4 = addresses[0] vdcs[0].primary_ip4 = addresses[0]
vdcs[0].primary_ip6 = addresses[3]
vdcs[0].save() vdcs[0].save()
vdcs[1].primary_ip4 = addresses[1] vdcs[1].primary_ip4 = addresses[1]
vdcs[1].primary_ip6 = addresses[4]
vdcs[1].save() vdcs[1].save()
def test_device(self): def test_device(self):
@ -4728,3 +4744,17 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'has_primary_ip': False} params = {'has_primary_ip': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_primary_ip4(self):
addresses = IPAddress.objects.filter(address__family=4)
params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'primary_ip4_id': [addresses[2].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
def test_primary_ip6(self):
addresses = IPAddress.objects.filter(address__family=6)
params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'primary_ip6_id': [addresses[2].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)

View File

@ -122,16 +122,18 @@ class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View)
if form.is_valid(): if form.is_valid():
with transaction.atomic(): with transaction.atomic():
count = 0 count = 0
cable_ids = set()
for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']): for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']):
if obj.cable is None: if obj.cable:
continue cable_ids.add(obj.cable.pk)
obj.cable.delete() count += 1
count += 1 for cable in Cable.objects.filter(pk__in=cable_ids):
cable.delete()
messages.success(request, "Disconnected {} {}".format( messages.success(request, _("Disconnected {count} {type}").format(
count, self.queryset.model._meta.verbose_name_plural count=count,
type=self.queryset.model._meta.verbose_name_plural
)) ))
return redirect(return_url) return redirect(return_url)
@ -2991,6 +2993,25 @@ class InventoryItemBulkDeleteView(generic.BulkDeleteView):
template_name = 'dcim/inventoryitem_bulk_delete.html' template_name = 'dcim/inventoryitem_bulk_delete.html'
@register_model_view(InventoryItem, 'children')
class InventoryItemChildrenView(generic.ObjectChildrenView):
queryset = InventoryItem.objects.all()
child_model = InventoryItem
table = tables.InventoryItemTable
filterset = filtersets.InventoryItemFilterSet
template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('Children'),
badge=lambda obj: obj.child_items.count(),
permission='dcim.view_inventoryitem',
hide_if_empty=True,
weight=5000
)
def get_children(self, request, parent):
return parent.child_items.restrict(request.user, 'view')
# #
# Inventory item roles # Inventory item roles
# #

View File

@ -454,7 +454,7 @@ class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer
required=False required=False
) )
data_file = NestedDataFileSerializer( data_file = NestedDataFileSerializer(
read_only=True required=False
) )
class Meta: class Meta:

View File

@ -82,7 +82,10 @@ class CustomFieldChoiceSetViewSet(NetBoxModelViewSet):
data = [ data = [
{'id': c[0], 'display': c[1]} for c in page {'id': c[0], 'display': c[1]} for c in page
] ]
return self.get_paginated_response(data) else:
data = []
return self.get_paginated_response(data)
# #

View File

@ -244,3 +244,39 @@ class ChangeActionChoices(ChoiceSet):
(ACTION_UPDATE, _('Update'), 'blue'), (ACTION_UPDATE, _('Update'), 'blue'),
(ACTION_DELETE, _('Delete'), 'red'), (ACTION_DELETE, _('Delete'), 'red'),
) )
#
# Dashboard widgets
#
class DashboardWidgetColorChoices(ChoiceSet):
BLUE = 'blue'
INDIGO = 'indigo'
PURPLE = 'purple'
PINK = 'pink'
RED = 'red'
ORANGE = 'orange'
YELLOW = 'yellow'
GREEN = 'green'
TEAL = 'teal'
CYAN = 'cyan'
GRAY = 'gray'
BLACK = 'black'
WHITE = 'white'
CHOICES = (
(BLUE, _('Blue')),
(INDIGO, _('Indigo')),
(PURPLE, _('Purple')),
(PINK, _('Pink')),
(RED, _('Red')),
(ORANGE, _('Orange')),
(YELLOW, _('Yellow')),
(GREEN, _('Green')),
(TEAL, _('Teal')),
(CYAN, _('Cyan')),
(GRAY, _('Gray')),
(BLACK, _('Black')),
(WHITE, _('White')),
)

View File

@ -2,9 +2,9 @@ from django import forms
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from extras.choices import DashboardWidgetColorChoices
from netbox.registry import registry from netbox.registry import registry
from utilities.forms import BootstrapMixin, add_blank_choice from utilities.forms import BootstrapMixin, add_blank_choice
from utilities.choices import ButtonColorChoices
__all__ = ( __all__ = (
'DashboardWidgetAddForm', 'DashboardWidgetAddForm',
@ -21,7 +21,7 @@ class DashboardWidgetForm(BootstrapMixin, forms.Form):
required=False required=False
) )
color = forms.ChoiceField( color = forms.ChoiceField(
choices=add_blank_choice(ButtonColorChoices), choices=add_blank_choice(DashboardWidgetColorChoices),
required=False, required=False,
) )

View File

@ -76,7 +76,8 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
'type': _( 'type': _(
"The type of data stored in this field. For object/multi-object fields, select the related object " "The type of data stored in this field. For object/multi-object fields, select the related object "
"type below." "type below."
) ),
'description': _("This will be displayed as help text for the form field. Markdown is supported.")
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -517,22 +518,34 @@ class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMe
config = get_config() config = get_config()
for param in PARAMS: for param in PARAMS:
value = getattr(config, param.name) value = getattr(config, param.name)
is_static = hasattr(settings, param.name)
if value: # Set the field's initial value, if it can be serialized. (This may not be the case e.g. for
help_text = self.fields[param.name].help_text # CUSTOM_VALIDATORS, which may reference Python objects.)
if help_text: try:
help_text += '<br />' # Line break json.dumps(value)
help_text += _('Current value: <strong>{value}</strong>').format(value=value)
if is_static:
help_text += _(' (defined statically)')
elif value == param.default:
help_text += _(' (default)')
self.fields[param.name].help_text = help_text
if type(value) in (tuple, list): if type(value) in (tuple, list):
value = ', '.join(value) self.fields[param.name].initial = ', '.join(value)
self.fields[param.name].initial = value else:
if is_static: self.fields[param.name].initial = value
except TypeError:
pass
# Check whether this parameter is statically configured (e.g. in configuration.py)
if hasattr(settings, param.name):
self.fields[param.name].disabled = True self.fields[param.name].disabled = True
self.fields[param.name].help_text = _(
'This parameter has been defined statically and cannot be modified.'
)
continue
# Set the field's help text
help_text = self.fields[param.name].help_text
if help_text:
help_text += '<br />' # Line break
help_text += _('Current value: <strong>{value}</strong>').format(value=value or '&mdash;')
if value == param.default:
help_text += _(' (default)')
self.fields[param.name].help_text = help_text
def save(self, commit=True): def save(self, commit=True):
instance = super().save(commit=False) instance = super().save(commit=False)

View File

@ -28,6 +28,7 @@ from utilities.forms.fields import (
from utilities.forms.utils import add_blank_choice from utilities.forms.utils import add_blank_choice
from utilities.forms.widgets import APISelect, APISelectMultiple, DatePicker, DateTimePicker from utilities.forms.widgets import APISelect, APISelectMultiple, DatePicker, DateTimePicker
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.templatetags.builtins.filters import render_markdown
from utilities.validators import validate_regex from utilities.validators import validate_regex
__all__ = ( __all__ = (
@ -219,7 +220,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Cache instance's original name so we can check later whether it has changed # Cache instance's original name so we can check later whether it has changed
self._name = self.name self._name = self.__dict__.get('name')
@property @property
def search_type(self): def search_type(self):
@ -231,6 +232,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
return self.choice_set.choices return self.choice_set.choices
return [] return []
def get_choice_label(self, value):
if not hasattr(self, '_choice_map'):
self._choice_map = dict(self.choices)
return self._choice_map.get(value, value)
def populate_initial_data(self, content_types): def populate_initial_data(self, content_types):
""" """
Populate initial custom field data upon either a) the creation of a new CustomField, or Populate initial custom field data upon either a) the creation of a new CustomField, or
@ -498,7 +504,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
field.model = self field.model = self
field.label = str(self) field.label = str(self)
if self.description: if self.description:
field.help_text = escape(self.description) field.help_text = render_markdown(self.description)
# Annotate read-only fields # Annotate read-only fields
if enforce_visibility and self.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY: if enforce_visibility and self.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:

View File

@ -23,7 +23,7 @@ logger = logging.getLogger(__name__)
def get_module_and_report(module_name, report_name): def get_module_and_report(module_name, report_name):
module = ReportModule.objects.get(file_path=f'{module_name}.py') module = ReportModule.objects.get(file_path=f'{module_name}.py')
report = module.reports.get(report_name) report = module.reports.get(report_name)()
return module, report return module, report
@ -106,8 +106,6 @@ class Report(object):
'failure': 0, 'failure': 0,
'log': [], 'log': [],
} }
if not test_methods:
raise Exception("A report must contain at least one test method.")
self.test_methods = test_methods self.test_methods = test_methods
@classproperty @classproperty
@ -137,6 +135,13 @@ class Report(object):
def source(self): def source(self):
return inspect.getsource(self.__class__) return inspect.getsource(self.__class__)
@property
def is_valid(self):
"""
Indicates whether the report can be run.
"""
return bool(self.test_methods)
# #
# Logging methods # Logging methods
# #

View File

@ -6,7 +6,7 @@ from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.urls import reverse from django.urls import reverse
from dcim.models import Site from dcim.models import DeviceType, Manufacturer, Site
from extras.choices import * from extras.choices import *
from extras.models import * from extras.models import *
from utilities.testing import ViewTestCases, TestCase from utilities.testing import ViewTestCases, TestCase
@ -434,7 +434,8 @@ class ConfigContextTestCase(
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
# Create three ConfigContexts # Create three ConfigContexts
for i in range(1, 4): for i in range(1, 4):
@ -443,7 +444,7 @@ class ConfigContextTestCase(
data={'foo': i} data={'foo': i}
) )
configcontext.save() configcontext.save()
configcontext.sites.add(site) configcontext.device_types.add(devicetype)
cls.form_data = { cls.form_data = {
'name': 'Config Context X', 'name': 'Config Context X',
@ -451,11 +452,12 @@ class ConfigContextTestCase(
'description': 'A new config context', 'description': 'A new config context',
'is_active': True, 'is_active': True,
'regions': [], 'regions': [],
'sites': [site.pk], 'sites': [],
'roles': [], 'roles': [],
'platforms': [], 'platforms': [],
'tenant_groups': [], 'tenant_groups': [],
'tenants': [], 'tenants': [],
'device_types': [devicetype.id],
'tags': [], 'tags': [],
'data': '{"foo": 123}', 'data': '{"foo": 123}',
} }

View File

@ -978,6 +978,10 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
}) })
def get_report_module(module, request):
return get_object_or_404(ReportModule.objects.restrict(request.user), file_path__regex=f"^{module}\\.")
class ReportView(ContentTypePermissionRequiredMixin, View): class ReportView(ContentTypePermissionRequiredMixin, View):
""" """
Display a single Report and its associated Job (if any). Display a single Report and its associated Job (if any).
@ -986,7 +990,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
return 'extras.view_report' return 'extras.view_report'
def get(self, request, module, name): def get(self, request, module, name):
module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module) module = get_report_module(module, request)
report = module.reports[name]() report = module.reports[name]()
object_type = ContentType.objects.get(app_label='extras', model='reportmodule') object_type = ContentType.objects.get(app_label='extras', model='reportmodule')
@ -1007,7 +1011,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
if not request.user.has_perm('extras.run_report'): if not request.user.has_perm('extras.run_report'):
return HttpResponseForbidden() return HttpResponseForbidden()
module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module) module = get_report_module(module, request)
report = module.reports[name]() report = module.reports[name]()
form = ReportForm(request.POST, scheduling_enabled=report.scheduling_enabled) form = ReportForm(request.POST, scheduling_enabled=report.scheduling_enabled)
@ -1046,7 +1050,7 @@ class ReportSourceView(ContentTypePermissionRequiredMixin, View):
return 'extras.view_report' return 'extras.view_report'
def get(self, request, module, name): def get(self, request, module, name):
module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module) module = get_report_module(module, request)
report = module.reports[name]() report = module.reports[name]()
return render(request, 'extras/report/source.html', { return render(request, 'extras/report/source.html', {
@ -1062,7 +1066,7 @@ class ReportJobsView(ContentTypePermissionRequiredMixin, View):
return 'extras.view_report' return 'extras.view_report'
def get(self, request, module, name): def get(self, request, module, name):
module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module) module = get_report_module(module, request)
report = module.reports[name]() report = module.reports[name]()
object_type = ContentType.objects.get(app_label='extras', model='reportmodule') object_type = ContentType.objects.get(app_label='extras', model='reportmodule')
@ -1151,13 +1155,17 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
}) })
def get_script_module(module, request):
return get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__regex=f"^{module}\\.")
class ScriptView(ContentTypePermissionRequiredMixin, View): class ScriptView(ContentTypePermissionRequiredMixin, View):
def get_required_permission(self): def get_required_permission(self):
return 'extras.view_script' return 'extras.view_script'
def get(self, request, module, name): def get(self, request, module, name):
module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module) module = get_script_module(module, request)
script = module.scripts[name]() script = module.scripts[name]()
form = script.as_form(initial=normalize_querydict(request.GET)) form = script.as_form(initial=normalize_querydict(request.GET))
@ -1181,7 +1189,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
if not request.user.has_perm('extras.run_script'): if not request.user.has_perm('extras.run_script'):
return HttpResponseForbidden() return HttpResponseForbidden()
module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module) module = get_script_module(module, request)
script = module.scripts[name]() script = module.scripts[name]()
form = script.as_form(request.POST, request.FILES) form = script.as_form(request.POST, request.FILES)
@ -1218,7 +1226,7 @@ class ScriptSourceView(ContentTypePermissionRequiredMixin, View):
return 'extras.view_script' return 'extras.view_script'
def get(self, request, module, name): def get(self, request, module, name):
module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module) module = get_script_module(module, request)
script = module.scripts[name]() script = module.scripts[name]()
return render(request, 'extras/script/source.html', { return render(request, 'extras/script/source.html', {
@ -1234,7 +1242,7 @@ class ScriptJobsView(ContentTypePermissionRequiredMixin, View):
return 'extras.view_script' return 'extras.view_script'
def get(self, request, module, name): def get(self, request, module, name):
module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module) module = get_script_module(module, request)
script = module.scripts[name]() script = module.scripts[name]()
object_type = ContentType.objects.get(app_label='extras', model='scriptmodule') object_type = ContentType.objects.get(app_label='extras', model='scriptmodule')

View File

@ -266,6 +266,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView):
# Normalize request data to a list of objects # Normalize request data to a list of objects
requested_objects = request.data if isinstance(request.data, list) else [request.data] requested_objects = request.data if isinstance(request.data, list) else [request.data]
limit = len(requested_objects)
# Serialize and validate the request data # Serialize and validate the request data
serializer = self.write_serializer_class(data=requested_objects, many=True, context={ serializer = self.write_serializer_class(data=requested_objects, many=True, context={
@ -279,7 +280,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView):
) )
with advisory_lock(ADVISORY_LOCK_KEYS[self.advisory_lock_key]): with advisory_lock(ADVISORY_LOCK_KEYS[self.advisory_lock_key]):
available_objects = self.get_available_objects(parent) available_objects = self.get_available_objects(parent, limit)
# Determine if the requested number of objects is available # Determine if the requested number of objects is available
if not self.check_sufficient_available(serializer.validated_data, available_objects): if not self.check_sufficient_available(serializer.validated_data, available_objects):
@ -289,7 +290,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView):
) )
# Prepare object data for deserialization # Prepare object data for deserialization
requested_objects = self.prep_object_data(serializer.validated_data, available_objects, parent) requested_objects = self.prep_object_data(requested_objects, available_objects, parent)
# Initialize the serializer with a list or a single object depending on what was requested # Initialize the serializer with a list or a single object depending on what was requested
serializer_class = get_serializer_for_model(self.queryset.model) serializer_class = get_serializer_for_model(self.queryset.model)

View File

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

View File

@ -1,7 +1,8 @@
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from dcim.models import Region, Site, SiteGroup from dcim.models import Location, Rack, Region, Site, SiteGroup
from ipam.choices import * from ipam.choices import *
from ipam.constants import * from ipam.constants import *
from ipam.models import * from ipam.models import *
@ -10,9 +11,10 @@ from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import add_blank_choice from utilities.forms import add_blank_choice
from utilities.forms.fields import ( from utilities.forms.fields import (
CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField, CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField,
) )
from utilities.forms.widgets import BulkEditNullBooleanSelect from utilities.forms.widgets import BulkEditNullBooleanSelect
from virtualization.models import Cluster, ClusterGroup
__all__ = ( __all__ = (
'AggregateBulkEditForm', 'AggregateBulkEditForm',
@ -407,11 +409,6 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm):
class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): class VLANGroupBulkEditForm(NetBoxModelBulkEditForm):
site = DynamicModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(),
required=False
)
min_vid = forms.IntegerField( min_vid = forms.IntegerField(
min_value=VLAN_VID_MIN, min_value=VLAN_VID_MIN,
max_value=VLAN_VID_MAX, max_value=VLAN_VID_MAX,
@ -429,12 +426,84 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm):
max_length=200, max_length=200,
required=False required=False
) )
scope_type = ContentTypeChoiceField(
label=_('Scope type'),
queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
required=False
)
scope_id = forms.IntegerField(
required=False,
widget=forms.HiddenInput()
)
region = DynamicModelChoiceField(
label=_('Region'),
queryset=Region.objects.all(),
required=False
)
sitegroup = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group')
)
site = DynamicModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(),
required=False,
query_params={
'region_id': '$region',
'group_id': '$sitegroup',
}
)
location = DynamicModelChoiceField(
label=_('Location'),
queryset=Location.objects.all(),
required=False,
query_params={
'site_id': '$site',
}
)
rack = DynamicModelChoiceField(
label=_('Rack'),
queryset=Rack.objects.all(),
required=False,
query_params={
'site_id': '$site',
'location_id': '$location',
}
)
clustergroup = DynamicModelChoiceField(
queryset=ClusterGroup.objects.all(),
required=False,
label=_('Cluster group')
)
cluster = DynamicModelChoiceField(
label=_('Cluster'),
queryset=Cluster.objects.all(),
required=False,
query_params={
'group_id': '$clustergroup',
}
)
model = VLANGroup model = VLANGroup
fieldsets = ( fieldsets = (
(None, ('site', 'min_vid', 'max_vid', 'description')), (None, ('site', 'min_vid', 'max_vid', 'description')),
(_('Scope'), ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')),
) )
nullable_fields = ('site', 'description') nullable_fields = ('description',)
def clean(self):
super().clean()
# Assign scope based on scope_type
if self.cleaned_data.get('scope_type'):
scope_field = self.cleaned_data['scope_type'].model
if scope_obj := self.cleaned_data.get(scope_field):
self.cleaned_data['scope_id'] = scope_obj.pk
self.changed_data.append('scope_id')
else:
self.cleaned_data.pop('scope_type')
self.changed_data.remove('scope_type')
class VLANBulkEditForm(NetBoxModelBulkEditForm): class VLANBulkEditForm(NetBoxModelBulkEditForm):

View File

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

View File

@ -295,7 +295,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = IPAddress model = IPAddress
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
(_('Attributes'), ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface')), (_('Attributes'), ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface', 'dns_name')),
(_('VRF'), ('vrf_id', 'present_in_vrf_id')), (_('VRF'), ('vrf_id', 'present_in_vrf_id')),
(_('Tenant'), ('tenant_group_id', 'tenant_id')), (_('Tenant'), ('tenant_group_id', 'tenant_id')),
(_('Device/VM'), ('device_id', 'virtual_machine_id')), (_('Device/VM'), ('device_id', 'virtual_machine_id')),
@ -357,6 +357,10 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
dns_name = forms.CharField(
required=False,
label=_('DNS Name')
)
tag = TagFilterField(model) tag = TagFilterField(model)
@ -519,6 +523,21 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
class ServiceFilterForm(ServiceTemplateFilterForm): class ServiceFilterForm(ServiceTemplateFilterForm):
model = Service model = Service
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
(_('Attributes'), ('protocol', 'port')),
(_('Assignment'), ('device_id', 'virtual_machine_id')),
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
label=_('Device'),
)
virtual_machine_id = DynamicModelMultipleChoiceField(
queryset=VirtualMachine.objects.all(),
required=False,
label=_('Virtual Machine'),
)
tag = TagFilterField(model) tag = TagFilterField(model)

View File

@ -354,7 +354,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
}) })
elif selected_objects: elif selected_objects:
assigned_object = self.cleaned_data[selected_objects[0]] assigned_object = self.cleaned_data[selected_objects[0]]
if self.instance.pk and self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object: if self.instance.pk and self.instance.assigned_object and self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object:
raise ValidationError( raise ValidationError(
_("Cannot reassign IP address while it is designated as the primary IP for the parent object") _("Cannot reassign IP address while it is designated as the primary IP for the parent object")
) )

View File

@ -290,8 +290,8 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Cache the original prefix and VRF so we can check if they have changed on post_save # Cache the original prefix and VRF so we can check if they have changed on post_save
self._prefix = self.prefix self._prefix = self.__dict__.get('prefix')
self._vrf_id = self.vrf_id self._vrf_id = self.__dict__.get('vrf_id')
def __str__(self): def __str__(self):
return str(self.prefix) return str(self.prefix)
@ -782,6 +782,13 @@ class IPAddress(PrimaryModel):
def __str__(self): def __str__(self):
return str(self.address) return str(self.address)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Denote the original assigned object (if any) for validation in clean()
self._original_assigned_object_id = self.__dict__.get('assigned_object_id')
self._original_assigned_object_type_id = self.__dict__.get('assigned_object_type_id')
def get_absolute_url(self): def get_absolute_url(self):
return reverse('ipam:ipaddress', args=[self.pk]) return reverse('ipam:ipaddress', args=[self.pk])
@ -843,6 +850,26 @@ class IPAddress(PrimaryModel):
) )
}) })
if self._original_assigned_object_id and self._original_assigned_object_type_id:
parent = getattr(self.assigned_object, 'parent_object', None)
ct = ContentType.objects.get_for_id(self._original_assigned_object_type_id)
original_assigned_object = ct.get_object_for_this_type(pk=self._original_assigned_object_id)
original_parent = getattr(original_assigned_object, 'parent_object', None)
# can't use is_primary_ip as self.assigned_object might be changed
is_primary = False
if self.family == 4 and hasattr(original_parent, 'primary_ip4') and original_parent.primary_ip4_id == self.pk:
is_primary = True
if self.family == 6 and hasattr(original_parent, 'primary_ip6') and original_parent.primary_ip6_id == self.pk:
is_primary = True
if is_primary and (parent != original_parent):
raise ValidationError({
'assigned_object': _(
"Cannot reassign IP address while it is designated as the primary IP for the parent object"
)
})
# Validate IP status selection # Validate IP status selection
if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6: if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6:
raise ValidationError({ raise ValidationError({

View File

@ -659,6 +659,62 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase):
) )
IPAddress.objects.bulk_create(ip_addresses) IPAddress.objects.bulk_create(ip_addresses)
def test_assign_object(self):
"""
Test the creation of available IP addresses within a parent IP range.
"""
site = Site.objects.create(name='Site 1')
manufacturer = Manufacturer.objects.create(name='Manufacturer 1')
device_type = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
role = DeviceRole.objects.create(name='Switch')
device1 = Device.objects.create(
name='Device 1',
site=site,
device_type=device_type,
role=role,
status='active'
)
interface1 = Interface.objects.create(name='Interface 1', device=device1, type='1000baset')
interface2 = Interface.objects.create(name='Interface 2', device=device1, type='1000baset')
device2 = Device.objects.create(
name='Device 2',
site=site,
device_type=device_type,
role=role,
status='active'
)
interface3 = Interface.objects.create(name='Interface 3', device=device2, type='1000baset')
ip_addresses = (
IPAddress(address=IPNetwork('192.168.0.4/24'), assigned_object=interface1),
IPAddress(address=IPNetwork('192.168.1.4/24')),
)
IPAddress.objects.bulk_create(ip_addresses)
ip1 = ip_addresses[0]
ip1.assigned_object = interface1
device1.primary_ip4 = ip_addresses[0]
device1.save()
ip2 = ip_addresses[1]
url = reverse('ipam-api:ipaddress-detail', kwargs={'pk': ip1.pk})
self.add_permissions('ipam.change_ipaddress')
# assign to same parent
data = {
'assigned_object_id': interface2.pk
}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
# assign to same different parent - should error
data = {
'assigned_object_id': interface3.pk
}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
class FHRPGroupTest(APIViewTestCases.APIViewTestCase): class FHRPGroupTest(APIViewTestCases.APIViewTestCase):
model = FHRPGroup model = FHRPGroup

View File

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

View File

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

View File

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

View File

@ -46,12 +46,13 @@ class ChoiceField(serializers.Field):
return super().validate_empty_values(data) return super().validate_empty_values(data)
def to_representation(self, obj): def to_representation(self, obj):
if obj == '': if obj != '':
return None # Use an empty string in place of the choice label if it cannot be resolved (i.e. because a previously
return { # configured choice has been removed from FIELD_CHOICES).
'value': obj, return {
'label': self._choices[obj], 'value': obj,
} 'label': self._choices.get(obj, ''),
}
def to_internal_value(self, data): def to_internal_value(self, data):
if data == '': if data == '':

View File

@ -3,6 +3,8 @@ import logging
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import transaction from django.db import transaction
from django.db.models import ProtectedError from django.db.models import ProtectedError
from django_pglocks import advisory_lock
from netbox.constants import ADVISORY_LOCK_KEYS
from rest_framework import mixins as drf_mixins from rest_framework import mixins as drf_mixins
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet from rest_framework.viewsets import GenericViewSet
@ -157,3 +159,22 @@ class NetBoxModelViewSet(
logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})") logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})")
return super().perform_destroy(instance) return super().perform_destroy(instance)
class MPTTLockedMixin:
"""
Puts pglock on objects that derive from MPTTModel for parallel API calling.
Note: If adding this to a view, must add the model name to ADVISORY_LOCK_KEYS
"""
def create(self, request, *args, **kwargs):
with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]):
return super().create(request, *args, **kwargs)
def update(self, request, *args, **kwargs):
with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]):
return super().update(request, *args, **kwargs)
def destroy(self, request, *args, **kwargs):
with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]):
return super().destroy(request, *args, **kwargs)

View File

@ -11,8 +11,19 @@ RQ_QUEUE_LOW = 'low'
# When adding a new key, pick something arbitrary and unique so that it is easily searchable in # When adding a new key, pick something arbitrary and unique so that it is easily searchable in
# query logs. # query logs.
ADVISORY_LOCK_KEYS = { ADVISORY_LOCK_KEYS = {
# Available object locks
'available-prefixes': 100100, 'available-prefixes': 100100,
'available-ips': 100200, 'available-ips': 100200,
'available-vlans': 100300, 'available-vlans': 100300,
'available-asns': 100400, 'available-asns': 100400,
# MPTT locks
'region': 105100,
'sitegroup': 105200,
'location': 105300,
'tenantgroup': 105400,
'contactgroup': 105500,
'wirelesslangroup': 105600,
'inventoryitem': 105700,
'inventoryitemtemplate': 105800,
} }

View File

@ -1,5 +1,6 @@
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import ObjectDoesNotExist
from django.core.validators import ValidationError from django.core.validators import ValidationError
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -85,11 +86,16 @@ class NetBoxModel(NetBoxFeatureSet, models.Model):
if ct_value and fk_value: if ct_value and fk_value:
klass = getattr(self, field.ct_field).model_class() klass = getattr(self, field.ct_field).model_class()
if not klass.objects.filter(pk=fk_value).exists(): try:
obj = klass.objects.get(pk=fk_value)
except ObjectDoesNotExist:
raise ValidationError({ raise ValidationError({
field.fk_field: f"Related object not found using the provided value: {fk_value}." field.fk_field: f"Related object not found using the provided value: {fk_value}."
}) })
# update the GFK field value
setattr(self, field.name, obj)
# #
# NetBox internal base models # NetBox internal base models

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.6.2-dev' VERSION = '3.6.6-dev'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()
@ -95,6 +95,7 @@ CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
CSRF_COOKIE_NAME = getattr(configuration, 'CSRF_COOKIE_NAME', 'csrftoken') CSRF_COOKIE_NAME = getattr(configuration, 'CSRF_COOKIE_NAME', 'csrftoken')
CSRF_COOKIE_SECURE = getattr(configuration, 'CSRF_COOKIE_SECURE', False) CSRF_COOKIE_SECURE = getattr(configuration, 'CSRF_COOKIE_SECURE', False)
CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', []) CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', [])
DATA_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'DATA_UPLOAD_MAX_MEMORY_SIZE', 2621440)
DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y') DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a') DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
DEBUG = getattr(configuration, 'DEBUG', False) DEBUG = getattr(configuration, 'DEBUG', False)
@ -355,6 +356,7 @@ INSTALLED_APPS = [
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'django.contrib.humanize', 'django.contrib.humanize',
'django.forms',
'corsheaders', 'corsheaders',
'debug_toolbar', 'debug_toolbar',
'graphiql_debug_toolbar', 'graphiql_debug_toolbar',
@ -430,6 +432,9 @@ TEMPLATES = [
}, },
] ]
# This allows us to override Django's stock form widget templates
FORM_RENDERER = 'django.forms.renderers.TemplatesSetting'
# Set up authentication backends # Set up authentication backends
if type(REMOTE_AUTH_BACKEND) not in (list, tuple): if type(REMOTE_AUTH_BACKEND) not in (list, tuple):
REMOTE_AUTH_BACKEND = [REMOTE_AUTH_BACKEND] REMOTE_AUTH_BACKEND = [REMOTE_AUTH_BACKEND]
@ -497,6 +502,9 @@ AUTH_EXEMPT_PATHS = (
MAINTENANCE_EXEMPT_PATHS = ( MAINTENANCE_EXEMPT_PATHS = (
f'/{BASE_PATH}admin/', f'/{BASE_PATH}admin/',
f'/{BASE_PATH}extras/config-revisions/', # Allow modifying the configuration f'/{BASE_PATH}extras/config-revisions/', # Allow modifying the configuration
LOGIN_URL,
LOGIN_REDIRECT_URL,
LOGOUT_REDIRECT_URL
) )
SERIALIZATION_MODULES = { SERIALIZATION_MODULES = {

View File

@ -483,8 +483,10 @@ class CustomFieldColumn(tables.Column):
return mark_safe('<i class="mdi mdi-close-thick text-danger"></i>') return mark_safe('<i class="mdi mdi-close-thick text-danger"></i>')
if self.customfield.type == CustomFieldTypeChoices.TYPE_URL: if self.customfield.type == CustomFieldTypeChoices.TYPE_URL:
return mark_safe(f'<a href="{escape(value)}">{escape(value)}</a>') return mark_safe(f'<a href="{escape(value)}">{escape(value)}</a>')
if self.customfield.type == CustomFieldTypeChoices.TYPE_SELECT:
return self.customfield.get_choice_label(value)
if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT: if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
return ', '.join(v for v in value) return ', '.join(self.customfield.get_choice_label(v) for v in value)
if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
return mark_safe(', '.join( return mark_safe(', '.join(
self._linkify_item(obj) for obj in self.customfield.deserialize(value) self._linkify_item(obj) for obj in self.customfield.deserialize(value)

View File

@ -119,7 +119,7 @@ class BaseTable(tables.Table):
@property @property
def available_columns(self): def available_columns(self):
return self._get_columns(visible=False) return sorted(self._get_columns(visible=False))
@property @property
def selected_columns(self): def selected_columns(self):

View File

@ -17,6 +17,36 @@ class CSVImportTestCase(ModelViewTestCase):
def _get_csv_data(self, csv_data): def _get_csv_data(self, csv_data):
return '\n'.join(csv_data) return '\n'.join(csv_data)
def test_invalid_headers(self):
"""
Test that import form validation fails when an unknown CSV header is present.
"""
self.add_permissions('dcim.add_region')
csv_data = [
'name,slug,INVALIDHEADER',
'Region 1,region-1,abc',
'Region 2,region-2,def',
'Region 3,region-3,ghi',
]
data = {
'format': ImportFormatChoices.CSV,
'data': self._get_csv_data(csv_data),
'csv_delimiter': CSVDelimiterChoices.AUTO,
}
# Form validation should fail with invalid header present
self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200)
self.assertEqual(Region.objects.count(), 0)
# Correct the CSV header name
csv_data[0] = 'name,slug,description'
data['data'] = self._get_csv_data(csv_data)
# Validation should succeed
self.assertHttpStatus(self.client.post(self._get_url('import'), data), 302)
self.assertEqual(Region.objects.count(), 3)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_valid_tags(self): def test_valid_tags(self):
csv_data = ( csv_data = (

View File

@ -3,6 +3,7 @@ import re
from copy import deepcopy from copy import deepcopy
from django.contrib import messages from django.contrib import messages
from django.contrib.contenttypes.fields import GenericRel
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError
from django.db import transaction, IntegrityError from django.db import transaction, IntegrityError
@ -519,9 +520,11 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
model_field = self.queryset.model._meta.get_field(name) model_field = self.queryset.model._meta.get_field(name)
if isinstance(model_field, (ManyToManyField, ManyToManyRel)): if isinstance(model_field, (ManyToManyField, ManyToManyRel)):
m2m_fields[name] = model_field m2m_fields[name] = model_field
elif isinstance(model_field, GenericRel):
# Ignore generic relations (these may be used for other purposes in the form)
continue
else: else:
model_fields[name] = model_field model_fields[name] = model_field
except FieldDoesNotExist: except FieldDoesNotExist:
# This form field is used to modify a field rather than set its value directly # This form field is used to modify a field rather than set its value directly
model_fields[name] = None model_fields[name] = None

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -2,7 +2,7 @@ import Clipboard from 'clipboard';
import { getElements } from './util'; import { getElements } from './util';
export function initClipboard(): void { export function initClipboard(): void {
for (const element of getElements('a.copy-content')) { for (const element of getElements('.copy-content')) {
new Clipboard(element); new Clipboard(element);
} }
} }

View File

@ -88,6 +88,7 @@ const showHideLayout: ShowHideLayout = {
const showHideMap: ShowHideMap = { const showHideMap: ShowHideMap = {
vlangroup_add: 'vlangroup', vlangroup_add: 'vlangroup',
vlangroup_edit: 'vlangroup', vlangroup_edit: 'vlangroup',
vlangroup_bulk_edit: 'vlangroup',
}; };
/** /**

View File

@ -141,9 +141,10 @@ class TableState {
private virtualButton: ButtonState; private virtualButton: ButtonState;
/** /**
* Underlying DOM Table Caption Element. * Instance of ButtonState for the 'show/hide virtual rows' button.
*/ */
private caption: Nullable<HTMLTableCaptionElement> = null; // @ts-expect-error null handling is performed in the constructor
private disconnectedButton: ButtonState;
/** /**
* All table rows in table * All table rows in table
@ -166,9 +167,10 @@ class TableState {
this.table, this.table,
'button.toggle-virtual', 'button.toggle-virtual',
); );
const toggleDisconnectedButton = findFirstAdjacent<HTMLButtonElement>(
const caption = this.table.querySelector('caption'); this.table,
this.caption = caption; 'button.toggle-disconnected',
);
if (toggleEnabledButton === null) { if (toggleEnabledButton === null) {
throw new TableStateError("Table is missing a 'toggle-enabled' button.", table); throw new TableStateError("Table is missing a 'toggle-enabled' button.", table);
@ -182,10 +184,15 @@ class TableState {
throw new TableStateError("Table is missing a 'toggle-virtual' button.", table); throw new TableStateError("Table is missing a 'toggle-virtual' button.", table);
} }
if (toggleDisconnectedButton === null) {
throw new TableStateError("Table is missing a 'toggle-disconnected' button.", table);
}
// Attach event listeners to the buttons elements. // Attach event listeners to the buttons elements.
toggleEnabledButton.addEventListener('click', event => this.handleClick(event, this)); toggleEnabledButton.addEventListener('click', event => this.handleClick(event, this));
toggleDisabledButton.addEventListener('click', event => this.handleClick(event, this)); toggleDisabledButton.addEventListener('click', event => this.handleClick(event, this));
toggleVirtualButton.addEventListener('click', event => this.handleClick(event, this)); toggleVirtualButton.addEventListener('click', event => this.handleClick(event, this));
toggleDisconnectedButton.addEventListener('click', event => this.handleClick(event, this));
// Instantiate ButtonState for each button for state management. // Instantiate ButtonState for each button for state management.
this.enabledButton = new ButtonState( this.enabledButton = new ButtonState(
@ -200,6 +207,10 @@ class TableState {
toggleVirtualButton, toggleVirtualButton,
table.querySelectorAll<HTMLTableRowElement>('tr[data-type="virtual"]'), table.querySelectorAll<HTMLTableRowElement>('tr[data-type="virtual"]'),
); );
this.disconnectedButton = new ButtonState(
toggleDisconnectedButton,
table.querySelectorAll<HTMLTableRowElement>('tr[data-connected="disconnected"]'),
);
} catch (err) { } catch (err) {
if (err instanceof TableStateError) { if (err instanceof TableStateError) {
// This class is useless for tables that don't have toggle buttons. // This class is useless for tables that don't have toggle buttons.
@ -211,52 +222,6 @@ class TableState {
} }
} }
/**
* Get the table caption's text.
*/
private get captionText(): string {
if (this.caption !== null) {
return this.caption.innerText;
}
return '';
}
/**
* Set the table caption's text.
*/
private set captionText(value: string) {
if (this.caption !== null) {
this.caption.innerText = value;
}
}
/**
* Update the table caption's text based on the state of each toggle button.
*/
private toggleCaption(): void {
const showEnabled = this.enabledButton.buttonState === 'show';
const showDisabled = this.disabledButton.buttonState === 'show';
const showVirtual = this.virtualButton.buttonState === 'show';
if (showEnabled && !showDisabled && !showVirtual) {
this.captionText = 'Showing Enabled Interfaces';
} else if (showEnabled && showDisabled && !showVirtual) {
this.captionText = 'Showing Enabled & Disabled Interfaces';
} else if (!showEnabled && showDisabled && !showVirtual) {
this.captionText = 'Showing Disabled Interfaces';
} else if (!showEnabled && !showDisabled && !showVirtual) {
this.captionText = 'Hiding Enabled, Disabled & Virtual Interfaces';
} else if (!showEnabled && !showDisabled && showVirtual) {
this.captionText = 'Showing Virtual Interfaces';
} else if (showEnabled && !showDisabled && showVirtual) {
this.captionText = 'Showing Enabled & Virtual Interfaces';
} else if (showEnabled && showDisabled && showVirtual) {
this.captionText = 'Showing Enabled, Disabled & Virtual Interfaces';
} else {
this.captionText = '';
}
}
/** /**
* When toggle buttons are clicked, reapply visability all rows and * When toggle buttons are clicked, reapply visability all rows and
* pass the event to all button handlers * pass the event to all button handlers
@ -272,7 +237,7 @@ class TableState {
instance.enabledButton.handleClick(event); instance.enabledButton.handleClick(event);
instance.disabledButton.handleClick(event); instance.disabledButton.handleClick(event);
instance.virtualButton.handleClick(event); instance.virtualButton.handleClick(event);
instance.toggleCaption(); instance.disconnectedButton.handleClick(event);
} }
} }

View File

@ -167,6 +167,12 @@ table td > .progress {
} }
} }
.alert {
code {
color: $gray-600;
}
}
span.profile-button .dropdown-menu { span.profile-button .dropdown-menu {
right: 0; right: 0;
left: auto; left: auto;

View File

@ -15,11 +15,6 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-12"> <div class="col col-md-12">
{% if key and not settings.ALLOW_TOKEN_RETRIEVAL %}
<div class="alert alert-danger" role="alert">
<i class="mdi mdi-alert"></i> Tokens cannot be retrieved at a later time. You must <a href="#" class="copy-content" data-clipboard-target="#token_id" title="Copy to clipboard">copy the token value</a> below and store it securely.
</div>
{% endif %}
<div class="card"> <div class="card">
<h5 class="card-header">{% trans "Token" %}</h5> <h5 class="card-header">{% trans "Token" %}</h5>
<div class="card-body"> <div class="card-body">

View File

@ -23,7 +23,15 @@
</div> </div>
</div> </div>
<div class="trace-end"> <div class="trace-end">
{% if path.is_split %} {% if path.is_split and path.get_asymmetric_nodes %}
<h3 class="text-danger">{% trans "Asymmetric Path" %}!</h3>
<p>{% trans "The nodes below have no links and result in an asymmetric path" %}:</p>
<ul class="text-start">
{% for next_node in path.get_asymmetric_nodes %}
<li class="text-muted">{{ next_node|linkify }}</li>
{% endfor %}
</ul>
{% elif path.is_split %}
<h3 class="text-danger">{% trans "Path split" %}!</h3> <h3 class="text-danger">{% trans "Path split" %}!</h3>
<p>{% trans "Select a node below to continue" %}:</p> <p>{% trans "Select a node below to continue" %}:</p>
<ul class="text-start"> <ul class="text-start">

View File

@ -9,5 +9,6 @@
<button type="button" class="dropdown-item toggle-enabled" data-state="show">{% trans "Hide Enabled" %}</button> <button type="button" class="dropdown-item toggle-enabled" data-state="show">{% trans "Hide Enabled" %}</button>
<button type="button" class="dropdown-item toggle-disabled" data-state="show">{% trans "Hide Disabled" %}</button> <button type="button" class="dropdown-item toggle-disabled" data-state="show">{% trans "Hide Disabled" %}</button>
<button type="button" class="dropdown-item toggle-virtual" data-state="show">{% trans "Hide Virtual" %}</button> <button type="button" class="dropdown-item toggle-virtual" data-state="show">{% trans "Hide Virtual" %}</button>
<button type="button" class="dropdown-item toggle-disconnected" data-state="show">{% trans "Hide Disconnected" %}</button>
</ul> </ul>
{% endblock extra_table_controls %} {% endblock extra_table_controls %}

View File

@ -13,7 +13,7 @@
{% block extra_controls %} {% block extra_controls %}
{% if perms.dcim.add_device %} {% if perms.dcim.add_device %}
<a href="{% url 'dcim:device_add' %}?role={{ object.pk }}" class="btn btn-sm btn-primary"> <a href="{% url 'dcim:device_add' %}?platform={{ object.pk }}" class="btn btn-sm btn-primary">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Device" %} <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Device" %}
</a> </a>
{% endif %} {% endif %}

View File

@ -0,0 +1,6 @@
{% comment %}
Include a hidden field of the same name to ensure that unchecked checkboxes
are always included in the submitted form data.
{% endcomment %}
<input type="hidden" name="{{ widget.name }}" value="">
{% include "django/forms/widgets/input.html" %}

View File

@ -8,11 +8,17 @@
{% if perms.extras.run_report %} {% if perms.extras.run_report %}
<div class="row"> <div class="row">
<div class="col"> <div class="col">
{% if not report.is_valid %}
<div class="alert alert-warning">
<i class="mdi mdi-alert"></i>
{% trans "This report is invalid and cannot be run." %}
</div>
{% endif %}
<form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post" class="form-object-edit"> <form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post" class="form-object-edit">
{% csrf_token %} {% csrf_token %}
{% render_form form %} {% render_form form %}
<div class="float-end"> <div class="float-end">
<button type="submit" name="_run" class="btn btn-primary"> <button type="submit" name="_run" class="btn btn-primary"{% if not report.is_valid %} disabled{% endif %}>
{% if report.result %} {% if report.result %}
<i class="mdi mdi-replay"></i> {% trans "Run Again" %} <i class="mdi mdi-replay"></i> {% trans "Run Again" %}
{% else %} {% else %}

View File

@ -68,10 +68,18 @@
</td> </td>
{% else %} {% else %}
<td class="text-muted">{% trans "Never" %}</td> <td class="text-muted">{% trans "Never" %}</td>
<td>{{ ''|placeholder }}</td> <td>
{% if report.is_valid %}
{{ ''|placeholder }}
{% else %}
<span class="badge bg-danger" title="{% trans "Report has no test methods" %}">
{% trans "Invalid" %}
</span>
{% endif %}
</td>
{% endif %} {% endif %}
<td> <td>
{% if perms.extras.run_report %} {% if perms.extras.run_report and report.is_valid %}
<div class="float-end noprint"> <div class="float-end noprint">
<form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post"> <form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
{% csrf_token %} {% csrf_token %}

View File

@ -42,7 +42,7 @@
<div class="alert alert-warning d-flex align-items-center" role="alert"> <div class="alert alert-warning d-flex align-items-center" role="alert">
<i class="mdi mdi-alert"></i> <i class="mdi mdi-alert"></i>
{% blocktrans trimmed with file_path=module.full_path %} {% blocktrans trimmed with file_path=module.full_path %}
Script file at <code>{{ file_path }}</code> could not be loaded. Script file at <code class="mx-1">{{ file_path }}</code> could not be loaded.
{% endblocktrans %} {% endblocktrans %}
</div> </div>
{% else %} {% else %}

View File

@ -67,6 +67,7 @@ Context:
<input type="hidden" name="import_method" value="upload" /> <input type="hidden" name="import_method" value="upload" />
{% render_field form.upload_file %} {% render_field form.upload_file %}
{% render_field form.format %} {% render_field form.format %}
{% render_field form.csv_delimiter %}
<div class="form-group"> <div class="form-group">
<div class="col col-md-12 text-end"> <div class="col col-md-12 text-end">
<button type="submit" name="file_submit" class="btn btn-primary">{% trans "Submit" %}</button> <button type="submit" name="file_submit" class="btn btn-primary">{% trans "Submit" %}</button>
@ -88,6 +89,7 @@ Context:
{% render_field form.data_source %} {% render_field form.data_source %}
{% render_field form.data_file %} {% render_field form.data_file %}
{% render_field form.format %} {% render_field form.format %}
{% render_field form.csv_delimiter %}
<div class="form-group"> <div class="form-group">
<div class="col col-md-12 text-end"> <div class="col col-md-12 text-end">
<button type="submit" name="file_submit" class="btn btn-primary">{% trans "Submit" %}</button> <button type="submit" name="file_submit" class="btn btn-primary">{% trans "Submit" %}</button>

View File

@ -41,6 +41,7 @@
<div class="tab-pane {% if form.initial.vminterface %}active{% endif %}" id="vminterface" role="tabpanel" aria-labeled-by="vminterface_tab"> <div class="tab-pane {% if form.initial.vminterface %}active{% endif %}" id="vminterface" role="tabpanel" aria-labeled-by="vminterface_tab">
{% render_field form.vminterface %} {% render_field form.vminterface %}
</div> </div>
{% render_field form.tags %}
</div> </div>
</div> </div>
</div> </div>

View File

@ -3,7 +3,7 @@ from rest_framework.routers import APIRootView
from circuits.models import Circuit from circuits.models import Circuit
from dcim.models import Device, Rack, Site from dcim.models import Device, Rack, Site
from ipam.models import IPAddress, Prefix, VLAN, VRF from ipam.models import IPAddress, Prefix, VLAN, VRF
from netbox.api.viewsets import NetBoxModelViewSet from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
from tenancy import filtersets from tenancy import filtersets
from tenancy.models import * from tenancy.models import *
from utilities.utils import count_related from utilities.utils import count_related
@ -23,7 +23,7 @@ class TenancyRootView(APIRootView):
# Tenants # Tenants
# #
class TenantGroupViewSet(NetBoxModelViewSet): class TenantGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = TenantGroup.objects.add_related_count( queryset = TenantGroup.objects.add_related_count(
TenantGroup.objects.all(), TenantGroup.objects.all(),
Tenant, Tenant,
@ -58,7 +58,7 @@ class TenantViewSet(NetBoxModelViewSet):
# Contacts # Contacts
# #
class ContactGroupViewSet(NetBoxModelViewSet): class ContactGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = ContactGroup.objects.add_related_count( queryset = ContactGroup.objects.add_related_count(
ContactGroup.objects.all(), ContactGroup.objects.all(),
Contact, Contact,

View File

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

View File

@ -386,7 +386,7 @@ class ContactAssignmentListView(generic.ObjectListView):
filterset = filtersets.ContactAssignmentFilterSet filterset = filtersets.ContactAssignmentFilterSet
filterset_form = forms.ContactAssignmentFilterForm filterset_form = forms.ContactAssignmentFilterForm
table = tables.ContactAssignmentTable table = tables.ContactAssignmentTable
actions = ('export', 'bulk_edit', 'bulk_delete') actions = ('export', 'bulk_edit', 'bulk_delete', 'import')
@register_model_view(ContactAssignment, 'edit') @register_model_view(ContactAssignment, 'edit')

View File

@ -114,6 +114,9 @@ class UserTokenForm(BootstrapMixin, forms.ModelForm):
help_text=_( help_text=_(
'Keys must be at least 40 characters in length. <strong>Be sure to record your key</strong> prior to ' 'Keys must be at least 40 characters in length. <strong>Be sure to record your key</strong> prior to '
'submitting this form, as it may no longer be accessible once the token has been created.' 'submitting this form, as it may no longer be accessible once the token has been created.'
),
widget=forms.TextInput(
attrs={'data-clipboard': 'true'}
) )
) )
allowed_ips = SimpleArrayField( allowed_ips = SimpleArrayField(

View File

@ -52,7 +52,7 @@ class UserTable(NetBoxTable):
model = NetBoxUser model = NetBoxUser
fields = ( fields = (
'pk', 'id', 'username', 'first_name', 'last_name', 'email', 'groups', 'is_active', 'is_staff', 'pk', 'id', 'username', 'first_name', 'last_name', 'email', 'groups', 'is_active', 'is_staff',
'is_superuser', 'is_superuser', 'last_login',
) )
default_columns = ('pk', 'username', 'first_name', 'last_name', 'email', 'is_active') default_columns = ('pk', 'username', 'first_name', 'last_name', 'email', 'is_active')

View File

@ -52,16 +52,17 @@ def post_save_receiver(sender, instance, created, **kwargs):
for field_name, counter_name in get_counters_for_model(sender): for field_name, counter_name in get_counters_for_model(sender):
parent_model = sender._meta.get_field(field_name).related_model parent_model = sender._meta.get_field(field_name).related_model
new_pk = getattr(instance, field_name, None) new_pk = getattr(instance, field_name, None)
old_pk = instance.tracker.get(field_name) if field_name in instance.tracker else None has_old_field = field_name in instance.tracker
old_pk = instance.tracker.get(field_name) if has_old_field else None
# Update the counters on the old and/or new parents as needed # Update the counters on the old and/or new parents as needed
if old_pk is not None: if old_pk is not None:
update_counter(parent_model, old_pk, counter_name, -1) update_counter(parent_model, old_pk, counter_name, -1)
if new_pk is not None and (old_pk or created): if new_pk is not None and (has_old_field or created):
update_counter(parent_model, new_pk, counter_name, 1) update_counter(parent_model, new_pk, counter_name, 1)
def post_delete_receiver(sender, instance, **kwargs): def post_delete_receiver(sender, instance, origin, **kwargs):
""" """
Update counter fields on related objects when a TrackingModelMixin subclass is deleted. Update counter fields on related objects when a TrackingModelMixin subclass is deleted.
""" """
@ -71,7 +72,9 @@ def post_delete_receiver(sender, instance, **kwargs):
# Decrement the parent's counter by one # Decrement the parent's counter by one
if parent_pk is not None: if parent_pk is not None:
update_counter(parent_model, parent_pk, counter_name, -1) # MPTT sends two delete signals for child elements so guard against multiple decrements
if not origin or origin == instance:
update_counter(parent_model, parent_pk, counter_name, -1)
# #

View File

@ -129,6 +129,7 @@ class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form):
headers, records = parse_csv(reader) headers, records = parse_csv(reader)
# Set CSV headers for reference by the model form # Set CSV headers for reference by the model form
headers.pop('id', None)
self._csv_headers = headers self._csv_headers = headers
return records return records

View File

@ -70,22 +70,24 @@ class CSVModelForm(forms.ModelForm):
""" """
ModelForm used for the import of objects in CSV format. ModelForm used for the import of objects in CSV format.
""" """
def __init__(self, *args, headers=None, fields=None, **kwargs): def __init__(self, *args, headers=None, **kwargs):
headers = headers or {} self.headers = headers or {}
fields = fields or []
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Modify the model form to accommodate any customized to_field_name properties # Modify the model form to accommodate any customized to_field_name properties
for field, to_field in headers.items(): for field, to_field in self.headers.items():
if to_field is not None: if to_field is not None:
self.fields[field].to_field_name = to_field self.fields[field].to_field_name = to_field
# Omit any fields not specified (e.g. because the form is being used to def clean(self):
# updated rather than create objects) # Flag any invalid CSV headers
if fields: for header in self.headers:
for field in list(self.fields.keys()): if header not in self.fields:
if field not in fields: raise forms.ValidationError(
del self.fields[field] _("Unrecognized header: {name}").format(name=header)
)
return super().clean()
class FilterForm(BootstrapMixin, forms.Form): class FilterForm(BootstrapMixin, forms.Form):

View File

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

View File

@ -29,6 +29,14 @@
{{ label }} {{ label }}
</label> </label>
</div> </div>
{# Include a copy-to-clipboard button #}
{% elif 'data-clipboard' in field.field.widget.attrs %}
<div class="input-group">
{{ field }}
<button type="button" title="{% trans "Copy to clipboard" %}" class="btn btn-outline-dark border-input copy-content" data-clipboard-target="#{{ field.id_for_label }}">
<i class="mdi mdi-content-copy"></i>
</button>
</div>
{# Default field rendering #} {# Default field rendering #}
{% else %} {% else %}
{{ field }} {{ field }}

View File

@ -1,6 +1,7 @@
from django import template from django import template
from django.http import QueryDict from django.http import QueryDict
from extras.choices import CustomFieldTypeChoices
from utilities.utils import dict_to_querydict from utilities.utils import dict_to_querydict
__all__ = ( __all__ = (
@ -38,6 +39,11 @@ def customfield_value(customfield, value):
customfield: A CustomField instance customfield: A CustomField instance
value: The custom field value applied to an object value: The custom field value applied to an object
""" """
if value:
if customfield.type == CustomFieldTypeChoices.TYPE_SELECT:
value = customfield.get_choice_label(value)
elif customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
value = [customfield.get_choice_label(v) for v in value]
return { return {
'customfield': customfield, 'customfield': customfield,
'value': value, 'value': value,

View File

@ -1,7 +1,11 @@
from django.test import TestCase from django.contrib.contenttypes.models import ContentType
from django.test import override_settings
from django.urls import reverse
from dcim.models import * from dcim.models import *
from utilities.testing.utils import create_test_device from users.models import ObjectPermission
from utilities.testing.base import TestCase
from utilities.testing.utils import create_test_device, create_test_user
class CountersTest(TestCase): class CountersTest(TestCase):
@ -10,7 +14,6 @@ class CountersTest(TestCase):
""" """
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
# Create devices # Create devices
device1 = create_test_device('Device 1') device1 = create_test_device('Device 1')
device2 = create_test_device('Device 2') device2 = create_test_device('Device 2')
@ -36,10 +39,18 @@ class CountersTest(TestCase):
self.assertEqual(device1.interface_count, 3) self.assertEqual(device1.interface_count, 3)
self.assertEqual(device2.interface_count, 3) self.assertEqual(device2.interface_count, 3)
# test saving an existing object - counter should not change
interface1.save() interface1.save()
device1.refresh_from_db() device1.refresh_from_db()
self.assertEqual(device1.interface_count, 3) self.assertEqual(device1.interface_count, 3)
# test save where tracked object FK back pointer is None
vc = VirtualChassis.objects.create(name='Virtual Chassis 1')
device1.virtual_chassis = vc
device1.save()
vc.refresh_from_db()
self.assertEqual(vc.member_count, 1)
def test_interface_count_deletion(self): def test_interface_count_deletion(self):
""" """
When a tracked object (Interface) is deleted the tracking counter should be updated. When a tracked object (Interface) is deleted the tracking counter should be updated.
@ -71,3 +82,25 @@ class CountersTest(TestCase):
device2.refresh_from_db() device2.refresh_from_db()
self.assertEqual(device1.interface_count, 1) self.assertEqual(device1.interface_count, 1)
self.assertEqual(device2.interface_count, 3) self.assertEqual(device2.interface_count, 3)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_mptt_child_delete(self):
device1, device2 = Device.objects.all()
inventory_item1 = InventoryItem.objects.create(device=device1, name='Inventory Item 1')
inventory_item2 = InventoryItem.objects.create(device=device1, name='Inventory Item 2', parent=inventory_item1)
device1.refresh_from_db()
self.assertEqual(device1.inventory_item_count, 2)
# Setup bulk_delete for the inventory items
self.add_permissions('dcim.delete_inventoryitem')
pk_list = device1.inventoryitems.values_list('pk', flat=True)
data = {
'pk': pk_list,
'confirm': True,
'_confirm': True, # Form button
}
# Try POST with model-level permission
self.client.post(reverse("dcim:inventoryitem_bulk_delete"), data)
device1.refresh_from_db()
self.assertEqual(device1.inventory_item_count, 0)

View File

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

View File

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

View File

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

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