12489 merge develop

This commit is contained in:
Arthur 2023-08-16 16:20:36 -07:00
commit 91664524bf
204 changed files with 3155 additions and 1619 deletions

View File

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

View File

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

View File

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

View File

@ -14,12 +14,25 @@
</div> </div>
<h3></h3> <h3></h3>
Some general tips for engaging here on GitHub: ## :information_source: Welcome to the Stadium!
In her book [Working in Public](https://www.amazon.com/Working-Public-Making-Maintenance-Software/dp/0578675862), Nadia Eghbal defines four production models for open source projects, categorized by contributor and user growth: federations, clubs, toys, and stadiums. The NetBox project fits her definition of a stadium very well:
> Stadiums are projects with low contributor growth and high user growth. While they may receive casual contributions, their regular contributor base does not grow proportionately to their users. As a result, they tend to be powered by one or a few developers.
The bulk of NetBox's development is carried out by a handful of core maintainers, with occasional contributions from collaborators in the community. We find the stadium analogy very useful in conveying the roles and obligations of both contributors and users.
If you're a contributor, actively working on the center stage, you have an obligation to produce quality content that will benefit the project as a whole. Conversely, if you're in the audience consuming the work being produced, you have the option of making requests and suggestions, but must also recognize that contributors are under no obligation to act on them.
NetBox users are welcome to participate in either role, on stage or in the crowd. We ask only that you acknowledge the role you've chosen and respect the roles of others.
### General Tips for Working on GitHub
* Register for a free [GitHub account](https://github.com/signup) if you haven't already. * Register for a free [GitHub account](https://github.com/signup) if you haven't already.
* You can use [GitHub Markdown](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax) for formatting text and adding images. * You can use [GitHub Markdown](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax) for formatting text and adding images.
* To help mitigate notification spam, please avoid "bumping" issues with no activity. (To vote an issue up or down, use a :thumbsup: or :thumbsdown: reaction.) * To help mitigate notification spam, please avoid "bumping" issues with no activity. (To vote an issue up or down, use a :thumbsup: or :thumbsdown: reaction.)
* Please avoid pinging members with `@` unless they've previously expressed interest or involvement with that particular issue. * Please avoid pinging members with `@` unless they've previously expressed interest or involvement with that particular issue.
* Familiarize yourself with [this list of discussion anti-patterns](https://github.com/bradfitz/issue-tracker-behaviors) and make every effort to avoid them.
## :bug: Reporting Bugs ## :bug: Reporting Bugs

View File

@ -1,11 +1,10 @@
<div align="center"> <div align="center">
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" /> <img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
<p>The premiere source of truth powering network automation</p>
The premiere source of truth powering network automation <img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" />
<p></p>
</div> </div>
![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master)
NetBox is the leading solution for modeling and documenting modern networks. By NetBox is the leading solution for modeling and documenting modern networks. By
combining the traditional disciplines of IP address management (IPAM) and combining the traditional disciplines of IP address management (IPAM) and
datacenter infrastructure management (DCIM) with powerful APIs and extensions, datacenter infrastructure management (DCIM) with powerful APIs and extensions,
@ -53,10 +52,10 @@ as the cornerstone for network automation in thousands of organizations.
## Project Stats ## Project Stats
<div align="center"> <div align="center">
<a href="https://github.com/netbox-community/netbox/commits"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_timeline.svg" alt="Timeline graph"></a> <a href="https://github.com/netbox-community/netbox/commits"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_timeline.svg" alt="Timeline graph"></a>
<a href="https://github.com/netbox-community/netbox/issues"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_issues.svg" alt="Issues graph"></a> <a href="https://github.com/netbox-community/netbox/issues"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_issues.svg" alt="Issues graph"></a>
<a href="https://github.com/netbox-community/netbox/pulls"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_prs.svg" alt="Pull requests graph"></a> <a href="https://github.com/netbox-community/netbox/pulls"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_prs.svg" alt="Pull requests graph"></a>
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_users.svg" alt="Top contributors"></a> <a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_users.svg" alt="Top contributors"></a>
<br />Stats via <a href="https://repography.com">Repography</a> <br />Stats via <a href="https://repography.com">Repography</a>
</div> </div>
@ -67,10 +66,12 @@ as the cornerstone for network automation in thousands of organizations.
[![NetBox Labs](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/netbox_labs.png)](https://netboxlabs.com) [![NetBox Labs](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/netbox_labs.png)](https://netboxlabs.com)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![DigitalOcean](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/digitalocean.png)](https://try.digitalocean.com/developer-cloud) [![DigitalOcean](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/digitalocean.png)](https://try.digitalocean.com/developer-cloud)
<br />
[![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io)
<br />
[![Equinix Metal](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/equinix.png)](https://metal.equinix.com) [![Equinix Metal](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/equinix.png)](https://metal.equinix.com)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![OneMind Services](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/onemind_services.png)](https://onemindservices.com)
</div> </div>

View File

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

View File

@ -0,0 +1,561 @@
{
"type": "object",
"additionalProperties": false,
"definitions": {
"airflow": {
"type": "string",
"enum": [
"front-to-rear",
"rear-to-front",
"left-to-right",
"right-to-left",
"side-to-rear",
"passive",
"mixed"
]
},
"weight-unit": {
"type": "string",
"enum": [
"kg",
"g",
"lb",
"oz"
]
},
"subdevice-role": {
"type": "string",
"enum": [
"parent",
"child"
]
},
"console-port": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"de-9",
"db-25",
"rj-11",
"rj-12",
"rj-45",
"mini-din-8",
"usb-a",
"usb-b",
"usb-c",
"usb-mini-a",
"usb-mini-b",
"usb-micro-a",
"usb-micro-b",
"usb-micro-ab",
"other"
]
}
}
},
"console-server-port": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"de-9",
"db-25",
"rj-11",
"rj-12",
"rj-45",
"mini-din-8",
"usb-a",
"usb-b",
"usb-c",
"usb-mini-a",
"usb-mini-b",
"usb-micro-a",
"usb-micro-b",
"usb-micro-ab",
"other"
]
}
}
},
"power-port": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"iec-60320-c6",
"iec-60320-c8",
"iec-60320-c14",
"iec-60320-c16",
"iec-60320-c20",
"iec-60320-c22",
"iec-60309-p-n-e-4h",
"iec-60309-p-n-e-6h",
"iec-60309-p-n-e-9h",
"iec-60309-2p-e-4h",
"iec-60309-2p-e-6h",
"iec-60309-2p-e-9h",
"iec-60309-3p-e-4h",
"iec-60309-3p-e-6h",
"iec-60309-3p-e-9h",
"iec-60309-3p-n-e-4h",
"iec-60309-3p-n-e-6h",
"iec-60309-3p-n-e-9h",
"iec-60906-1",
"nbr-14136-10a",
"nbr-14136-20a",
"nema-1-15p",
"nema-5-15p",
"nema-5-20p",
"nema-5-30p",
"nema-5-50p",
"nema-6-15p",
"nema-6-20p",
"nema-6-30p",
"nema-6-50p",
"nema-10-30p",
"nema-10-50p",
"nema-14-20p",
"nema-14-30p",
"nema-14-50p",
"nema-14-60p",
"nema-15-15p",
"nema-15-20p",
"nema-15-30p",
"nema-15-50p",
"nema-15-60p",
"nema-l1-15p",
"nema-l5-15p",
"nema-l5-20p",
"nema-l5-30p",
"nema-l5-50p",
"nema-l6-15p",
"nema-l6-20p",
"nema-l6-30p",
"nema-l6-50p",
"nema-l10-30p",
"nema-l14-20p",
"nema-l14-30p",
"nema-l14-50p",
"nema-l14-60p",
"nema-l15-20p",
"nema-l15-30p",
"nema-l15-50p",
"nema-l15-60p",
"nema-l21-20p",
"nema-l21-30p",
"nema-l22-30p",
"cs6361c",
"cs6365c",
"cs8165c",
"cs8265c",
"cs8365c",
"cs8465c",
"ita-c",
"ita-e",
"ita-f",
"ita-ef",
"ita-g",
"ita-h",
"ita-i",
"ita-j",
"ita-k",
"ita-l",
"ita-m",
"ita-n",
"ita-o",
"usb-a",
"usb-b",
"usb-c",
"usb-mini-a",
"usb-mini-b",
"usb-micro-a",
"usb-micro-b",
"usb-micro-ab",
"usb-3-b",
"usb-3-micro-b",
"dc-terminal",
"saf-d-grid",
"neutrik-powercon-20",
"neutrik-powercon-32",
"neutrik-powercon-true1",
"neutrik-powercon-true1-top",
"ubiquiti-smartpower",
"hardwired",
"other"
]
}
}
},
"power-outlet": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"iec-60320-c5",
"iec-60320-c7",
"iec-60320-c13",
"iec-60320-c15",
"iec-60320-c19",
"iec-60320-c21",
"iec-60309-p-n-e-4h",
"iec-60309-p-n-e-6h",
"iec-60309-p-n-e-9h",
"iec-60309-2p-e-4h",
"iec-60309-2p-e-6h",
"iec-60309-2p-e-9h",
"iec-60309-3p-e-4h",
"iec-60309-3p-e-6h",
"iec-60309-3p-e-9h",
"iec-60309-3p-n-e-4h",
"iec-60309-3p-n-e-6h",
"iec-60309-3p-n-e-9h",
"iec-60906-1",
"nbr-14136-10a",
"nbr-14136-20a",
"nema-1-15r",
"nema-5-15r",
"nema-5-20r",
"nema-5-30r",
"nema-5-50r",
"nema-6-15r",
"nema-6-20r",
"nema-6-30r",
"nema-6-50r",
"nema-10-30r",
"nema-10-50r",
"nema-14-20r",
"nema-14-30r",
"nema-14-50r",
"nema-14-60r",
"nema-15-15r",
"nema-15-20r",
"nema-15-30r",
"nema-15-50r",
"nema-15-60r",
"nema-l1-15r",
"nema-l5-15r",
"nema-l5-20r",
"nema-l5-30r",
"nema-l5-50r",
"nema-l6-15r",
"nema-l6-20r",
"nema-l6-30r",
"nema-l6-50r",
"nema-l10-30r",
"nema-l14-20r",
"nema-l14-30r",
"nema-l14-50r",
"nema-l14-60r",
"nema-l15-20r",
"nema-l15-30r",
"nema-l15-50r",
"nema-l15-60r",
"nema-l21-20r",
"nema-l21-30r",
"nema-l22-30r",
"CS6360C",
"CS6364C",
"CS8164C",
"CS8264C",
"CS8364C",
"CS8464C",
"ita-e",
"ita-f",
"ita-g",
"ita-h",
"ita-i",
"ita-j",
"ita-k",
"ita-l",
"ita-m",
"ita-n",
"ita-o",
"ita-multistandard",
"usb-a",
"usb-micro-b",
"usb-c",
"dc-terminal",
"hdot-cx",
"saf-d-grid",
"neutrik-powercon-20a",
"neutrik-powercon-32a",
"neutrik-powercon-true1",
"neutrik-powercon-true1-top",
"ubiquiti-smartpower",
"hardwired",
"other"
]
},
"feed-leg": {
"type": "string",
"enum": [
"A",
"B",
"C"
]
}
}
},
"interface": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"virtual",
"bridge",
"lag",
"100base-fx",
"100base-lfx",
"100base-tx",
"100base-t1",
"1000base-t",
"2.5gbase-t",
"5gbase-t",
"10gbase-t",
"10gbase-cx4",
"1000base-x-gbic",
"1000base-x-sfp",
"10gbase-x-sfpp",
"10gbase-x-xfp",
"10gbase-x-xenpak",
"10gbase-x-x2",
"25gbase-x-sfp28",
"50gbase-x-sfp56",
"40gbase-x-qsfpp",
"50gbase-x-sfp28",
"100gbase-x-cfp",
"100gbase-x-cfp2",
"200gbase-x-cfp2",
"100gbase-x-cfp4",
"100gbase-x-cxp",
"100gbase-x-cpak",
"100gbase-x-dsfp",
"100gbase-x-sfpdd",
"100gbase-x-qsfp28",
"100gbase-x-qsfpdd",
"200gbase-x-qsfp56",
"200gbase-x-qsfpdd",
"400gbase-x-qsfpdd",
"400gbase-x-osfp",
"400gbase-x-cdfp",
"400gbase-x-cfp8",
"800gbase-x-qsfpdd",
"800gbase-x-osfp",
"1000base-kx",
"10gbase-kr",
"10gbase-kx4",
"25gbase-kr",
"40gbase-kr4",
"50gbase-kr",
"100gbase-kp4",
"100gbase-kr2",
"100gbase-kr4",
"ieee802.11a",
"ieee802.11g",
"ieee802.11n",
"ieee802.11ac",
"ieee802.11ad",
"ieee802.11ax",
"ieee802.11ay",
"ieee802.15.1",
"other-wireless",
"gsm",
"cdma",
"lte",
"sonet-oc3",
"sonet-oc12",
"sonet-oc48",
"sonet-oc192",
"sonet-oc768",
"sonet-oc1920",
"sonet-oc3840",
"1gfc-sfp",
"2gfc-sfp",
"4gfc-sfp",
"8gfc-sfpp",
"16gfc-sfpp",
"32gfc-sfp28",
"64gfc-qsfpp",
"128gfc-qsfp28",
"infiniband-sdr",
"infiniband-ddr",
"infiniband-qdr",
"infiniband-fdr10",
"infiniband-fdr",
"infiniband-edr",
"infiniband-hdr",
"infiniband-ndr",
"infiniband-xdr",
"t1",
"e1",
"t3",
"e3",
"xdsl",
"docsis",
"gpon",
"xg-pon",
"xgs-pon",
"ng-pon2",
"epon",
"10g-epon",
"cisco-stackwise",
"cisco-stackwise-plus",
"cisco-flexstack",
"cisco-flexstack-plus",
"cisco-stackwise-80",
"cisco-stackwise-160",
"cisco-stackwise-320",
"cisco-stackwise-480",
"cisco-stackwise-1t",
"juniper-vcp",
"extreme-summitstack",
"extreme-summitstack-128",
"extreme-summitstack-256",
"extreme-summitstack-512",
"other"
]
},
"poe_mode": {
"type": "string",
"enum": [
"pd",
"pse"
]
},
"poe_type": {
"type": "string",
"enum": [
"type1-ieee802.3af",
"type2-ieee802.3at",
"type3-ieee802.3bt",
"type4-ieee802.3bt",
"passive-24v-2pair",
"passive-24v-4pair",
"passive-48v-2pair",
"passive-48v-4pair"
]
}
}
},
"front-port": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"8p8c",
"8p6c",
"8p4c",
"8p2c",
"6p6c",
"6p4c",
"6p2c",
"4p4c",
"4p2c",
"gg45",
"tera-4p",
"tera-2p",
"tera-1p",
"110-punch",
"bnc",
"f",
"n",
"mrj21",
"fc",
"lc",
"lc-pc",
"lc-upc",
"lc-apc",
"lsh",
"lsh-pc",
"lsh-upc",
"lsh-apc",
"lx5",
"lx5-pc",
"lx5-upc",
"lx5-apc",
"mpo",
"mtrj",
"sc",
"sc-pc",
"sc-upc",
"sc-apc",
"st",
"cs",
"sn",
"sma-905",
"sma-906",
"urm-p2",
"urm-p4",
"urm-p8",
"splice",
"other"
]
}
}
},
"rear-port": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"8p8c",
"8p6c",
"8p4c",
"8p2c",
"6p6c",
"6p4c",
"6p2c",
"4p4c",
"4p2c",
"gg45",
"tera-4p",
"tera-2p",
"tera-1p",
"110-punch",
"bnc",
"f",
"n",
"mrj21",
"fc",
"lc",
"lc-pc",
"lc-upc",
"lc-apc",
"lsh",
"lsh-pc",
"lsh-upc",
"lsh-apc",
"lx5",
"lx5-pc",
"lx5-upc",
"lx5-apc",
"mpo",
"mtrj",
"sc",
"sc-pc",
"sc-upc",
"sc-apc",
"st",
"cs",
"sn",
"sma-905",
"sma-906",
"urm-p2",
"urm-p4",
"urm-p8",
"splice",
"other"
]
}
}
}
}
}

View File

@ -29,6 +29,17 @@ This defines custom content to be displayed on the login page above the login fo
--- ---
## BANNER_MAINTENANCE
!!! tip "Dynamic Configuration Parameter"
!!! note
This parameter was added in NetBox v3.5.
This adds a banner to the top of every page when maintenance mode is enabled. HTML is allowed.
---
## BANNER_TOP ## BANNER_TOP
!!! tip "Dynamic Configuration Parameter" !!! tip "Dynamic Configuration Parameter"
@ -193,3 +204,25 @@ This parameter defines the URL of the repository that will be checked for new Ne
Default: `300` Default: `300`
The maximum execution time of a background task (such as running a custom script), in seconds. The maximum execution time of a background task (such as running a custom script), in seconds.
---
## RQ_RETRY_INTERVAL
!!! note
This parameter was added in NetBox v3.5.
Default: `60`
This parameter controls how frequently a failed job is retried, up to the maximum number of times specified by `RQ_RETRY_MAX`. This must be either an integer specifying the number of seconds to wait between successive attempts, or a list of such values. For example, `[60, 300, 3600]` will retry the task after 1 minute, 5 minutes, and 1 hour.
---
## RQ_RETRY_MAX
!!! note
This parameter was added in NetBox v3.5.
Default: `0` (retries disabled)
The maximum number of times a background task will be retried before being marked as failed.

View File

@ -4,6 +4,14 @@ The configuration parameters listed here control remote authentication for NetBo
--- ---
## REMOTE_AUTH_AUTO_CREATE_GROUPS
Default: `False`
If true, NetBox will automatically create groups specified in the `REMOTE_AUTH_GROUP_HEADER` header if they don't already exist. (Requires `REMOTE_AUTH_ENABLED`.)
---
## REMOTE_AUTH_AUTO_CREATE_USER ## REMOTE_AUTH_AUTO_CREATE_USER
Default: `False` Default: `False`

View File

@ -43,10 +43,22 @@ Follow these instructions to perform a new installation of NetBox in a temporary
Submit a pull request to merge the `feature` branch into the `develop` branch in preparation for its release. Once it has been merged, continue with the section for patch releases below. Submit a pull request to merge the `feature` branch into the `develop` branch in preparation for its release. Once it has been merged, continue with the section for patch releases below.
### Rebuild Demo Data (After Release)
After the release of a new minor version, generate a new demo data snapshot compatible with the new release. See the [`netbox-demo-data`](https://github.com/netbox-community/netbox-demo-data) repository for instructions.
--- ---
## Patch Releases ## Patch Releases
### Notify netbox-docker Project of Any Relevant Changes
Notify the [`netbox-docker`](https://github.com/netbox-community/netbox-docker) maintainers (in **#netbox-docker**) of any changes that may be relevant to their build process, including:
* Significant changes to `upgrade.sh`
* Increases in minimum versions for service dependencies (PostgreSQL, Redis, etc.)
* Any changes to the reference installation
### Update Requirements ### Update Requirements
Before each release, update each of NetBox's Python dependencies to its most recent stable version. These are defined in `requirements.txt`, which is updated from `base_requirements.txt` using `pip`. To do this: Before each release, update each of NetBox's Python dependencies to its most recent stable version. These are defined in `requirements.txt`, which is updated from `base_requirements.txt` using `pip`. To do this:
@ -58,6 +70,16 @@ Before each release, update each of NetBox's Python dependencies to its most rec
In cases where upgrading a dependency to its most recent release is breaking, it should be constrained to its current minor version in `base_requirements.txt` with an explanatory comment and revisited for the next major NetBox release (see the [Address Constrained Dependencies](#address-constrained-dependencies) section above). In cases where upgrading a dependency to its most recent release is breaking, it should be constrained to its current minor version in `base_requirements.txt` with an explanatory comment and revisited for the next major NetBox release (see the [Address Constrained Dependencies](#address-constrained-dependencies) section above).
### Rebuild the Device Type Definition Schema
Run the following command to update the device type definition validation schema:
```nohighlight
./manage.py buildschema --write
```
This will automatically update the schema file at `contrib/generated_schema.json`.
### Update Version and Changelog ### Update Version and Changelog
* Update the `VERSION` constant in `settings.py` to the new release version. * Update the `VERSION` constant in `settings.py` to the new release version.

View File

@ -38,7 +38,7 @@ An example hierarchy might look like this:
* 100.64.16.1/24 (address) * 100.64.16.1/24 (address)
* 100.64.16.2/24 (address) * 100.64.16.2/24 (address)
* 100.64.16.3/24 (address) * 100.64.16.3/24 (address)
* 100.64.16.9/24 (prefix) * 100.64.19.0/24 (prefix)
* 100.64.32.0/20 (prefix) * 100.64.32.0/20 (prefix)
* 100.64.32.1/24 (address) * 100.64.32.1/24 (address)
* 100.64.32.10-99/24 (range) * 100.64.32.10-99/24 (range)

View File

@ -55,6 +55,9 @@ Within the shell, enter the following commands to create the database and user (
CREATE DATABASE netbox; CREATE DATABASE netbox;
CREATE USER netbox WITH PASSWORD 'J5brHrAXFLQSif0K'; CREATE USER netbox WITH PASSWORD 'J5brHrAXFLQSif0K';
ALTER DATABASE netbox OWNER TO netbox; ALTER DATABASE netbox OWNER TO netbox;
-- the next two commands are needed on PostgreSQL 15 and later
\connect netbox;
GRANT CREATE ON SCHEMA public TO netbox;
``` ```
!!! danger "Use a strong password" !!! danger "Use a strong password"

View File

@ -100,6 +100,8 @@ Create a system user account named `netbox`. We'll configure the WSGI and HTTP s
``` ```
sudo adduser --system --group netbox sudo adduser --system --group netbox
sudo chown --recursive netbox /opt/netbox/netbox/media/ sudo chown --recursive netbox /opt/netbox/netbox/media/
sudo chown --recursive netbox /opt/netbox/netbox/reports/
sudo chown --recursive netbox /opt/netbox/netbox/scripts/
``` ```
=== "CentOS" === "CentOS"
@ -108,6 +110,8 @@ Create a system user account named `netbox`. We'll configure the WSGI and HTTP s
sudo groupadd --system netbox sudo groupadd --system netbox
sudo adduser --system -g netbox netbox sudo adduser --system -g netbox netbox
sudo chown --recursive netbox /opt/netbox/netbox/media/ sudo chown --recursive netbox /opt/netbox/netbox/media/
sudo chown --recursive netbox /opt/netbox/netbox/reports/
sudo chown --recursive netbox /opt/netbox/netbox/scripts/
``` ```
## Configuration ## Configuration

View File

@ -48,36 +48,40 @@ Download the [latest stable release](https://github.com/netbox-community/netbox/
Download and extract the latest version: Download and extract the latest version:
```no-highlight ```no-highlight
wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz # Set $NEWVER to the NetBox version being installed
sudo tar -xzf vX.Y.Z.tar.gz -C /opt NEWVER=3.5.0
sudo ln -sfn /opt/netbox-X.Y.Z/ /opt/netbox wget https://github.com/netbox-community/netbox/archive/v$NEWVER.tar.gz
sudo tar -xzf v$NEWVER.tar.gz -C /opt
sudo ln -sfn /opt/netbox-$NEWVER/ /opt/netbox
``` ```
Copy `local_requirements.txt`, `configuration.py`, and `ldap_config.py` (if present) from the current installation to the new version: Copy `local_requirements.txt`, `configuration.py`, and `ldap_config.py` (if present) from the current installation to the new version:
```no-highlight ```no-highlight
sudo cp /opt/netbox-X.Y.Z/local_requirements.txt /opt/netbox/ # Set $OLDVER to the NetBox version currently installed
sudo cp /opt/netbox-X.Y.Z/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/ OLDVER=3.4.9
sudo cp /opt/netbox-X.Y.Z/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/ sudo cp /opt/netbox-$OLDVER/local_requirements.txt /opt/netbox/
sudo cp /opt/netbox-$OLDVER/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/
sudo cp /opt/netbox-$OLDVER/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/
``` ```
Be sure to replicate your uploaded media as well. (The exact action necessary will depend on where you choose to store your media, but in general moving or copying the media directory will suffice.) Be sure to replicate your uploaded media as well. (The exact action necessary will depend on where you choose to store your media, but in general moving or copying the media directory will suffice.)
```no-highlight ```no-highlight
sudo cp -pr /opt/netbox-X.Y.Z/netbox/media/ /opt/netbox/netbox/ sudo cp -pr /opt/netbox-$OLDVER/netbox/media/ /opt/netbox/netbox/
``` ```
Also make sure to copy or link any custom scripts and reports that you've made. Note that if these are stored outside the project root, you will not need to copy them. (Check the `SCRIPTS_ROOT` and `REPORTS_ROOT` parameters in the configuration file above if you're unsure.) Also make sure to copy or link any custom scripts and reports that you've made. Note that if these are stored outside the project root, you will not need to copy them. (Check the `SCRIPTS_ROOT` and `REPORTS_ROOT` parameters in the configuration file above if you're unsure.)
```no-highlight ```no-highlight
sudo cp -r /opt/netbox-X.Y.Z/netbox/scripts /opt/netbox/netbox/ sudo cp -r /opt/netbox-$OLDVER/netbox/scripts /opt/netbox/netbox/
sudo cp -r /opt/netbox-X.Y.Z/netbox/reports /opt/netbox/netbox/ sudo cp -r /opt/netbox-$OLDVER/netbox/reports /opt/netbox/netbox/
``` ```
If you followed the original installation guide to set up gunicorn, be sure to copy its configuration as well: If you followed the original installation guide to set up gunicorn, be sure to copy its configuration as well:
```no-highlight ```no-highlight
sudo cp /opt/netbox-X.Y.Z/gunicorn.py /opt/netbox/ sudo cp /opt/netbox-$OLDVER/gunicorn.py /opt/netbox/
``` ```
### Option B: Clone the Git Repository ### Option B: Clone the Git Repository

View File

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

View File

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

View File

@ -19,6 +19,9 @@ class MyModel(models.Model):
Every model includes by default a numeric primary key. This value is generated automatically by the database, and can be referenced as `pk` or `id`. Every model includes by default a numeric primary key. This value is generated automatically by the database, and can be referenced as `pk` or `id`.
!!! note
Model names should adhere to [PEP8](https://www.python.org/dev/peps/pep-0008/#class-names) standards and be CapWords (no underscores). Using underscores in model names will result in problems with permissions.
## Enabling NetBox Features ## Enabling NetBox Features
Plugin models can leverage certain NetBox features by inheriting from NetBox's `NetBoxModel` class. This class extends the plugin model to enable features unique to NetBox, including: Plugin models can leverage certain NetBox features by inheriting from NetBox's `NetBoxModel` class. This class extends the plugin model to enable features unique to NetBox, including:

View File

@ -1,9 +1,166 @@
# NetBox v3.5 # NetBox v3.5
## v3.5.2 (FUTURE) ## v3.5.9 (FUTURE)
---
## v3.5.8 (2023-08-15)
### Enhancements ### Enhancements
* [#10030](https://github.com/netbox-community/netbox/issues/10030) - Ship a validation schema for the device type library with each release
* [#11675](https://github.com/netbox-community/netbox/issues/11675) - Add support for specifying import/export route targets during VRF bulk import
* [#11922](https://github.com/netbox-community/netbox/issues/11922) - Automatically populate any VDC assignments from the parent when adding a child interface via the UI
* [#12889](https://github.com/netbox-community/netbox/issues/12889) - Add 400GE CFP2 interface type
* [#13033](https://github.com/netbox-community/netbox/issues/13033) - Add human-friendly speed column to interfaces table
* [#13151](https://github.com/netbox-community/netbox/issues/13151) - Add "assigned" filter for IP addresses
* [#13368](https://github.com/netbox-community/netbox/issues/13368) - List installed plugins on the server error report page
* [#13442](https://github.com/netbox-community/netbox/issues/13442) - Add 200 and 400 Gbps speeds to dropdown choices on interface form
### Bug Fixes
* [#11578](https://github.com/netbox-community/netbox/issues/11578) - Fix schema definition for available IP & VLAN REST API endpoints
* [#12639](https://github.com/netbox-community/netbox/issues/12639) - Raise validation error for invalid alphanumeric ranges when creating objects
* [#12665](https://github.com/netbox-community/netbox/issues/12665) - Avoid escaping semicolons when rendering custom links
* [#12750](https://github.com/netbox-community/netbox/issues/12750) - Automatically delete an AutoSyncRecord when its object is deleted
* [#13343](https://github.com/netbox-community/netbox/issues/13343) - Fix filtering of circuits under provider network view
* [#13369](https://github.com/netbox-community/netbox/issues/13369) - Fix job termination status for failed reports
* [#13414](https://github.com/netbox-community/netbox/issues/13414) - Fix support for "hide-if-unset" custom fields on bulk import forms
* [#13446](https://github.com/netbox-community/netbox/issues/13446) - Don't disable bulk edit/delete buttons after deselecting "select all" checkbox
* [#13451](https://github.com/netbox-community/netbox/issues/13451) - Disable table ordering for custom link columns
---
## v3.5.7 (2023-07-28)
### Enhancements
* [#11803](https://github.com/netbox-community/netbox/issues/11803) - Move non-rack devices list to a separate tab under the rack view
* [#12625](https://github.com/netbox-community/netbox/issues/12625) - Mask sensitive parameters when viewing a configured data source
* [#13009](https://github.com/netbox-community/netbox/issues/13009) - Add IEC 10609-1 and NBR 14136 power port & outlet types
* [#13097](https://github.com/netbox-community/netbox/issues/13097) - Implement a faster initial poll for report & script results
* [#13234](https://github.com/netbox-community/netbox/issues/13234) - Add 100GBASE-X-DSFP and 100GBASE-X-SFPDD interface types
### Bug Fixes
* [#13051](https://github.com/netbox-community/netbox/issues/13051) - Fix Markdown support for table cell alignment
* [#13167](https://github.com/netbox-community/netbox/issues/13167) - Fix missing script results when fetched via REST API
* [#13233](https://github.com/netbox-community/netbox/issues/13233) - Remove extraneous VLAN group field from bulk edit form for interfaces
* [#13237](https://github.com/netbox-community/netbox/issues/13237) - Permit unauthenticated access to content types REST API endpoint when `LOGIN_REQUIRED` is false
* [#13285](https://github.com/netbox-community/netbox/issues/13285) - Fix exception when importing device type missing rack unit height value
---
## v3.5.6 (2023-07-10)
### Bug Fixes
* [#13061](https://github.com/netbox-community/netbox/issues/13061) - Fix display of last result for scripts & reports with a custom name defined
* [#13096](https://github.com/netbox-community/netbox/issues/13096) - Hide scheduling fields for all scripts with scheduling disabled
* [#13105](https://github.com/netbox-community/netbox/issues/13105) - Fix exception when attempting to allocate next available IP address from prefix marked as utilized
* [#13116](https://github.com/netbox-community/netbox/issues/13116) - Catch ProgrammingError exception when starting NetBox without pre-populated content types
---
## v3.5.5 (2023-07-06)
### Enhancements
* [#11738](https://github.com/netbox-community/netbox/issues/11738) - Annotate VLAN group utilization
* [#12499](https://github.com/netbox-community/netbox/issues/12499) - Add "copy to clipboard" buttons in UI for IP addresses
* [#12945](https://github.com/netbox-community/netbox/issues/12945) - Add 100GE QSFP-DD interface type
* [#12955](https://github.com/netbox-community/netbox/issues/12955) - Include additional contact details on contact assignments table
* [#13065](https://github.com/netbox-community/netbox/issues/13065) - Associate contact assignments with their objects in the change log
### Bug Fixes
* [#11335](https://github.com/netbox-community/netbox/issues/11335) - Exclude stale content types when retrieving changelog records
* [#12533](https://github.com/netbox-community/netbox/issues/12533) - Fix REST API validation of null values for several interface attributes
* [#12579](https://github.com/netbox-community/netbox/issues/12579) - Fix exception when clicking "create and add another" to add a cable
* [#12617](https://github.com/netbox-community/netbox/issues/12617) - Populate prechange snapshot on parent object when assigning/removing primary IP address
* [#12760](https://github.com/netbox-community/netbox/issues/12760) - Avoid rendering partial HTMX responses when restoring browser tabs
* [#12842](https://github.com/netbox-community/netbox/issues/12842) - Improve handling of exceptions when loading reports
* [#12849](https://github.com/netbox-community/netbox/issues/12849) - Fix LDAP group permissions assignment for API clients
* [#12951](https://github.com/netbox-community/netbox/issues/12951) - Display consistent parent information for each termination under cable view
* [#12953](https://github.com/netbox-community/netbox/issues/12953) - Fix designation of primary IP addresses during interface assignment
* [#12960](https://github.com/netbox-community/netbox/issues/12960) - Fix OpenAPI schema for various choice fields
* [#12961](https://github.com/netbox-community/netbox/issues/12961) - Set correct return URL for object contacts tabs
* [#12966](https://github.com/netbox-community/netbox/issues/12966) - Avoid catching database exceptions when maintenance mode is disabled
* [#12975](https://github.com/netbox-community/netbox/issues/12975) - Correct URL for VirtualDeviceContext API serializer
* [#12977](https://github.com/netbox-community/netbox/issues/12977) - Fix URL parameters for object count dashboard widgets
* [#12983](https://github.com/netbox-community/netbox/issues/12983) - Avoid erroneously clearing many-to-many assignments during bulk edit
* [#12989](https://github.com/netbox-community/netbox/issues/12989) - Fix bulk import of tags for device & module types
* [#13011](https://github.com/netbox-community/netbox/issues/13011) - Do not escape commas when rendering custom links
* [#13047](https://github.com/netbox-community/netbox/issues/13047) - Correct ASN count under ASN ranges list
* [#13056](https://github.com/netbox-community/netbox/issues/13056) - Add `config_template` field to device API serializer
* [#13092](https://github.com/netbox-community/netbox/issues/13092) - Allow nullifying power port max & allocated draw values during bulk edit
* [#13100](https://github.com/netbox-community/netbox/issues/13100) - Fix ValueError exception when searching for virtual device context for non-numeric values
---
## v3.5.4 (2023-06-20)
### Enhancements
* [#12828](https://github.com/netbox-community/netbox/issues/12828) - Define colors for staged change action choices
* [#12847](https://github.com/netbox-community/netbox/issues/12847) - Include "add" button on all device & virtual machine component list views
* [#12862](https://github.com/netbox-community/netbox/issues/12862) - Add menu navigation button to add wireless links directly
* [#12865](https://github.com/netbox-community/netbox/issues/12865) - Add "add" buttons for reports & scripts to navigation menu
### Bug Fixes
* [#12474](https://github.com/netbox-community/netbox/issues/12474) - Update cable terminations when assigning a location to a new site
* [#12622](https://github.com/netbox-community/netbox/issues/12622) - Permit the assignment of non-site VLANs to prefixes assigned to a site
* [#12682](https://github.com/netbox-community/netbox/issues/12682) - Correct OpenAPI schema for connected device API endpoint
* [#12687](https://github.com/netbox-community/netbox/issues/12687) - Allow the assignment of all /31 IP addresses to interfaces
* [#12818](https://github.com/netbox-community/netbox/issues/12818) - Fix permissions evaluation when queuing a data sync job
* [#12822](https://github.com/netbox-community/netbox/issues/12822) - Fix encoding of whitespace in custom link URLs
* [#12838](https://github.com/netbox-community/netbox/issues/12838) - Correct rounding of rack power utilization values
* [#12845](https://github.com/netbox-community/netbox/issues/12845) - Fix pagination of objects for related IP addresses table
* [#12850](https://github.com/netbox-community/netbox/issues/12850) - Fix table configuration modal for the contact assignments list
* [#12885](https://github.com/netbox-community/netbox/issues/12885) - Permit mounting of devices in rack unit 100
* [#12914](https://github.com/netbox-community/netbox/issues/12914) - Clear stored ordering from user config when cleared by request
---
## v3.5.3 (2023-06-02)
### Enhancements
* [#9876](https://github.com/netbox-community/netbox/issues/9876) - Improve support for matching tags in conditional rules
* [#12015](https://github.com/netbox-community/netbox/issues/12015) - Add device type & role filters for device components
* [#12470](https://github.com/netbox-community/netbox/issues/12470) - Collapse context data by default when viewing a rendered device configuration
* [#12562](https://github.com/netbox-community/netbox/issues/12562) - Record client IP address when logging authentication failures
* [#12597](https://github.com/netbox-community/netbox/issues/12597) - Add an option to hide custom fields only if unset
* [#12599](https://github.com/netbox-community/netbox/issues/12599) - Apply filter parameters to links in object count dashboard widgets
### Bug Fixes
* [#7503](https://github.com/netbox-community/netbox/issues/7503) - Improve rack space validation when creating multiple devices via REST API
* [#11539](https://github.com/netbox-community/netbox/issues/11539) - Fix exception when applying "empty" filter lookup with invalid value
* [#11934](https://github.com/netbox-community/netbox/issues/11934) - Prevent reassignment of an IP address designated as primary for its parent object
* [#12538](https://github.com/netbox-community/netbox/issues/12538) - Redirect user to originating view after editing/deleting an image attachment
* [#12627](https://github.com/netbox-community/netbox/issues/12627) - Restore hover preview for embedded image attachment tables
* [#12694](https://github.com/netbox-community/netbox/issues/12694) - Strip leading & trailing whitespace from custom link URL & text
* [#12702](https://github.com/netbox-community/netbox/issues/12702) - Fix sizing of rear port selection widget on front port template creation form
* [#12715](https://github.com/netbox-community/netbox/issues/12715) - Use contact assignments table to display the contacts assigned to an object
* [#12730](https://github.com/netbox-community/netbox/issues/12730) - Fix extraneous contacts listed in object contact assignments view
* [#12742](https://github.com/netbox-community/netbox/issues/12742) - Object counts dashboard widget should support URL-compatible query filters
* [#12762](https://github.com/netbox-community/netbox/issues/12762) - Fix GraphiQL UI by reverting graphene-django to earlier version
* [#12745](https://github.com/netbox-community/netbox/issues/12745) - Escape display text in API-backed selection widgets
* [#12779](https://github.com/netbox-community/netbox/issues/12779) - Correct arithmetic for converting inches to meters
---
## v3.5.2 (2023-05-22)
### Enhancements
* [#7671](https://github.com/netbox-community/netbox/issues/7671) - Introduce `REMOTE_AUTH_AUTO_CREATE_GROUPS` config parameter to enable the automatic creation of new groups when remote authentication is in use
* [#9068](https://github.com/netbox-community/netbox/issues/9068) - Disallow the assignment of network/broadcast IP addresses to interfaces
* [#11017](https://github.com/netbox-community/netbox/issues/11017) - Increase the maximum values for allocated and maximum power draws
* [#11233](https://github.com/netbox-community/netbox/issues/11233) - Intercept and cleanly report errors upon attempted database writes when maintenance mode is enabled
* [#11599](https://github.com/netbox-community/netbox/issues/11599) - Move contacts panels to separate tabs under object views
* [#11670](https://github.com/netbox-community/netbox/issues/11670) - Enable setting device type & module type weight via bulk import * [#11670](https://github.com/netbox-community/netbox/issues/11670) - Enable setting device type & module type weight via bulk import
* [#11900](https://github.com/netbox-community/netbox/issues/11900) - Add an outline to the reservation markers on rack elevations * [#11900](https://github.com/netbox-community/netbox/issues/11900) - Add an outline to the reservation markers on rack elevations
* [#12131](https://github.com/netbox-community/netbox/issues/12131) - Show custom field description as an icon tooltip under object views * [#12131](https://github.com/netbox-community/netbox/issues/12131) - Show custom field description as an icon tooltip under object views
@ -11,11 +168,23 @@
* [#12233](https://github.com/netbox-community/netbox/issues/12233) - Move related IP addresses table to a separate tab * [#12233](https://github.com/netbox-community/netbox/issues/12233) - Move related IP addresses table to a separate tab
* [#12286](https://github.com/netbox-community/netbox/issues/12286) - Show height and total weight under device view * [#12286](https://github.com/netbox-community/netbox/issues/12286) - Show height and total weight under device view
* [#12323](https://github.com/netbox-community/netbox/issues/12323) - Add 100GE CXP interface type * [#12323](https://github.com/netbox-community/netbox/issues/12323) - Add 100GE CXP interface type
* [#12327](https://github.com/netbox-community/netbox/issues/12327) - Introduce the ability to automatically retry failed background jobs
* [#12498](https://github.com/netbox-community/netbox/issues/12498) - Hide map button if `MAPS_URL` is empty * [#12498](https://github.com/netbox-community/netbox/issues/12498) - Hide map button if `MAPS_URL` is empty
* [#12548](https://github.com/netbox-community/netbox/issues/12548) - Optimize REST API performance when retrieving interfaces with L2VPN assignments
* [#12554](https://github.com/netbox-community/netbox/issues/12554) - Allow customization or disabling of the maintenance mode banner
* [#12605](https://github.com/netbox-community/netbox/issues/12605) - Add LX.5 port types
* [#12629](https://github.com/netbox-community/netbox/issues/12629) - Add 400GE CDFP and CFP8 interface types
* [#12678](https://github.com/netbox-community/netbox/issues/12678) - Add 200GE QSFP-DD interface type
### Bug Fixes ### Bug Fixes
* [#10686](https://github.com/netbox-community/netbox/issues/10686) - Enable specifying termination object by virtual chassis master when importing cables
* [#11619](https://github.com/netbox-community/netbox/issues/11619) - Enable assigning VLANs without a site to interfaces during bulk edit
* [#12468](https://github.com/netbox-community/netbox/issues/12468) - Custom field names should not permit double underscores
* [#12550](https://github.com/netbox-community/netbox/issues/12550) - Fix rear port selection widget under front port creation form * [#12550](https://github.com/netbox-community/netbox/issues/12550) - Fix rear port selection widget under front port creation form
* [#12570](https://github.com/netbox-community/netbox/issues/12570) - Disable ordering of synchronized object tables by the "synced" attribute
* [#12594](https://github.com/netbox-community/netbox/issues/12594) - Enable selecting config context as object type in object counts dashboard widget
* [#12642](https://github.com/netbox-community/netbox/issues/12642) - Fix bulk tenant assignment via cluster import form
--- ---

View File

@ -1,10 +1,10 @@
from django.contrib import messages from django.contrib import messages
from django.db import transaction from django.db import transaction
from django.db.models import Q
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from dcim.views import PathTraceView from dcim.views import PathTraceView
from netbox.views import generic from netbox.views import generic
from tenancy.views import ObjectContactsView
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.utils import count_related from utilities.utils import count_related
from utilities.views import register_model_view from utilities.views import register_model_view
@ -73,6 +73,11 @@ class ProviderBulkDeleteView(generic.BulkDeleteView):
table = tables.ProviderTable table = tables.ProviderTable
@register_model_view(Provider, 'contacts')
class ProviderContactsView(ObjectContactsView):
queryset = Provider.objects.all()
# #
# ProviderAccounts # ProviderAccounts
# #
@ -134,6 +139,11 @@ class ProviderAccountBulkDeleteView(generic.BulkDeleteView):
table = tables.ProviderAccountTable table = tables.ProviderAccountTable
@register_model_view(ProviderAccount, 'contacts')
class ProviderAccountContactsView(ObjectContactsView):
queryset = ProviderAccount.objects.all()
# #
# Provider networks # Provider networks
# #
@ -153,7 +163,7 @@ class ProviderNetworkView(generic.ObjectView):
related_models = ( related_models = (
( (
Circuit.objects.restrict(request.user, 'view').filter(terminations__provider_network=instance), Circuit.objects.restrict(request.user, 'view').filter(terminations__provider_network=instance),
'providernetwork_id', 'provider_network_id',
), ),
) )
@ -389,6 +399,11 @@ class CircuitSwapTerminations(generic.ObjectEditView):
}) })
@register_model_view(Circuit, 'contacts')
class CircuitContactsView(ObjectContactsView):
queryset = Circuit.objects.all()
# #
# Circuit terminations # Circuit terminations
# #

View File

@ -1,5 +1,6 @@
import re import re
import typing import typing
from collections import OrderedDict
from drf_spectacular.extensions import OpenApiSerializerFieldExtension from drf_spectacular.extensions import OpenApiSerializerFieldExtension
from drf_spectacular.openapi import AutoSchema from drf_spectacular.openapi import AutoSchema
@ -28,14 +29,19 @@ class ChoiceFieldFix(OpenApiSerializerFieldExtension):
target_class = 'netbox.api.fields.ChoiceField' target_class = 'netbox.api.fields.ChoiceField'
def map_serializer_field(self, auto_schema, direction): def map_serializer_field(self, auto_schema, direction):
build_cf = build_choice_field(self.target)
if direction == 'request': if direction == 'request':
return build_choice_field(self.target) return build_cf
elif direction == "response": elif direction == "response":
value = build_cf
label = {**build_basic_type(OpenApiTypes.STR), "enum": list(OrderedDict.fromkeys(self.target.choices.values()))}
return build_object_type( return build_object_type(
properties={ properties={
"value": build_basic_type(OpenApiTypes.STR), "value": value,
"label": build_basic_type(OpenApiTypes.STR), "label": label
} }
) )

View File

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

View File

@ -41,6 +41,7 @@ def register_backend(name):
class DataBackend: class DataBackend:
parameters = {} parameters = {}
sensitive_parameters = []
def __init__(self, url, **kwargs): def __init__(self, url, **kwargs):
self.url = url self.url = url
@ -86,6 +87,7 @@ class GitBackend(DataBackend):
widget=forms.TextInput(attrs={'class': 'form-control'}) widget=forms.TextInput(attrs={'class': 'form-control'})
) )
} }
sensitive_parameters = ['password']
@contextmanager @contextmanager
def fetch(self): def fetch(self):
@ -101,12 +103,13 @@ class GitBackend(DataBackend):
} }
if self.url_scheme in ('http', 'https'): if self.url_scheme in ('http', 'https'):
clone_args.update( if self.params.get('username'):
{ clone_args.update(
"username": self.params.get('username'), {
"password": self.params.get('password'), "username": self.params.get('username'),
} "password": self.params.get('password'),
) }
)
if settings.HTTP_PROXIES and self.url_scheme in ('http', 'https'): if settings.HTTP_PROXIES and self.url_scheme in ('http', 'https'):
if proxy := settings.HTTP_PROXIES.get(self.url_scheme): if proxy := settings.HTTP_PROXIES.get(self.url_scheme):
@ -135,6 +138,7 @@ class S3Backend(DataBackend):
widget=forms.TextInput(attrs={'class': 'form-control'}) widget=forms.TextInput(attrs={'class': 'form-control'})
), ),
} }
sensitive_parameters = ['aws_secret_access_key']
REGION_REGEX = r's3\.([a-z0-9-]+)\.amazonaws\.com' REGION_REGEX = r's3\.([a-z0-9-]+)\.amazonaws\.com'

View File

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

View File

@ -698,7 +698,8 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip', 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated', 'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'config_template',
'created', 'last_updated',
] ]
@extend_schema_field(serializers.JSONField(allow_null=True)) @extend_schema_field(serializers.JSONField(allow_null=True))
@ -707,7 +708,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
class VirtualDeviceContextSerializer(NetBoxModelSerializer): class VirtualDeviceContextSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualdevicecontext-detail')
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
tenant = NestedTenantSerializer(required=False, allow_null=True, default=None) tenant = NestedTenantSerializer(required=False, allow_null=True, default=None)
primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=True) primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=True)
@ -880,12 +881,12 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
parent = NestedInterfaceSerializer(required=False, allow_null=True) parent = NestedInterfaceSerializer(required=False, allow_null=True)
bridge = NestedInterfaceSerializer(required=False, allow_null=True) bridge = NestedInterfaceSerializer(required=False, allow_null=True)
lag = NestedInterfaceSerializer(required=False, allow_null=True) lag = NestedInterfaceSerializer(required=False, allow_null=True)
mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True, allow_null=True) mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True)
duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True, allow_null=True) duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True, allow_null=True)
rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True, allow_null=True) rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True)
rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True, allow_null=True) rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True)
poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True, allow_null=True) poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True)
poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True, allow_null=True) poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True)
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
tagged_vlans = SerializedPKRelatedField( tagged_vlans = SerializedPKRelatedField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
@ -907,9 +908,10 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
mac_address = serializers.CharField( mac_address = serializers.CharField(
required=False, required=False,
default=None, default=None,
allow_blank=True,
allow_null=True allow_null=True
) )
wwn = serializers.CharField(required=False, default=None) wwn = serializers.CharField(required=False, default=None, allow_blank=True, allow_null=True)
class Meta: class Meta:
model = Interface model = Interface

View File

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

View File

@ -318,6 +318,10 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_IEC_3PNE4H = 'iec-60309-3p-n-e-4h' TYPE_IEC_3PNE4H = 'iec-60309-3p-n-e-4h'
TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h' TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h'
TYPE_IEC_3PNE9H = 'iec-60309-3p-n-e-9h' TYPE_IEC_3PNE9H = 'iec-60309-3p-n-e-9h'
# IEC 60906-1
TYPE_IEC_60906_1 = 'iec-60906-1'
TYPE_NBR_14136_10A = 'nbr-14136-10a'
TYPE_NBR_14136_20A = 'nbr-14136-20a'
# NEMA non-locking # NEMA non-locking
TYPE_NEMA_115P = 'nema-1-15p' TYPE_NEMA_115P = 'nema-1-15p'
TYPE_NEMA_515P = 'nema-5-15p' TYPE_NEMA_515P = 'nema-5-15p'
@ -429,6 +433,11 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_IEC_3PNE6H, '3P+N+E 6H'), (TYPE_IEC_3PNE6H, '3P+N+E 6H'),
(TYPE_IEC_3PNE9H, '3P+N+E 9H'), (TYPE_IEC_3PNE9H, '3P+N+E 9H'),
)), )),
('IEC 60906-1', (
(TYPE_IEC_60906_1, 'IEC 60906-1'),
(TYPE_NBR_14136_10A, '2P+T 10A (NBR 14136)'),
(TYPE_NBR_14136_20A, '2P+T 20A (NBR 14136)'),
)),
('NEMA (Non-locking)', ( ('NEMA (Non-locking)', (
(TYPE_NEMA_115P, 'NEMA 1-15P'), (TYPE_NEMA_115P, 'NEMA 1-15P'),
(TYPE_NEMA_515P, 'NEMA 5-15P'), (TYPE_NEMA_515P, 'NEMA 5-15P'),
@ -553,6 +562,10 @@ class PowerOutletTypeChoices(ChoiceSet):
TYPE_IEC_3PNE4H = 'iec-60309-3p-n-e-4h' TYPE_IEC_3PNE4H = 'iec-60309-3p-n-e-4h'
TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h' TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h'
TYPE_IEC_3PNE9H = 'iec-60309-3p-n-e-9h' TYPE_IEC_3PNE9H = 'iec-60309-3p-n-e-9h'
# IEC 60906-1
TYPE_IEC_60906_1 = 'iec-60906-1'
TYPE_NBR_14136_10A = 'nbr-14136-10a'
TYPE_NBR_14136_20A = 'nbr-14136-20a'
# NEMA non-locking # NEMA non-locking
TYPE_NEMA_115R = 'nema-1-15r' TYPE_NEMA_115R = 'nema-1-15r'
TYPE_NEMA_515R = 'nema-5-15r' TYPE_NEMA_515R = 'nema-5-15r'
@ -657,6 +670,11 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_IEC_3PNE6H, '3P+N+E 6H'), (TYPE_IEC_3PNE6H, '3P+N+E 6H'),
(TYPE_IEC_3PNE9H, '3P+N+E 9H'), (TYPE_IEC_3PNE9H, '3P+N+E 9H'),
)), )),
('IEC 60906-1', (
(TYPE_IEC_60906_1, 'IEC 60906-1'),
(TYPE_NBR_14136_10A, '2P+T 10A (NBR 14136)'),
(TYPE_NBR_14136_20A, '2P+T 20A (NBR 14136)'),
)),
('NEMA (Non-locking)', ( ('NEMA (Non-locking)', (
(TYPE_NEMA_115R, 'NEMA 1-15R'), (TYPE_NEMA_115R, 'NEMA 1-15R'),
(TYPE_NEMA_515R, 'NEMA 5-15R'), (TYPE_NEMA_515R, 'NEMA 5-15R'),
@ -809,11 +827,18 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_100GE_CFP4 = '100gbase-x-cfp4' TYPE_100GE_CFP4 = '100gbase-x-cfp4'
TYPE_100GE_CXP = '100gbase-x-cxp' TYPE_100GE_CXP = '100gbase-x-cxp'
TYPE_100GE_CPAK = '100gbase-x-cpak' TYPE_100GE_CPAK = '100gbase-x-cpak'
TYPE_100GE_DSFP = '100gbase-x-dsfp'
TYPE_100GE_SFP_DD = '100gbase-x-sfpdd'
TYPE_100GE_QSFP28 = '100gbase-x-qsfp28' TYPE_100GE_QSFP28 = '100gbase-x-qsfp28'
TYPE_100GE_QSFP_DD = '100gbase-x-qsfpdd'
TYPE_200GE_CFP2 = '200gbase-x-cfp2' TYPE_200GE_CFP2 = '200gbase-x-cfp2'
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56' TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
TYPE_400GE_CFP2 = '400gbase-x-cfp2'
TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd' TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
TYPE_400GE_OSFP = '400gbase-x-osfp' TYPE_400GE_OSFP = '400gbase-x-osfp'
TYPE_400GE_CDFP = '400gbase-x-cdfp'
TYPE_400GE_CFP8 = '400gbase-x-cfp8'
TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd' TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
TYPE_800GE_OSFP = '800gbase-x-osfp' TYPE_800GE_OSFP = '800gbase-x-osfp'
@ -952,13 +977,20 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_100GE_CFP, 'CFP (100GE)'), (TYPE_100GE_CFP, 'CFP (100GE)'),
(TYPE_100GE_CFP2, 'CFP2 (100GE)'), (TYPE_100GE_CFP2, 'CFP2 (100GE)'),
(TYPE_200GE_CFP2, 'CFP2 (200GE)'), (TYPE_200GE_CFP2, 'CFP2 (200GE)'),
(TYPE_400GE_CFP2, 'CFP2 (400GE)'),
(TYPE_100GE_CFP4, 'CFP4 (100GE)'), (TYPE_100GE_CFP4, 'CFP4 (100GE)'),
(TYPE_100GE_CXP, 'CXP (100GE)'), (TYPE_100GE_CXP, 'CXP (100GE)'),
(TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'), (TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'),
(TYPE_100GE_DSFP, 'DSFP (100GE)'),
(TYPE_100GE_SFP_DD, 'SFP-DD (100GE)'),
(TYPE_100GE_QSFP28, 'QSFP28 (100GE)'), (TYPE_100GE_QSFP28, 'QSFP28 (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_400GE_QSFP_DD, 'QSFP-DD (400GE)'), (TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
(TYPE_400GE_OSFP, 'OSFP (400GE)'), (TYPE_400GE_OSFP, 'OSFP (400GE)'),
(TYPE_400GE_CDFP, 'CDFP (400GE)'),
(TYPE_400GE_CFP8, 'CPF8 (400GE)'),
(TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'), (TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),
(TYPE_800GE_OSFP, 'OSFP (800GE)'), (TYPE_800GE_OSFP, 'OSFP (800GE)'),
) )
@ -1109,6 +1141,8 @@ class InterfaceSpeedChoices(ChoiceSet):
(25000000, '25 Gbps'), (25000000, '25 Gbps'),
(40000000, '40 Gbps'), (40000000, '40 Gbps'),
(100000000, '100 Gbps'), (100000000, '100 Gbps'),
(200000000, '200 Gbps'),
(400000000, '400 Gbps'),
] ]
@ -1223,6 +1257,10 @@ class PortTypeChoices(ChoiceSet):
TYPE_LSH_PC = 'lsh-pc' TYPE_LSH_PC = 'lsh-pc'
TYPE_LSH_UPC = 'lsh-upc' TYPE_LSH_UPC = 'lsh-upc'
TYPE_LSH_APC = 'lsh-apc' TYPE_LSH_APC = 'lsh-apc'
TYPE_LX5 = 'lx5'
TYPE_LX5_PC = 'lx5-pc'
TYPE_LX5_UPC = 'lx5-upc'
TYPE_LX5_APC = 'lx5-apc'
TYPE_SPLICE = 'splice' TYPE_SPLICE = 'splice'
TYPE_CS = 'cs' TYPE_CS = 'cs'
TYPE_SN = 'sn' TYPE_SN = 'sn'
@ -1269,6 +1307,10 @@ class PortTypeChoices(ChoiceSet):
(TYPE_LSH_PC, 'LSH/PC'), (TYPE_LSH_PC, 'LSH/PC'),
(TYPE_LSH_UPC, 'LSH/UPC'), (TYPE_LSH_UPC, 'LSH/UPC'),
(TYPE_LSH_APC, 'LSH/APC'), (TYPE_LSH_APC, 'LSH/APC'),
(TYPE_LX5, 'LX.5'),
(TYPE_LX5_PC, 'LX.5/PC'),
(TYPE_LX5_UPC, 'LX.5/UPC'),
(TYPE_LX5_APC, 'LX.5/APC'),
(TYPE_MPO, 'MPO'), (TYPE_MPO, 'MPO'),
(TYPE_MTRJ, 'MTRJ'), (TYPE_MTRJ, 'MTRJ'),
(TYPE_SC, 'SC'), (TYPE_SC, 'SC'),

View File

@ -11,6 +11,7 @@ DEVICETYPE_IMAGE_FORMATS = 'image/bmp,image/gif,image/jpeg,image/png,image/tiff,
# #
RACK_U_HEIGHT_DEFAULT = 42 RACK_U_HEIGHT_DEFAULT = 42
RACK_U_HEIGHT_MAX = 100
RACK_ELEVATION_BORDER_WIDTH = 2 RACK_ELEVATION_BORDER_WIDTH = 2
RACK_ELEVATION_DEFAULT_LEGEND_WIDTH = 30 RACK_ELEVATION_DEFAULT_LEGEND_WIDTH = 30

View File

@ -1084,10 +1084,13 @@ class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
return queryset return queryset
return queryset.filter(
Q(name__icontains=value) | qs_filter = Q(name__icontains=value)
Q(identifier=value.strip()) try:
).distinct() qs_filter |= Q(identifier=int(value))
except ValueError:
pass
return queryset.filter(qs_filter).distinct()
def _has_primary_ip(self, queryset, name, value): def _has_primary_ip(self, queryset, name, value):
params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False) params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False)
@ -1226,6 +1229,28 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
to_field_name='name', to_field_name='name',
label=_('Device (name)'), label=_('Device (name)'),
) )
device_type_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__device_type',
queryset=DeviceType.objects.all(),
label=_('Device type (ID)'),
)
device_type = django_filters.ModelMultipleChoiceFilter(
field_name='device__device_type__model',
queryset=DeviceType.objects.all(),
to_field_name='model',
label=_('Device type (model)'),
)
device_role_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__device_role',
queryset=DeviceRole.objects.all(),
label=_('Device role (ID)'),
)
device_role = django_filters.ModelMultipleChoiceFilter(
field_name='device__device_role__slug',
queryset=DeviceRole.objects.all(),
to_field_name='slug',
label=_('Device role (slug)'),
)
virtual_chassis_id = django_filters.ModelMultipleChoiceFilter( virtual_chassis_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__virtual_chassis', field_name='device__virtual_chassis',
queryset=VirtualChassis.objects.all(), queryset=VirtualChassis.objects.all(),

View File

@ -1,4 +1,5 @@
from django import forms from django import forms
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from timezone_field import TimeZoneFormField from timezone_field import TimeZoneFormField
@ -1105,7 +1106,7 @@ class PowerPortBulkEditForm(
(None, ('module', 'type', 'label', 'description', 'mark_connected')), (None, ('module', 'type', 'label', 'description', 'mark_connected')),
('Power', ('maximum_draw', 'allocated_draw')), ('Power', ('maximum_draw', 'allocated_draw')),
) )
nullable_fields = ('module', 'label', 'description') nullable_fields = ('module', 'label', 'description', 'maximum_draw', 'allocated_draw')
class PowerOutletBulkEditForm( class PowerOutletBulkEditForm(
@ -1258,8 +1259,8 @@ class InterfaceBulkEditForm(
) )
nullable_fields = ( nullable_fields = (
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'vdcs', 'mtu', 'description', 'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'vdcs', 'mtu', 'description',
'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan',
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', 'wireless_lans' 'tagged_vlans', 'vrf', 'wireless_lans'
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -1292,8 +1293,13 @@ class InterfaceBulkEditForm(
break break
if site is not None: if site is not None:
self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk) # Query for VLANs assigned to the same site and VLANs with no site assigned (null).
self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk) self.fields['untagged_vlan'].widget.add_query_param(
'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE]
)
self.fields['tagged_vlans'].widget.add_query_param(
'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE]
)
self.fields['parent'].choices = () self.fields['parent'].choices = ()
self.fields['parent'].widget.attrs['disabled'] = True self.fields['parent'].widget.attrs['disabled'] = True

View File

@ -306,7 +306,7 @@ class DeviceTypeImportForm(NetBoxModelImportForm):
model = DeviceType model = DeviceType
fields = [ fields = [
'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments', 'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments', 'tags',
] ]
@ -327,7 +327,7 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = ModuleType model = ModuleType
fields = ['manufacturer', 'model', 'part_number', 'description', 'weight', 'weight_unit', 'comments'] fields = ['manufacturer', 'model', 'part_number', 'description', 'weight', 'weight_unit', 'comments', 'tags']
class DeviceRoleImportForm(NetBoxModelImportForm): class DeviceRoleImportForm(NetBoxModelImportForm):
@ -1078,7 +1078,11 @@ class CableImportForm(NetBoxModelImportForm):
model = content_type.model_class() model = content_type.model_class()
try: try:
termination_object = model.objects.get(device=device, name=name) if device.virtual_chassis and device.virtual_chassis.master == device and \
model.objects.filter(device=device, name=name).count() == 0:
termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name)
else:
termination_object = model.objects.get(device=device, name=name)
if termination_object.cable is not None: if termination_object.cable is not None:
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:

View File

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

View File

@ -1042,6 +1042,9 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
queryset=VirtualDeviceContext.objects.all(), queryset=VirtualDeviceContext.objects.all(),
required=False, required=False,
label='Virtual Device Contexts', label='Virtual Device Contexts',
initial_params={
'interfaces': '$parent',
},
query_params={ query_params={
'device_id': '$device', 'device_id': '$device',
} }

View File

@ -52,7 +52,10 @@ class ComponentCreateForm(forms.Form):
super().clean() super().clean()
# Validate that all replication fields generate an equal number of values # Validate that all replication fields generate an equal number of values
pattern_count = len(self.cleaned_data[self.replication_fields[0]]) if not (patterns := self.cleaned_data.get(self.replication_fields[0])):
return
pattern_count = len(patterns)
for field_name in self.replication_fields: for field_name in self.replication_fields:
value_count = len(self.cleaned_data[field_name]) value_count = len(self.cleaned_data[field_name])
if self.cleaned_data[field_name] and value_count != pattern_count: if self.cleaned_data[field_name] and value_count != pattern_count:
@ -101,6 +104,7 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp
choices=[], choices=[],
label=_('Rear ports'), label=_('Rear ports'),
help_text=_('Select one rear port assignment for each front port being created.'), help_text=_('Select one rear port assignment for each front port being created.'),
widget=forms.SelectMultiple(attrs={'size': 6})
) )
# Override fieldsets from FrontPortTemplateForm to omit rear_port_position # Override fieldsets from FrontPortTemplateForm to omit rear_port_position

View File

@ -53,23 +53,23 @@ class LinkPeerType(graphene.Union):
@classmethod @classmethod
def resolve_type(cls, instance, info): def resolve_type(cls, instance, info):
if type(instance) == CircuitTermination: if type(instance) is CircuitTermination:
return CircuitTerminationType return CircuitTerminationType
if type(instance) == ConsolePortType: if type(instance) is ConsolePortType:
return ConsolePortType return ConsolePortType
if type(instance) == ConsoleServerPort: if type(instance) is ConsoleServerPort:
return ConsoleServerPortType return ConsoleServerPortType
if type(instance) == FrontPort: if type(instance) is FrontPort:
return FrontPortType return FrontPortType
if type(instance) == Interface: if type(instance) is Interface:
return InterfaceType return InterfaceType
if type(instance) == PowerFeed: if type(instance) is PowerFeed:
return PowerFeedType return PowerFeedType
if type(instance) == PowerOutlet: if type(instance) is PowerOutlet:
return PowerOutletType return PowerOutletType
if type(instance) == PowerPort: if type(instance) is PowerPort:
return PowerPortType return PowerPortType
if type(instance) == RearPort: if type(instance) is RearPort:
return RearPortType return RearPortType
@ -89,23 +89,23 @@ class CableTerminationTerminationType(graphene.Union):
@classmethod @classmethod
def resolve_type(cls, instance, info): def resolve_type(cls, instance, info):
if type(instance) == CircuitTermination: if type(instance) is CircuitTermination:
return CircuitTerminationType return CircuitTerminationType
if type(instance) == ConsolePortType: if type(instance) is ConsolePortType:
return ConsolePortType return ConsolePortType
if type(instance) == ConsoleServerPort: if type(instance) is ConsoleServerPort:
return ConsoleServerPortType return ConsoleServerPortType
if type(instance) == FrontPort: if type(instance) is FrontPort:
return FrontPortType return FrontPortType
if type(instance) == Interface: if type(instance) is Interface:
return InterfaceType return InterfaceType
if type(instance) == PowerFeed: if type(instance) is PowerFeed:
return PowerFeedType return PowerFeedType
if type(instance) == PowerOutlet: if type(instance) is PowerOutlet:
return PowerOutletType return PowerOutletType
if type(instance) == PowerPort: if type(instance) is PowerPort:
return PowerPortType return PowerPortType
if type(instance) == RearPort: if type(instance) is RearPort:
return RearPortType return RearPortType
@ -123,19 +123,19 @@ class InventoryItemTemplateComponentType(graphene.Union):
@classmethod @classmethod
def resolve_type(cls, instance, info): def resolve_type(cls, instance, info):
if type(instance) == ConsolePortTemplate: if type(instance) is ConsolePortTemplate:
return ConsolePortTemplateType return ConsolePortTemplateType
if type(instance) == ConsoleServerPortTemplate: if type(instance) is ConsoleServerPortTemplate:
return ConsoleServerPortTemplateType return ConsoleServerPortTemplateType
if type(instance) == FrontPortTemplate: if type(instance) is FrontPortTemplate:
return FrontPortTemplateType return FrontPortTemplateType
if type(instance) == InterfaceTemplate: if type(instance) is InterfaceTemplate:
return InterfaceTemplateType return InterfaceTemplateType
if type(instance) == PowerOutletTemplate: if type(instance) is PowerOutletTemplate:
return PowerOutletTemplateType return PowerOutletTemplateType
if type(instance) == PowerPortTemplate: if type(instance) is PowerPortTemplate:
return PowerPortTemplateType return PowerPortTemplateType
if type(instance) == RearPortTemplate: if type(instance) is RearPortTemplate:
return RearPortTemplateType return RearPortTemplateType
@ -153,17 +153,17 @@ class InventoryItemComponentType(graphene.Union):
@classmethod @classmethod
def resolve_type(cls, instance, info): def resolve_type(cls, instance, info):
if type(instance) == ConsolePort: if type(instance) is ConsolePort:
return ConsolePortType return ConsolePortType
if type(instance) == ConsoleServerPort: if type(instance) is ConsoleServerPort:
return ConsoleServerPortType return ConsoleServerPortType
if type(instance) == FrontPort: if type(instance) is FrontPort:
return FrontPortType return FrontPortType
if type(instance) == Interface: if type(instance) is Interface:
return InterfaceType return InterfaceType
if type(instance) == PowerOutlet: if type(instance) is PowerOutlet:
return PowerOutletType return PowerOutletType
if type(instance) == PowerPort: if type(instance) is PowerPort:
return PowerPortType return PowerPortType
if type(instance) == RearPort: if type(instance) is RearPort:
return RearPortType return RearPortType

View File

@ -0,0 +1,62 @@
import json
import os
from django.conf import settings
from django.core.management.base import BaseCommand
from jinja2 import FileSystemLoader, Environment
from dcim.choices import *
TEMPLATE_FILENAME = 'devicetype_schema.jinja2'
OUTPUT_FILENAME = 'contrib/generated_schema.json'
CHOICES_MAP = {
'airflow_choices': DeviceAirflowChoices,
'weight_unit_choices': WeightUnitChoices,
'subdevice_role_choices': SubdeviceRoleChoices,
'console_port_type_choices': ConsolePortTypeChoices,
'console_server_port_type_choices': ConsolePortTypeChoices,
'power_port_type_choices': PowerPortTypeChoices,
'power_outlet_type_choices': PowerOutletTypeChoices,
'power_outlet_feedleg_choices': PowerOutletFeedLegChoices,
'interface_type_choices': InterfaceTypeChoices,
'interface_poe_mode_choices': InterfacePoEModeChoices,
'interface_poe_type_choices': InterfacePoETypeChoices,
'front_port_type_choices': PortTypeChoices,
'rear_port_type_choices': PortTypeChoices,
}
class Command(BaseCommand):
help = "Generate JSON schema for validating NetBox device type definitions"
def add_arguments(self, parser):
parser.add_argument(
'--write',
action='store_true',
help="Write the generated schema to file"
)
def handle(self, *args, **kwargs):
# Initialize template
template_loader = FileSystemLoader(searchpath=f'{settings.TEMPLATES_DIR}/extras/schema/')
template_env = Environment(loader=template_loader)
template = template_env.get_template(TEMPLATE_FILENAME)
# Render template
context = {
key: json.dumps(choices.values())
for key, choices in CHOICES_MAP.items()
}
rendered = template.render(**context)
if kwargs['write']:
# $root/contrib/generated_schema.json
filename = os.path.join(os.path.split(settings.BASE_DIR)[0], OUTPUT_FILENAME)
with open(filename, mode='w', encoding='UTF-8') as f:
f.write(json.dumps(json.loads(rendered), indent=4))
f.write('\n')
f.close()
self.stdout.write(self.style.SUCCESS(f"Schema written to {filename}."))
else:
self.stdout.write(rendered)

View File

@ -18,6 +18,6 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='device', model_name='device',
name='position', name='position',
field=models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(99.5)]), field=models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100.5)]),
), ),
] ]

View File

@ -0,0 +1,42 @@
# Generated by Django 4.1.9 on 2023-05-12 18:46
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0171_cabletermination_change_logging'),
]
operations = [
migrations.AlterField(
model_name='powerport',
name='allocated_draw',
field=models.PositiveIntegerField(
blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]
),
),
migrations.AlterField(
model_name='powerport',
name='maximum_draw',
field=models.PositiveIntegerField(
blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]
),
),
migrations.AlterField(
model_name='powerporttemplate',
name='allocated_draw',
field=models.PositiveIntegerField(
blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]
),
),
migrations.AlterField(
model_name='powerporttemplate',
name='maximum_draw',
field=models.PositiveIntegerField(
blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]
),
),
]

View File

@ -232,13 +232,13 @@ class PowerPortTemplate(ModularComponentTemplateModel):
choices=PowerPortTypeChoices, choices=PowerPortTypeChoices,
blank=True blank=True
) )
maximum_draw = models.PositiveSmallIntegerField( maximum_draw = models.PositiveIntegerField(
blank=True, blank=True,
null=True, null=True,
validators=[MinValueValidator(1)], validators=[MinValueValidator(1)],
help_text=_("Maximum power draw (watts)") help_text=_("Maximum power draw (watts)")
) )
allocated_draw = models.PositiveSmallIntegerField( allocated_draw = models.PositiveIntegerField(
blank=True, blank=True,
null=True, null=True,
validators=[MinValueValidator(1)], validators=[MinValueValidator(1)],

View File

@ -329,13 +329,13 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
blank=True, blank=True,
help_text=_('Physical port type') help_text=_('Physical port type')
) )
maximum_draw = models.PositiveSmallIntegerField( maximum_draw = models.PositiveIntegerField(
blank=True, blank=True,
null=True, null=True,
validators=[MinValueValidator(1)], validators=[MinValueValidator(1)],
help_text=_("Maximum power draw (watts)") help_text=_("Maximum power draw (watts)")
) )
allocated_draw = models.PositiveSmallIntegerField( allocated_draw = models.PositiveIntegerField(
blank=True, blank=True,
null=True, null=True,
validators=[MinValueValidator(1)], validators=[MinValueValidator(1)],

View File

@ -232,7 +232,7 @@ class DeviceType(PrimaryModel, WeightMixin):
super().clean() super().clean()
# U height must be divisible by 0.5 # U height must be divisible by 0.5
if self.u_height % decimal.Decimal(0.5): if decimal.Decimal(self.u_height) % decimal.Decimal(0.5):
raise ValidationError({ raise ValidationError({
'u_height': "U height must be in increments of 0.5 rack units." 'u_height': "U height must be in increments of 0.5 rack units."
}) })
@ -568,7 +568,7 @@ class Device(PrimaryModel, ConfigContextModel):
decimal_places=1, decimal_places=1,
blank=True, blank=True,
null=True, null=True,
validators=[MinValueValidator(1), MaxValueValidator(99.5)], validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX + 0.5)],
verbose_name='Position (U)', verbose_name='Position (U)',
help_text=_('The lowest-numbered unit occupied by the device') help_text=_('The lowest-numbered unit occupied by the device')
) )

View File

@ -126,7 +126,7 @@ class Rack(PrimaryModel, WeightMixin):
u_height = models.PositiveSmallIntegerField( u_height = models.PositiveSmallIntegerField(
default=RACK_U_HEIGHT_DEFAULT, default=RACK_U_HEIGHT_DEFAULT,
verbose_name='Height (U)', verbose_name='Height (U)',
validators=[MinValueValidator(1), MaxValueValidator(100)], validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX)],
help_text=_('Height in rack units') help_text=_('Height in rack units')
) )
desc_units = models.BooleanField( desc_units = models.BooleanField(
@ -466,7 +466,7 @@ class Rack(PrimaryModel, WeightMixin):
powerport.get_power_draw()['allocated'] for powerport in powerports powerport.get_power_draw()['allocated'] for powerport in powerports
]) ])
return int(allocated_draw / available_power_total * 100) return round(allocated_draw / available_power_total * 100, 1)
@cached_property @cached_property
def total_weight(self): def total_weight(self):

View File

@ -27,6 +27,7 @@ def handle_location_site_change(instance, created, **kwargs):
Rack.objects.filter(location__in=locations).update(site=instance.site) Rack.objects.filter(location__in=locations).update(site=instance.site)
Device.objects.filter(location__in=locations).update(site=instance.site) Device.objects.filter(location__in=locations).update(site=instance.site)
PowerPanel.objects.filter(location__in=locations).update(site=instance.site) PowerPanel.objects.filter(location__in=locations).update(site=instance.site)
CableTermination.objects.filter(_location__in=locations).update(_site=instance.site)
@receiver(post_save, sender=Rack) @receiver(post_save, sender=Rack)

View File

@ -545,6 +545,11 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
} }
) )
mgmt_only = columns.BooleanColumn() mgmt_only = columns.BooleanColumn()
speed_formatted = columns.TemplateColumn(
template_code='{% load helpers %}{{ value|humanize_speed }}',
accessor=Accessor('speed'),
verbose_name='Speed'
)
wireless_link = tables.Column( wireless_link = tables.Column(
linkify=True linkify=True
) )
@ -568,7 +573,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
model = models.Interface model = models.Interface
fields = ( fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu',
'speed', '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', 'created', 'last_updated',

View File

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

View File

@ -12,6 +12,23 @@ from virtualization.models import Cluster, ClusterType
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
class DeviceComponentFilterSetTests:
def test_device_type(self):
device_types = DeviceType.objects.all()[:2]
params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'device_type': [device_types[0].model, device_types[1].model]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_device_role(self):
device_role = DeviceRole.objects.all()[:2]
params = {'device_role_id': [device_role[0].pk, device_role[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'device_role': [device_role[0].slug, device_role[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class RegionTestCase(TestCase, ChangeLoggedFilterSetTests): class RegionTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Region.objects.all() queryset = Region.objects.all()
filterset = RegionFilterSet filterset = RegionFilterSet
@ -1998,7 +2015,7 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests): class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = ConsolePort.objects.all() queryset = ConsolePort.objects.all()
filterset = ConsolePortFilterSet filterset = ConsolePortFilterSet
@ -2027,10 +2044,23 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'), Site(name='Site X', slug='site-x'),
)) ))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = ( locations = (
Location(name='Location 1', slug='location-1', site=sites[0]), Location(name='Location 1', slug='location-1', site=sites[0]),
@ -2048,10 +2078,10 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections Device(name=None, device_type=device_types[0], device_role=device_roles[0], site=sites[3]), # For cable connections
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
@ -2165,7 +2195,7 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests): class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = ConsoleServerPort.objects.all() queryset = ConsoleServerPort.objects.all()
filterset = ConsoleServerPortFilterSet filterset = ConsoleServerPortFilterSet
@ -2194,10 +2224,23 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'), Site(name='Site X', slug='site-x'),
)) ))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = ( locations = (
Location(name='Location 1', slug='location-1', site=sites[0]), Location(name='Location 1', slug='location-1', site=sites[0]),
@ -2215,10 +2258,10 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
@ -2332,7 +2375,7 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests): class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = PowerPort.objects.all() queryset = PowerPort.objects.all()
filterset = PowerPortFilterSet filterset = PowerPortFilterSet
@ -2361,10 +2404,23 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'), Site(name='Site X', slug='site-x'),
)) ))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = ( locations = (
Location(name='Location 1', slug='location-1', site=sites[0]), Location(name='Location 1', slug='location-1', site=sites[0]),
@ -2382,10 +2438,10 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
@ -2507,7 +2563,7 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests): class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = PowerOutlet.objects.all() queryset = PowerOutlet.objects.all()
filterset = PowerOutletFilterSet filterset = PowerOutletFilterSet
@ -2536,10 +2592,23 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'), Site(name='Site X', slug='site-x'),
)) ))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = ( locations = (
Location(name='Location 1', slug='location-1', site=sites[0]), Location(name='Location 1', slug='location-1', site=sites[0]),
@ -2557,10 +2626,10 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
@ -2678,7 +2747,7 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = Interface.objects.all() queryset = Interface.objects.all()
filterset = InterfaceFilterSet filterset = InterfaceFilterSet
@ -2707,10 +2776,23 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'), Site(name='Site X', slug='site-x'),
)) ))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = ( locations = (
Location(name='Location 1', slug='location-1', site=sites[0]), Location(name='Location 1', slug='location-1', site=sites[0]),
@ -2728,10 +2810,10 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
@ -3101,7 +3183,7 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests): class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = FrontPort.objects.all() queryset = FrontPort.objects.all()
filterset = FrontPortFilterSet filterset = FrontPortFilterSet
@ -3130,10 +3212,23 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'), Site(name='Site X', slug='site-x'),
)) ))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = ( locations = (
Location(name='Location 1', slug='location-1', site=sites[0]), Location(name='Location 1', slug='location-1', site=sites[0]),
@ -3151,10 +3246,10 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
@ -3277,7 +3372,7 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests): class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = RearPort.objects.all() queryset = RearPort.objects.all()
filterset = RearPortFilterSet filterset = RearPortFilterSet
@ -3306,10 +3401,23 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'), Site(name='Site X', slug='site-x'),
)) ))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = ( locations = (
Location(name='Location 1', slug='location-1', site=sites[0]), Location(name='Location 1', slug='location-1', site=sites[0]),
@ -3327,10 +3435,10 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
@ -3447,7 +3555,7 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests): class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = ModuleBay.objects.all() queryset = ModuleBay.objects.all()
filterset = ModuleBayFilterSet filterset = ModuleBayFilterSet
@ -3476,9 +3584,21 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'), Site(name='Site X', slug='site-x'),
)) ))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1') device_types = (
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = ( locations = (
Location(name='Location 1', slug='location-1', site=sites[0]), Location(name='Location 1', slug='location-1', site=sites[0]),
@ -3496,9 +3616,9 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
@ -3564,7 +3684,7 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests): class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = DeviceBay.objects.all() queryset = DeviceBay.objects.all()
filterset = DeviceBayFilterSet filterset = DeviceBayFilterSet
@ -3593,9 +3713,21 @@ class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'), Site(name='Site X', slug='site-x'),
)) ))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1') device_types = (
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = ( locations = (
Location(name='Location 1', slug='location-1', site=sites[0]), Location(name='Location 1', slug='location-1', site=sites[0]),
@ -3613,9 +3745,9 @@ class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
@ -3694,8 +3826,19 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
) )
Manufacturer.objects.bulk_create(manufacturers) Manufacturer.objects.bulk_create(manufacturers)
device_type = DeviceType.objects.create(manufacturer=manufacturers[0], model='Model 1', slug='model-1') device_types = (
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') DeviceType(manufacturer=manufacturers[0], model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturers[0], model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturers[0], model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
regions = ( regions = (
Region(name='Region 1', slug='region-1'), Region(name='Region 1', slug='region-1'),
@ -3736,9 +3879,9 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
@ -3829,6 +3972,20 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'rack': [racks[0].name, racks[1].name]} params = {'rack': [racks[0].name, racks[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_device_type(self):
device_types = DeviceType.objects.all()[:2]
params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'device_type': [device_types[0].model, device_types[1].model]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_device_role(self):
device_role = DeviceRole.objects.all()[:2]
params = {'device_role_id': [device_role[0].pk, device_role[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'device_role': [device_role[0].slug, device_role[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_device(self): def test_device(self):
devices = Device.objects.all()[:2] devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]} params = {'device_id': [devices[0].pk, devices[1].pk]}

View File

@ -2907,6 +2907,7 @@ class CableTestCase(
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetype = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer) devicetype = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
vc = VirtualChassis.objects.create(name='Virtual Chassis')
devices = ( devices = (
Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole), Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole),
@ -2916,6 +2917,10 @@ class CableTestCase(
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
vc.members.set((devices[0], devices[1], devices[2]))
vc.master = devices[0]
vc.save()
interfaces = ( interfaces = (
Interface(device=devices[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED), Interface(device=devices[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[0], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED), Interface(device=devices[0], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
@ -2929,6 +2934,10 @@ class CableTestCase(
Interface(device=devices[3], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED), Interface(device=devices[3], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[3], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED), Interface(device=devices[3], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[3], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED), Interface(device=devices[3], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[1], name='Device 2 Interface', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[2], name='Device 3 Interface', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[3], name='Interface 4', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[3], name='Interface 5', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
) )
Interface.objects.bulk_create(interfaces) Interface.objects.bulk_create(interfaces)
@ -2961,6 +2970,8 @@ class CableTestCase(
"Device 3,dcim.interface,Interface 1,Device 4,dcim.interface,Interface 1", "Device 3,dcim.interface,Interface 1,Device 4,dcim.interface,Interface 1",
"Device 3,dcim.interface,Interface 2,Device 4,dcim.interface,Interface 2", "Device 3,dcim.interface,Interface 2,Device 4,dcim.interface,Interface 2",
"Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3", "Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3",
"Device 1,dcim.interface,Device 2 Interface,Device 4,dcim.interface,Interface 4",
"Device 1,dcim.interface,Device 3 Interface,Device 4,dcim.interface,Interface 5",
) )
cls.csv_update_data = ( cls.csv_update_data = (

View File

@ -1,4 +1,5 @@
import traceback import traceback
from collections import defaultdict
from django.contrib import messages from django.contrib import messages
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
@ -20,6 +21,7 @@ from extras.views import ObjectConfigContextView
from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup
from ipam.tables import InterfaceVLANTable from ipam.tables import InterfaceVLANTable
from netbox.views import generic from netbox.views import generic
from tenancy.views import ObjectContactsView
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.permissions import get_permission_for_model from utilities.permissions import get_permission_for_model
@ -44,6 +46,15 @@ CABLE_TERMINATION_TYPES = {
class DeviceComponentsView(generic.ObjectChildrenView): class DeviceComponentsView(generic.ObjectChildrenView):
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename', 'bulk_disconnect')
action_perms = defaultdict(set, **{
'add': {'add'},
'import': {'add'},
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
'bulk_rename': {'change'},
'bulk_disconnect': {'change'},
})
queryset = Device.objects.all() queryset = Device.objects.all()
def get_children(self, request, parent): def get_children(self, request, parent):
@ -267,6 +278,11 @@ class RegionBulkDeleteView(generic.BulkDeleteView):
table = tables.RegionTable table = tables.RegionTable
@register_model_view(Region, 'contacts')
class RegionContactsView(ObjectContactsView):
queryset = Region.objects.all()
# #
# Site groups # Site groups
# #
@ -342,6 +358,11 @@ class SiteGroupBulkDeleteView(generic.BulkDeleteView):
table = tables.SiteGroupTable table = tables.SiteGroupTable
@register_model_view(SiteGroup, 'contacts')
class SiteGroupContactsView(ObjectContactsView):
queryset = SiteGroup.objects.all()
# #
# Sites # Sites
# #
@ -411,6 +432,11 @@ class SiteBulkDeleteView(generic.BulkDeleteView):
table = tables.SiteTable table = tables.SiteTable
@register_model_view(Site, 'contacts')
class SiteContactsView(ObjectContactsView):
queryset = Site.objects.all()
# #
# Locations # Locations
# #
@ -491,6 +517,11 @@ class LocationBulkDeleteView(generic.BulkDeleteView):
table = tables.LocationTable table = tables.LocationTable
@register_model_view(Location, 'contacts')
class LocationContactsView(ObjectContactsView):
queryset = Location.objects.all()
# #
# Rack roles # Rack roles
# #
@ -670,6 +701,26 @@ class RackRackReservationsView(generic.ObjectChildrenView):
return parent.reservations.restrict(request.user, 'view') return parent.reservations.restrict(request.user, 'view')
@register_model_view(Rack, 'nonracked_devices', 'nonracked-devices')
class RackNonRackedView(generic.ObjectChildrenView):
queryset = Rack.objects.all()
child_model = Device
table = tables.DeviceTable
filterset = filtersets.DeviceFilterSet
template_name = 'dcim/rack/non_racked_devices.html'
tab = ViewTab(
label=_('Non-Racked Devices'),
badge=lambda obj: obj.devices.filter(rack=obj, position__isnull=True, parent_bay__isnull=True).count(),
weight=500,
permission='dcim.view_device',
)
def get_children(self, request, parent):
return parent.devices.restrict(request.user, 'view').filter(
rack=parent, position__isnull=True, parent_bay__isnull=True
)
@register_model_view(Rack, 'edit') @register_model_view(Rack, 'edit')
class RackEditView(generic.ObjectEditView): class RackEditView(generic.ObjectEditView):
queryset = Rack.objects.all() queryset = Rack.objects.all()
@ -700,6 +751,11 @@ class RackBulkDeleteView(generic.BulkDeleteView):
table = tables.RackTable table = tables.RackTable
@register_model_view(Rack, 'contacts')
class RackContactsView(ObjectContactsView):
queryset = Rack.objects.all()
# #
# Rack reservations # Rack reservations
# #
@ -834,6 +890,11 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
table = tables.ManufacturerTable table = tables.ManufacturerTable
@register_model_view(Manufacturer, 'contacts')
class ManufacturerContactsView(ObjectContactsView):
queryset = Manufacturer.objects.all()
# #
# Device types # Device types
# #
@ -1914,6 +1975,7 @@ class DeviceModuleBaysView(DeviceComponentsView):
table = tables.DeviceModuleBayTable table = tables.DeviceModuleBayTable
filterset = filtersets.ModuleBayFilterSet filterset = filtersets.ModuleBayFilterSet
template_name = 'dcim/device/modulebays.html' template_name = 'dcim/device/modulebays.html'
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
tab = ViewTab( tab = ViewTab(
label=_('Module Bays'), label=_('Module Bays'),
badge=lambda obj: obj.modulebays.count(), badge=lambda obj: obj.modulebays.count(),
@ -1929,6 +1991,7 @@ class DeviceDeviceBaysView(DeviceComponentsView):
table = tables.DeviceDeviceBayTable table = tables.DeviceDeviceBayTable
filterset = filtersets.DeviceBayFilterSet filterset = filtersets.DeviceBayFilterSet
template_name = 'dcim/device/devicebays.html' template_name = 'dcim/device/devicebays.html'
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
tab = ViewTab( tab = ViewTab(
label=_('Device Bays'), label=_('Device Bays'),
badge=lambda obj: obj.devicebays.count(), badge=lambda obj: obj.devicebays.count(),
@ -1940,6 +2003,7 @@ class DeviceDeviceBaysView(DeviceComponentsView):
@register_model_view(Device, 'inventory') @register_model_view(Device, 'inventory')
class DeviceInventoryView(DeviceComponentsView): class DeviceInventoryView(DeviceComponentsView):
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
child_model = InventoryItem child_model = InventoryItem
table = tables.DeviceInventoryItemTable table = tables.DeviceInventoryItemTable
filterset = filtersets.InventoryItemFilterSet filterset = filtersets.InventoryItemFilterSet
@ -2048,6 +2112,11 @@ class DeviceBulkRenameView(generic.BulkRenameView):
table = tables.DeviceTable table = tables.DeviceTable
@register_model_view(Device, 'contacts')
class DeviceContactsView(ObjectContactsView):
queryset = Device.objects.all()
# #
# Modules # Modules
# #
@ -2117,7 +2186,6 @@ class ConsolePortListView(generic.ObjectListView):
filterset = filtersets.ConsolePortFilterSet filterset = filtersets.ConsolePortFilterSet
filterset_form = forms.ConsolePortFilterForm filterset_form = forms.ConsolePortFilterForm
table = tables.ConsolePortTable table = tables.ConsolePortTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(ConsolePort) @register_model_view(ConsolePort)
@ -2181,7 +2249,6 @@ class ConsoleServerPortListView(generic.ObjectListView):
filterset = filtersets.ConsoleServerPortFilterSet filterset = filtersets.ConsoleServerPortFilterSet
filterset_form = forms.ConsoleServerPortFilterForm filterset_form = forms.ConsoleServerPortFilterForm
table = tables.ConsoleServerPortTable table = tables.ConsoleServerPortTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(ConsoleServerPort) @register_model_view(ConsoleServerPort)
@ -2245,7 +2312,6 @@ class PowerPortListView(generic.ObjectListView):
filterset = filtersets.PowerPortFilterSet filterset = filtersets.PowerPortFilterSet
filterset_form = forms.PowerPortFilterForm filterset_form = forms.PowerPortFilterForm
table = tables.PowerPortTable table = tables.PowerPortTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(PowerPort) @register_model_view(PowerPort)
@ -2309,7 +2375,6 @@ class PowerOutletListView(generic.ObjectListView):
filterset = filtersets.PowerOutletFilterSet filterset = filtersets.PowerOutletFilterSet
filterset_form = forms.PowerOutletFilterForm filterset_form = forms.PowerOutletFilterForm
table = tables.PowerOutletTable table = tables.PowerOutletTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(PowerOutlet) @register_model_view(PowerOutlet)
@ -2373,7 +2438,6 @@ class InterfaceListView(generic.ObjectListView):
filterset = filtersets.InterfaceFilterSet filterset = filtersets.InterfaceFilterSet
filterset_form = forms.InterfaceFilterForm filterset_form = forms.InterfaceFilterForm
table = tables.InterfaceTable table = tables.InterfaceTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(Interface) @register_model_view(Interface)
@ -2483,7 +2547,6 @@ class FrontPortListView(generic.ObjectListView):
filterset = filtersets.FrontPortFilterSet filterset = filtersets.FrontPortFilterSet
filterset_form = forms.FrontPortFilterForm filterset_form = forms.FrontPortFilterForm
table = tables.FrontPortTable table = tables.FrontPortTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(FrontPort) @register_model_view(FrontPort)
@ -2547,7 +2610,6 @@ class RearPortListView(generic.ObjectListView):
filterset = filtersets.RearPortFilterSet filterset = filtersets.RearPortFilterSet
filterset_form = forms.RearPortFilterForm filterset_form = forms.RearPortFilterForm
table = tables.RearPortTable table = tables.RearPortTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(RearPort) @register_model_view(RearPort)
@ -2611,7 +2673,6 @@ class ModuleBayListView(generic.ObjectListView):
filterset = filtersets.ModuleBayFilterSet filterset = filtersets.ModuleBayFilterSet
filterset_form = forms.ModuleBayFilterForm filterset_form = forms.ModuleBayFilterForm
table = tables.ModuleBayTable table = tables.ModuleBayTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(ModuleBay) @register_model_view(ModuleBay)
@ -2667,7 +2728,6 @@ class DeviceBayListView(generic.ObjectListView):
filterset = filtersets.DeviceBayFilterSet filterset = filtersets.DeviceBayFilterSet
filterset_form = forms.DeviceBayFilterForm filterset_form = forms.DeviceBayFilterForm
table = tables.DeviceBayTable table = tables.DeviceBayTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(DeviceBay) @register_model_view(DeviceBay)
@ -2792,7 +2852,6 @@ class InventoryItemListView(generic.ObjectListView):
filterset = filtersets.InventoryItemFilterSet filterset = filtersets.InventoryItemFilterSet
filterset_form = forms.InventoryItemFilterForm filterset_form = forms.InventoryItemFilterForm
table = tables.InventoryItemTable table = tables.InventoryItemTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(InventoryItem) @register_model_view(InventoryItem)
@ -3065,6 +3124,19 @@ class CableEditView(generic.ObjectEditView):
return obj return obj
def get_extra_addanother_params(self, request):
params = {
'a_terminations_type': request.GET.get('a_terminations_type'),
'b_terminations_type': request.GET.get('b_terminations_type')
}
for key in request.POST:
if 'device' in key or 'power_panel' in key or 'circuit' in key:
params.update({key: request.POST.get(key)})
return params
@register_model_view(Cable, 'delete') @register_model_view(Cable, 'delete')
class CableDeleteView(generic.ObjectDeleteView): class CableDeleteView(generic.ObjectDeleteView):
@ -3429,6 +3501,11 @@ class PowerPanelBulkDeleteView(generic.BulkDeleteView):
table = tables.PowerPanelTable table = tables.PowerPanelTable
@register_model_view(PowerPanel, 'contacts')
class PowerPanelContactsView(ObjectContactsView):
queryset = PowerPanel.objects.all()
# #
# Power feeds # Power feeds
# #

View File

@ -25,7 +25,7 @@ class ConfigRevisionAdmin(admin.ModelAdmin):
'fields': ('ALLOWED_URL_SCHEMES',), 'fields': ('ALLOWED_URL_SCHEMES',),
}), }),
('Banners', { ('Banners', {
'fields': ('BANNER_LOGIN', 'BANNER_TOP', 'BANNER_BOTTOM'), 'fields': ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM'),
'classes': ('monospace',), 'classes': ('monospace',),
}), }),
('Pagination', { ('Pagination', {

View File

@ -6,7 +6,6 @@ from rest_framework import status
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from rest_framework.generics import RetrieveUpdateDestroyAPIView from rest_framework.generics import RetrieveUpdateDestroyAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.renderers import JSONRenderer from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
@ -303,7 +302,7 @@ class ScriptViewSet(ViewSet):
# Attach Job objects to each script (if any) # Attach Job objects to each script (if any)
for script in script_list: for script in script_list:
script.result = results.get(script.name, None) script.result = results.get(script.class_name, None)
serializer = serializers.ScriptSerializer(script_list, many=True, context={'request': request}) serializer = serializers.ScriptSerializer(script_list, many=True, context={'request': request})
@ -314,7 +313,7 @@ class ScriptViewSet(ViewSet):
object_type = ContentType.objects.get(app_label='extras', model='scriptmodule') object_type = ContentType.objects.get(app_label='extras', model='scriptmodule')
script.result = Job.objects.filter( script.result = Job.objects.filter(
object_type=object_type, object_type=object_type,
name=script.name, name=script.class_name,
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
).first() ).first()
serializer = serializers.ScriptDetailSerializer(script, context={'request': request}) serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
@ -368,7 +367,7 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
Retrieve a list of recent changes. Retrieve a list of recent changes.
""" """
metadata_class = ContentTypeMetadata metadata_class = ContentTypeMetadata
queryset = ObjectChange.objects.prefetch_related('user') queryset = ObjectChange.objects.valid_models().prefetch_related('user')
serializer_class = serializers.ObjectChangeSerializer serializer_class = serializers.ObjectChangeSerializer
filterset_class = filtersets.ObjectChangeFilterSet filterset_class = filtersets.ObjectChangeFilterSet
@ -381,7 +380,7 @@ class ContentTypeViewSet(ReadOnlyModelViewSet):
""" """
Read-only list of ContentTypes. Limit results to ContentTypes pertinent to NetBox objects. Read-only list of ContentTypes. Limit results to ContentTypes pertinent to NetBox objects.
""" """
permission_classes = (IsAuthenticated,) permission_classes = [IsAuthenticatedOrLoginNotRequired]
queryset = ContentType.objects.order_by('app_label', 'model') queryset = ContentType.objects.order_by('app_label', 'model')
serializer_class = serializers.ContentTypeSerializer serializer_class = serializers.ContentTypeSerializer
filterset_class = filtersets.ContentTypeFilterSet filterset_class = filtersets.ContentTypeFilterSet

View File

@ -56,11 +56,13 @@ class CustomFieldVisibilityChoices(ChoiceSet):
VISIBILITY_READ_WRITE = 'read-write' VISIBILITY_READ_WRITE = 'read-write'
VISIBILITY_READ_ONLY = 'read-only' VISIBILITY_READ_ONLY = 'read-only'
VISIBILITY_HIDDEN = 'hidden' VISIBILITY_HIDDEN = 'hidden'
VISIBILITY_HIDDEN_IFUNSET = 'hidden-ifunset'
CHOICES = ( CHOICES = (
(VISIBILITY_READ_WRITE, 'Read/Write'), (VISIBILITY_READ_WRITE, 'Read/Write'),
(VISIBILITY_READ_ONLY, 'Read-only'), (VISIBILITY_READ_ONLY, 'Read-only'),
(VISIBILITY_HIDDEN, 'Hidden'), (VISIBILITY_HIDDEN, 'Hidden'),
(VISIBILITY_HIDDEN_IFUNSET, 'Hidden (if unset)'),
) )
@ -208,7 +210,7 @@ class ChangeActionChoices(ChoiceSet):
ACTION_DELETE = 'delete' ACTION_DELETE = 'delete'
CHOICES = ( CHOICES = (
(ACTION_CREATE, 'Create'), (ACTION_CREATE, 'Create', 'green'),
(ACTION_UPDATE, 'Update'), (ACTION_UPDATE, 'Update', 'blue'),
(ACTION_DELETE, 'Delete'), (ACTION_DELETE, 'Delete', 'red'),
) )

View File

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

View File

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

View File

@ -56,10 +56,3 @@ class ScriptForm(BootstrapMixin, forms.Form):
self.cleaned_data['_schedule_at'] = local_now() self.cleaned_data['_schedule_at'] = local_now()
return self.cleaned_data return self.cleaned_data
@property
def requires_input(self):
"""
A boolean indicating whether the form requires user input (ignore the built-in fields).
"""
return bool(len(self.fields) > 3)

View File

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

View File

@ -13,6 +13,22 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='customfield', model_name='customfield',
name='name', name='name',
field=models.CharField(max_length=50, unique=True, validators=[django.core.validators.RegexValidator(flags=re.RegexFlag['IGNORECASE'], message='Only alphanumeric characters and underscores are allowed.', regex='^[a-z0-9_]+$')]), field=models.CharField(
max_length=50,
unique=True,
validators=[
django.core.validators.RegexValidator(
flags=re.RegexFlag['IGNORECASE'],
message='Only alphanumeric characters and underscores are allowed.',
regex='^[a-z0-9_]+$',
),
django.core.validators.RegexValidator(
flags=re.RegexFlag['IGNORECASE'],
inverse_match=True,
message='Double underscores are not permitted in custom field names.',
regex=r'__',
),
],
),
), ),
] ]

View File

@ -5,7 +5,7 @@ from django.db import models
from django.urls import reverse from django.urls import reverse
from extras.choices import * from extras.choices import *
from utilities.querysets import RestrictedQuerySet from ..querysets import ObjectChangeQuerySet
__all__ = ( __all__ = (
'ObjectChange', 'ObjectChange',
@ -82,7 +82,7 @@ class ObjectChange(models.Model):
null=True null=True
) )
objects = RestrictedQuerySet.as_manager() objects = ObjectChangeQuerySet.as_manager()
class Meta: class Meta:
ordering = ['-time'] ordering = ['-time']

View File

@ -85,6 +85,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
message="Only alphanumeric characters and underscores are allowed.", message="Only alphanumeric characters and underscores are allowed.",
flags=re.IGNORECASE flags=re.IGNORECASE
), ),
RegexValidator(
regex=r'__',
message="Double underscores are not permitted in custom field names.",
flags=re.IGNORECASE,
inverse_match=True
),
) )
) )
label = models.CharField( label = models.CharField(

View File

@ -9,7 +9,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache from django.core.cache import cache
from django.core.validators import ValidationError from django.core.validators import ValidationError
from django.db import models from django.db import models
from django.http import HttpResponse, QueryDict from django.http import HttpResponse
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.formats import date_format from django.utils.formats import date_format
@ -26,7 +26,7 @@ from netbox.models.features import (
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin, CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin,
) )
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.utils import clean_html, render_jinja2 from utilities.utils import clean_html, dict_to_querydict, render_jinja2
__all__ = ( __all__ = (
'ConfigRevision', 'ConfigRevision',
@ -274,10 +274,10 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
:param context: The context passed to Jinja2 :param context: The context passed to Jinja2
""" """
text = render_jinja2(self.link_text, context) text = render_jinja2(self.link_text, context).strip()
if not text: if not text:
return {} return {}
link = render_jinja2(self.link_url, context) link = render_jinja2(self.link_url, context).strip()
link_target = ' target="_blank"' if self.new_window else '' link_target = ' target="_blank"' if self.new_window else ''
# Sanitize link text # Sanitize link text
@ -285,7 +285,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
text = clean_html(text, allowed_schemes) text = clean_html(text, allowed_schemes)
# Sanitize link # Sanitize link
link = urllib.parse.quote_plus(link, safe='/:?&=%+[]@#') link = urllib.parse.quote(link, safe='/:?&=%+[]@#,;')
# Verify link scheme is allowed # Verify link scheme is allowed
result = urllib.parse.urlparse(link) result = urllib.parse.urlparse(link)
@ -462,8 +462,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
@property @property
def url_params(self): def url_params(self):
qd = QueryDict(mutable=True) qd = dict_to_querydict(self.parameters)
qd.update(self.parameters)
return qd.urlencode() return qd.urlencode()

View File

@ -1,7 +1,7 @@
import inspect import inspect
import logging
from functools import cached_property from functools import cached_property
from django.contrib.contenttypes.fields import GenericRelation
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
@ -12,6 +12,8 @@ from netbox.models.features import JobsMixin, WebhooksMixin
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from .mixins import PythonModuleMixin from .mixins import PythonModuleMixin
logger = logging.getLogger('netbox.reports')
__all__ = ( __all__ = (
'Report', 'Report',
'ReportModule', 'ReportModule',
@ -56,7 +58,8 @@ class ReportModule(PythonModuleMixin, JobsMixin, ManagedFile):
try: try:
module = self.get_module() module = self.get_module()
except ImportError: except (ImportError, SyntaxError) as e:
logger.error(f"Unable to load report module {self.name}, exception: {e}")
return {} return {}
reports = {} reports = {}
ordered = getattr(module, 'report_order', []) ordered = getattr(module, 'report_order', [])

View File

@ -112,3 +112,6 @@ class StagedChange(ChangeLoggedModel):
instance = self.model.objects.get(pk=self.object_id) instance = self.model.objects.get(pk=self.object_id)
logger.info(f'Deleting {self.model._meta.verbose_name} {instance}') logger.info(f'Deleting {self.model._meta.verbose_name} {instance}')
instance.delete() instance.delete()
def get_action_color(self):
return ChangeActionChoices.colors.get(self.action)

View File

@ -2,7 +2,6 @@ import collections
from importlib import import_module from importlib import import_module
from django.apps import AppConfig from django.apps import AppConfig
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string from django.utils.module_loading import import_string
from packaging import version from packaging import version
@ -146,23 +145,3 @@ class PluginConfig(AppConfig):
for setting, value in cls.default_settings.items(): for setting, value in cls.default_settings.items():
if setting not in user_config: if setting not in user_config:
user_config[setting] = value user_config[setting] = value
#
# Utilities
#
def get_plugin_config(plugin_name, parameter, default=None):
"""
Return the value of the specified plugin configuration parameter.
Args:
plugin_name: The name of the plugin
parameter: The name of the configuration parameter
default: The value to return if the parameter is not defined (default: None)
"""
try:
plugin_config = settings.PLUGINS_CONFIG[plugin_name]
return plugin_config.get(parameter, default)
except KeyError:
raise ImproperlyConfigured(f"Plugin {plugin_name} is not registered.")

View File

@ -0,0 +1,37 @@
from django.apps import apps
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
__all__ = (
'get_installed_plugins',
'get_plugin_config',
)
def get_installed_plugins():
"""
Return a dictionary mapping the names of installed plugins to their versions.
"""
plugins = {}
for plugin_name in settings.PLUGINS:
plugin_name = plugin_name.rsplit('.', 1)[-1]
plugin_config = apps.get_app_config(plugin_name)
plugins[plugin_name] = getattr(plugin_config, 'version', None)
return dict(sorted(plugins.items()))
def get_plugin_config(plugin_name, parameter, default=None):
"""
Return the value of the specified plugin configuration parameter.
Args:
plugin_name: The name of the plugin
parameter: The name of the configuration parameter
default: The value to return if the parameter is not defined (default: None)
"""
try:
plugin_config = settings.PLUGINS_CONFIG[plugin_name]
return plugin_config.get(parameter, default)
except KeyError:
raise ImproperlyConfigured(f"Plugin {plugin_name} is not registered.")

View File

@ -1,5 +1,8 @@
from django.apps import apps
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.aggregates import JSONBAgg from django.contrib.postgres.aggregates import JSONBAgg
from django.db.models import OuterRef, Subquery, Q from django.db.models import OuterRef, Subquery, Q
from django.db.utils import ProgrammingError
from extras.models.tags import TaggedItem from extras.models.tags import TaggedItem
from utilities.query_functions import EmptyGroupByJSONBAgg from utilities.query_functions import EmptyGroupByJSONBAgg
@ -151,3 +154,20 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
) )
return base_query return base_query
class ObjectChangeQuerySet(RestrictedQuerySet):
def valid_models(self):
# Exclude any change records which refer to an instance of a model that's no longer installed. This
# can happen when a plugin is removed but its data remains in the database, for example.
try:
content_types = ContentType.objects.get_for_models(*apps.get_models()).values()
except ProgrammingError:
# Handle the case where the database schema has not yet been initialized
content_types = ContentType.objects.none()
content_type_ids = set(
ct.pk for ct in content_types
)
return self.filter(changed_object_type_id__in=content_type_ids)

View File

@ -214,20 +214,18 @@ class Report(object):
self.active_test = method_name self.active_test = method_name
test_method = getattr(self, method_name) test_method = getattr(self, method_name)
test_method() test_method()
job.data = self._results
if self.failed: if self.failed:
self.logger.warning("Report failed") self.logger.warning("Report failed")
job.status = JobStatusChoices.STATUS_FAILED job.terminate(status=JobStatusChoices.STATUS_FAILED)
else: else:
self.logger.info("Report completed successfully") self.logger.info("Report completed successfully")
job.status = JobStatusChoices.STATUS_COMPLETED job.terminate()
except Exception as e: except Exception as e:
stacktrace = traceback.format_exc() stacktrace = traceback.format_exc()
self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>") self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>")
logger.error(f"Exception raised during report execution: {e}") logger.error(f"Exception raised during report execution: {e}")
job.terminate(status=JobStatusChoices.STATUS_ERRORED) job.terminate(status=JobStatusChoices.STATUS_ERRORED)
finally:
job.data = self._results
job.terminate()
# Perform any post-run tasks # Perform any post-run tasks
self.post_run() self.post_run()

View File

@ -366,7 +366,7 @@ class BaseScript:
if self.fieldsets: if self.fieldsets:
fieldsets.extend(self.fieldsets) fieldsets.extend(self.fieldsets)
else: else:
fields = (name for name, _ in self._get_vars().items()) fields = list(name for name, _ in self._get_vars().items())
fieldsets.append(('Script Data', fields)) fieldsets.append(('Script Data', fields))
# Append the default fieldset if defined in the Meta class # Append the default fieldset if defined in the Meta class
@ -390,6 +390,11 @@ class BaseScript:
# Set initial "commit" checkbox state based on the script's Meta parameter # Set initial "commit" checkbox state based on the script's Meta parameter
form.fields['_commit'].initial = self.commit_default form.fields['_commit'].initial = self.commit_default
# Hide fields if scheduling has been disabled
if not self.scheduling_enabled:
form.fields['_schedule_at'].widget = forms.HiddenInput()
form.fields['_interval'].widget = forms.HiddenInput()
return form return form
# Logging # Logging

View File

@ -22,6 +22,14 @@ __all__ = (
'WebhookTable', 'WebhookTable',
) )
IMAGEATTACHMENT_IMAGE = '''
{% if record.image %}
<a class="image-preview" href="{{ record.image.url }}" target="_blank">{{ record }}</a>
{% else %}
&mdash;
{% endif %}
'''
class CustomFieldTable(NetBoxTable): class CustomFieldTable(NetBoxTable):
name = tables.Column( name = tables.Column(
@ -73,6 +81,7 @@ class ExportTemplateTable(NetBoxTable):
linkify=True linkify=True
) )
is_synced = columns.BooleanColumn( is_synced = columns.BooleanColumn(
orderable=False,
verbose_name='Synced' verbose_name='Synced'
) )
@ -95,6 +104,9 @@ class ImageAttachmentTable(NetBoxTable):
parent = tables.Column( parent = tables.Column(
linkify=True linkify=True
) )
image = tables.TemplateColumn(
template_code=IMAGEATTACHMENT_IMAGE,
)
size = tables.Column( size = tables.Column(
orderable=False, orderable=False,
verbose_name='Size (bytes)' verbose_name='Size (bytes)'
@ -218,6 +230,7 @@ class ConfigContextTable(NetBoxTable):
verbose_name='Active' verbose_name='Active'
) )
is_synced = columns.BooleanColumn( is_synced = columns.BooleanColumn(
orderable=False,
verbose_name='Synced' verbose_name='Synced'
) )
@ -242,6 +255,7 @@ class ConfigTemplateTable(NetBoxTable):
linkify=True linkify=True
) )
is_synced = columns.BooleanColumn( is_synced = columns.BooleanColumn(
orderable=False,
verbose_name='Synced' verbose_name='Synced'
) )
tags = columns.TagColumn( tags = columns.TagColumn(

View File

@ -8,7 +8,6 @@ from rest_framework import status
from core.choices import ManagedFileRootPathChoices from core.choices import ManagedFileRootPathChoices
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
from extras.api.views import ReportViewSet, ScriptViewSet
from extras.models import * from extras.models import *
from extras.reports import Report from extras.reports import Report
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
@ -579,6 +578,7 @@ class ReportTest(APITestCase):
super().setUp() super().setUp()
# Monkey-patch the API viewset's _get_report() method to return our test Report above # Monkey-patch the API viewset's _get_report() method to return our test Report above
from extras.api.views import ReportViewSet
ReportViewSet._get_report = self.get_test_report ReportViewSet._get_report = self.get_test_report
def test_get_report(self): def test_get_report(self):
@ -621,6 +621,7 @@ class ScriptTest(APITestCase):
super().setUp() super().setUp()
# Monkey-patch the API viewset's _get_script() method to return our test Script above # Monkey-patch the API viewset's _get_script() method to return our test Script above
from extras.api.views import ScriptViewSet
ScriptViewSet._get_script = self.get_test_script ScriptViewSet._get_script = self.get_test_script
def test_get_script(self): def test_get_script(self):

View File

@ -29,6 +29,17 @@ class CustomFieldTest(TestCase):
cls.object_type = ContentType.objects.get_for_model(Site) cls.object_type = ContentType.objects.get_for_model(Site)
def test_invalid_name(self):
"""
Try creating a CustomField with an invalid name.
"""
with self.assertRaises(ValidationError):
# Invalid character
CustomField(name='?', type=CustomFieldTypeChoices.TYPE_TEXT).full_clean()
with self.assertRaises(ValidationError):
# Double underscores not permitted
CustomField(name='foo__bar', type=CustomFieldTypeChoices.TYPE_TEXT).full_clean()
def test_text_field(self): def test_text_field(self):
value = 'Foobar!' value = 'Foobar!'

View File

@ -5,8 +5,9 @@ from django.core.exceptions import ImproperlyConfigured
from django.test import Client, TestCase, override_settings from django.test import Client, TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from extras.plugins import PluginMenu, get_plugin_config from extras.plugins import PluginMenu
from extras.tests.dummy_plugin import config as dummy_config from extras.tests.dummy_plugin import config as dummy_config
from extras.plugins.utils import get_plugin_config
from netbox.graphql.schema import Query from netbox.graphql.schema import Query
from netbox.registry import registry from netbox.registry import registry

View File

@ -31,8 +31,8 @@ class WebhookTest(APITestCase):
def setUpTestData(cls): def setUpTestData(cls):
site_ct = ContentType.objects.get_for_model(Site) site_ct = ContentType.objects.get_for_model(Site)
DUMMY_URL = "http://localhost/" DUMMY_URL = 'http://localhost:9000/'
DUMMY_SECRET = "LOOKATMEIMASECRETSTRING" DUMMY_SECRET = 'LOOKATMEIMASECRETSTRING'
webhooks = Webhook.objects.bulk_create(( webhooks = Webhook.objects.bulk_create((
Webhook(name='Webhook 1', type_create=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET, additional_headers='X-Foo: Bar'), Webhook(name='Webhook 1', type_create=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET, additional_headers='X-Foo: Bar'),
@ -259,7 +259,7 @@ class WebhookTest(APITestCase):
name='Conditional Webhook', name='Conditional Webhook',
type_create=True, type_create=True,
type_update=True, type_update=True,
payload_url='http://localhost/', payload_url='http://localhost:9000/',
conditions={ conditions={
'and': [ 'and': [
{ {

View File

@ -511,7 +511,7 @@ class ConfigTemplateBulkSyncDataView(generic.BulkSyncDataView):
# #
class ObjectChangeListView(generic.ObjectListView): class ObjectChangeListView(generic.ObjectListView):
queryset = ObjectChange.objects.all() queryset = ObjectChange.objects.valid_models()
filterset = filtersets.ObjectChangeFilterSet filterset = filtersets.ObjectChangeFilterSet
filterset_form = forms.ObjectChangeFilterForm filterset_form = forms.ObjectChangeFilterForm
table = tables.ObjectChangeTable table = tables.ObjectChangeTable
@ -521,10 +521,10 @@ class ObjectChangeListView(generic.ObjectListView):
@register_model_view(ObjectChange) @register_model_view(ObjectChange)
class ObjectChangeView(generic.ObjectView): class ObjectChangeView(generic.ObjectView):
queryset = ObjectChange.objects.all() queryset = ObjectChange.objects.valid_models()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
related_changes = ObjectChange.objects.restrict(request.user, 'view').filter( related_changes = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
request_id=instance.request_id request_id=instance.request_id
).exclude( ).exclude(
pk=instance.pk pk=instance.pk
@ -534,7 +534,7 @@ class ObjectChangeView(generic.ObjectView):
orderable=False orderable=False
) )
objectchanges = ObjectChange.objects.restrict(request.user, 'view').filter( objectchanges = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
changed_object_type=instance.changed_object_type, changed_object_type=instance.changed_object_type,
changed_object_id=instance.changed_object_id, changed_object_id=instance.changed_object_id,
) )

View File

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

View File

@ -218,12 +218,13 @@ class VLANGroupSerializer(NetBoxModelSerializer):
scope_id = serializers.IntegerField(allow_null=True, required=False, default=None) scope_id = serializers.IntegerField(allow_null=True, required=False, default=None)
scope = serializers.SerializerMethodField(read_only=True) scope = serializers.SerializerMethodField(read_only=True)
vlan_count = serializers.IntegerField(read_only=True) vlan_count = serializers.IntegerField(read_only=True)
utilization = serializers.CharField(read_only=True)
class Meta: class Meta:
model = VLANGroup model = VLANGroup
fields = [ fields = [
'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid', 'max_vid', 'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid', 'max_vid',
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization'
] ]
validators = [] validators = []

View File

@ -1,5 +1,7 @@
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 F
from django.db.models.functions import Round
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django_pglocks import advisory_lock from django_pglocks import advisory_lock
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
@ -145,9 +147,7 @@ class FHRPGroupAssignmentViewSet(NetBoxModelViewSet):
class VLANGroupViewSet(NetBoxModelViewSet): class VLANGroupViewSet(NetBoxModelViewSet):
queryset = VLANGroup.objects.annotate( queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
vlan_count=count_related(VLAN, 'group')
).prefetch_related('tags')
serializer_class = serializers.VLANGroupSerializer serializer_class = serializers.VLANGroupSerializer
filterset_class = filtersets.VLANGroupFilterSet filterset_class = filtersets.VLANGroupFilterSet
@ -224,7 +224,10 @@ class AvailableASNsView(ObjectValidationMixin, APIView):
return Response(serializer.data) return Response(serializer.data)
@extend_schema(methods=["post"], responses={201: serializers.ASNSerializer(many=True)}) @extend_schema(methods=["post"],
responses={201: serializers.ASNSerializer(many=True)},
request=serializers.ASNSerializer(many=True),
)
@advisory_lock(ADVISORY_LOCK_KEYS['available-asns']) @advisory_lock(ADVISORY_LOCK_KEYS['available-asns'])
def post(self, request, pk): def post(self, request, pk):
self.queryset = self.queryset.restrict(request.user, 'add') self.queryset = self.queryset.restrict(request.user, 'add')
@ -293,7 +296,10 @@ class AvailablePrefixesView(ObjectValidationMixin, APIView):
return Response(serializer.data) return Response(serializer.data)
@extend_schema(methods=["post"], responses={201: serializers.PrefixSerializer(many=True)}) @extend_schema(methods=["post"],
responses={201: serializers.PrefixSerializer(many=True)},
request=serializers.PrefixSerializer(many=True),
)
@advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes']) @advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes'])
def post(self, request, pk): def post(self, request, pk):
self.queryset = self.queryset.restrict(request.user, 'add') self.queryset = self.queryset.restrict(request.user, 'add')
@ -388,7 +394,10 @@ class AvailableIPAddressesView(ObjectValidationMixin, APIView):
return Response(serializer.data) return Response(serializer.data)
@extend_schema(methods=["post"], responses={201: serializers.IPAddressSerializer(many=True)}) @extend_schema(methods=["post"],
responses={201: serializers.IPAddressSerializer(many=True)},
request=serializers.IPAddressSerializer(many=True),
)
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips']) @advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
def post(self, request, pk): def post(self, request, pk):
self.queryset = self.queryset.restrict(request.user, 'add') self.queryset = self.queryset.restrict(request.user, 'add')
@ -468,7 +477,10 @@ class AvailableVLANsView(ObjectValidationMixin, APIView):
return Response(serializer.data) return Response(serializer.data)
@extend_schema(methods=["post"], responses={201: serializers.VLANSerializer(many=True)}) @extend_schema(methods=["post"],
responses={201: serializers.VLANSerializer(many=True)},
request=serializers.VLANSerializer(many=True),
)
@advisory_lock(ADVISORY_LOCK_KEYS['available-vlans']) @advisory_lock(ADVISORY_LOCK_KEYS['available-vlans'])
def post(self, request, pk): def post(self, request, pk):
self.queryset = self.queryset.restrict(request.user, 'add') self.queryset = self.queryset.restrict(request.user, 'add')

View File

@ -4,6 +4,8 @@ from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Q from django.db.models import Q
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from drf_spectacular.utils import extend_schema_field
from drf_spectacular.types import OpenApiTypes
from netaddr.core import AddrFormatError from netaddr.core import AddrFormatError
from dcim.models import Device, Interface, Region, Site, SiteGroup from dcim.models import Device, Interface, Region, Site, SiteGroup
@ -414,6 +416,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
except (AddrFormatError, ValueError): except (AddrFormatError, ValueError):
return queryset.none() return queryset.none()
@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:
return queryset.none return queryset.none
@ -588,6 +591,10 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
method='_assigned_to_interface', method='_assigned_to_interface',
label=_('Is assigned to an interface'), label=_('Is assigned to an interface'),
) )
assigned = django_filters.BooleanFilter(
method='_assigned',
label=_('Is assigned'),
)
status = django_filters.MultipleChoiceFilter( status = django_filters.MultipleChoiceFilter(
choices=IPAddressStatusChoices, choices=IPAddressStatusChoices,
null_value=None null_value=None
@ -659,6 +666,7 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
return queryset return queryset
return queryset.filter(address__net_mask_length=value) return queryset.filter(address__net_mask_length=value)
@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:
return queryset.none return queryset.none
@ -702,6 +710,18 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
assigned_object_id__isnull=False assigned_object_id__isnull=False
) )
def _assigned(self, queryset, name, value):
if value:
return queryset.exclude(
assigned_object_type__isnull=True,
assigned_object_id__isnull=True
)
else:
return queryset.filter(
assigned_object_type__isnull=True,
assigned_object_id__isnull=True
)
class FHRPGroupFilterSet(NetBoxModelFilterSet): class FHRPGroupFilterSet(NetBoxModelFilterSet):
protocol = django_filters.MultipleChoiceFilter( protocol = django_filters.MultipleChoiceFilter(
@ -727,6 +747,7 @@ class FHRPGroupFilterSet(NetBoxModelFilterSet):
Q(name__icontains=value) Q(name__icontains=value)
) )
@extend_schema_field(OpenApiTypes.STR)
def filter_related_ip(self, queryset, name, value): def filter_related_ip(self, queryset, name, value):
""" """
Filter by VRF & prefix of assigned IP addresses. Filter by VRF & prefix of assigned IP addresses.
@ -941,9 +962,11 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
pass pass
return queryset.filter(qs_filter) return queryset.filter(qs_filter)
@extend_schema_field(OpenApiTypes.STR)
def get_for_device(self, queryset, name, value): def get_for_device(self, queryset, name, value):
return queryset.get_for_device(value) return queryset.get_for_device(value)
@extend_schema_field(OpenApiTypes.STR)
def get_for_virtualmachine(self, queryset, name, value): def get_for_virtualmachine(self, queryset, name, value):
return queryset.get_for_virtualmachine(value) return queryset.get_for_virtualmachine(value)

View File

@ -9,7 +9,9 @@ from ipam.constants import *
from ipam.models import * from ipam.models import *
from netbox.forms import NetBoxModelImportForm from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField from utilities.forms.fields import (
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, SlugField
)
from virtualization.models import VirtualMachine, VMInterface from virtualization.models import VirtualMachine, VMInterface
__all__ = ( __all__ = (
@ -40,10 +42,25 @@ class VRFImportForm(NetBoxModelImportForm):
to_field_name='name', to_field_name='name',
help_text=_('Assigned tenant') help_text=_('Assigned tenant')
) )
import_targets = CSVModelMultipleChoiceField(
queryset=RouteTarget.objects.all(),
required=False,
to_field_name='name',
help_text=_('Import route targets')
)
export_targets = CSVModelMultipleChoiceField(
queryset=RouteTarget.objects.all(),
required=False,
to_field_name='name',
help_text=_('Export route targets')
)
class Meta: class Meta:
model = VRF model = VRF
fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description', 'comments', 'tags') fields = (
'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'comments',
'tags',
)
class RouteTargetImportForm(NetBoxModelImportForm): class RouteTargetImportForm(NetBoxModelImportForm):
@ -181,16 +198,31 @@ class PrefixImportForm(NetBoxModelImportForm):
def __init__(self, data=None, *args, **kwargs): def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs) super().__init__(data, *args, **kwargs)
if data: if not data:
return
# Limit VLAN queryset by assigned site and/or group (if specified) site = data.get('site')
params = {} vlan_group = data.get('vlan_group')
if data.get('site'):
params[f"site__{self.fields['site'].to_field_name}"] = data.get('site') # Limit VLAN queryset by assigned site and/or group (if specified)
if data.get('vlan_group'): query = Q()
params[f"group__{self.fields['vlan_group'].to_field_name}"] = data.get('vlan_group')
if params: if site:
self.fields['vlan'].queryset = self.fields['vlan'].queryset.filter(**params) query |= Q(**{
f"site__{self.fields['site'].to_field_name}": site
})
# Don't Forget to include VLANs without a site in the filter
query |= Q(**{
f"site__{self.fields['site'].to_field_name}__isnull": True
})
if vlan_group:
query &= Q(**{
f"group__{self.fields['vlan_group'].to_field_name}": vlan_group
})
queryset = self.fields['vlan'].queryset.filter(query)
self.fields['vlan'].queryset = queryset
class IPRangeImportForm(NetBoxModelImportForm): class IPRangeImportForm(NetBoxModelImportForm):

View File

@ -253,7 +253,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = IPRange model = IPRange
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attriubtes', ('family', 'vrf_id', 'status', 'role_id', 'mark_utilized')), ('Attributes', ('family', 'vrf_id', 'status', 'role_id', 'mark_utilized')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
) )
family = forms.ChoiceField( family = forms.ChoiceField(

View File

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

View File

@ -24,11 +24,11 @@ class IPAddressAssignmentType(graphene.Union):
@classmethod @classmethod
def resolve_type(cls, instance, info): def resolve_type(cls, instance, info):
if type(instance) == Interface: if type(instance) is Interface:
return InterfaceType return InterfaceType
if type(instance) == FHRPGroup: if type(instance) is FHRPGroup:
return FHRPGroupType return FHRPGroupType
if type(instance) == VMInterface: if type(instance) is VMInterface:
return VMInterfaceType return VMInterfaceType
@ -42,11 +42,11 @@ class L2VPNAssignmentType(graphene.Union):
@classmethod @classmethod
def resolve_type(cls, instance, info): def resolve_type(cls, instance, info):
if type(instance) == Interface: if type(instance) is Interface:
return InterfaceType return InterfaceType
if type(instance) == VLAN: if type(instance) is VLAN:
return VLANType return VLANType
if type(instance) == VMInterface: if type(instance) is VMInterface:
return VMInterfaceType return VMInterfaceType
@ -59,9 +59,9 @@ class FHRPGroupInterfaceType(graphene.Union):
@classmethod @classmethod
def resolve_type(cls, instance, info): def resolve_type(cls, instance, info):
if type(instance) == Interface: if type(instance) is Interface:
return InterfaceType return InterfaceType
if type(instance) == VMInterface: if type(instance) is VMInterface:
return VMInterfaceType return VMInterfaceType
@ -79,17 +79,17 @@ class VLANGroupScopeType(graphene.Union):
@classmethod @classmethod
def resolve_type(cls, instance, info): def resolve_type(cls, instance, info):
if type(instance) == Cluster: if type(instance) is Cluster:
return ClusterType return ClusterType
if type(instance) == ClusterGroup: if type(instance) is ClusterGroup:
return ClusterGroupType return ClusterGroupType
if type(instance) == Location: if type(instance) is Location:
return LocationType return LocationType
if type(instance) == Rack: if type(instance) is Rack:
return RackType return RackType
if type(instance) == Region: if type(instance) is Region:
return RegionType return RegionType
if type(instance) == Site: if type(instance) is Site:
return SiteType return SiteType
if type(instance) == SiteGroup: if type(instance) is SiteGroup:
return SiteGroupType return SiteGroupType

View File

@ -4,6 +4,7 @@ from django.urls import reverse
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from ipam.fields import ASNField from ipam.fields import ASNField
from ipam.querysets import ASNRangeQuerySet
from netbox.models import OrganizationalModel, PrimaryModel from netbox.models import OrganizationalModel, PrimaryModel
__all__ = ( __all__ = (
@ -37,6 +38,8 @@ class ASNRange(OrganizationalModel):
null=True null=True
) )
objects = ASNRangeQuerySet.as_manager()
class Meta: class Meta:
ordering = ('name',) ordering = ('name',)
verbose_name = 'ASN range' verbose_name = 'ASN range'

View File

@ -406,7 +406,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
Return all available IPs within this prefix as an IPSet. Return all available IPs within this prefix as an IPSet.
""" """
if self.mark_utilized: if self.mark_utilized:
return list() return netaddr.IPSet()
prefix = netaddr.IPSet(self.prefix) prefix = netaddr.IPSet(self.prefix)
child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()]) child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])

View File

@ -9,7 +9,7 @@ from django.utils.translation import gettext as _
from dcim.models import Interface from dcim.models import Interface
from ipam.choices import * from ipam.choices import *
from ipam.constants import * from ipam.constants import *
from ipam.querysets import VLANQuerySet from ipam.querysets import VLANQuerySet, VLANGroupQuerySet
from netbox.models import OrganizationalModel, PrimaryModel from netbox.models import OrganizationalModel, PrimaryModel
from virtualization.models import VMInterface from virtualization.models import VMInterface
@ -63,6 +63,8 @@ class VLANGroup(OrganizationalModel):
help_text=_('Highest permissible ID of a child VLAN') help_text=_('Highest permissible ID of a child VLAN')
) )
objects = VLANGroupQuerySet.as_manager()
class Meta: class Meta:
ordering = ('name', 'pk') # Name may be non-unique ordering = ('name', 'pk') # Name may be non-unique
constraints = ( constraints = (

View File

@ -1,8 +1,34 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Q from django.db.models import Count, F, OuterRef, Q, Subquery, Value
from django.db.models.expressions import RawSQL from django.db.models.expressions import RawSQL
from django.db.models.functions import Round
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.utils import count_related
__all__ = (
'ASNRangeQuerySet',
'PrefixQuerySet',
'VLANQuerySet',
)
class ASNRangeQuerySet(RestrictedQuerySet):
def annotate_asn_counts(self):
"""
Annotate the number of ASNs which appear within each range.
"""
from .models import ASN
# Because ASN does not have a foreign key to ASNRange, we create a fake column "_" with a consistent value
# that we can use to count ASNs and return a single value per ASNRange.
asns = ASN.objects.filter(
asn__gte=OuterRef('start'),
asn__lte=OuterRef('end')
).order_by().annotate(_=Value(1)).values('_').annotate(c=Count('*')).values('c')
return self.annotate(asn_count=Subquery(asns))
class PrefixQuerySet(RestrictedQuerySet): class PrefixQuerySet(RestrictedQuerySet):
@ -30,6 +56,17 @@ class PrefixQuerySet(RestrictedQuerySet):
) )
class VLANGroupQuerySet(RestrictedQuerySet):
def annotate_utilization(self):
from .models import VLAN
return self.annotate(
vlan_count=count_related(VLAN, 'group'),
utilization=Round(F('vlan_count') / (F('max_vid') - F('min_vid') + 1.0) * 100, 2)
)
class VLANQuerySet(RestrictedQuerySet): class VLANQuerySet(RestrictedQuerySet):
def get_for_device(self, device): def get_for_device(self, device):

View File

@ -21,10 +21,8 @@ class ASNRangeTable(TenancyColumnsMixin, NetBoxTable):
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='ipam:asnrange_list' url_name='ipam:asnrange_list'
) )
asn_count = columns.LinkedCountColumn( asn_count = tables.Column(
viewname='ipam:asn_list', verbose_name=_('ASNs')
url_params={'asn_id': 'pk'},
verbose_name=_('ASN Count')
) )
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
@ -59,7 +57,8 @@ class ASNTable(TenancyColumnsMixin, NetBoxTable):
verbose_name=_('Provider Count') verbose_name=_('Provider Count')
) )
sites = columns.ManyToManyColumn( sites = columns.ManyToManyColumn(
linkify_item=True linkify_item=True,
verbose_name=_('Sites')
) )
comments = columns.MarkdownColumn() comments = columns.MarkdownColumn()
tags = columns.TagColumn( tags = columns.TagColumn(

View File

@ -19,14 +19,22 @@ __all__ = (
AVAILABLE_LABEL = mark_safe('<span class="badge bg-success">Available</span>') AVAILABLE_LABEL = mark_safe('<span class="badge bg-success">Available</span>')
AGGREGATE_COPY_BUTTON = """
{% copy_content record.pk prefix="aggregate_" %}
"""
PREFIX_LINK = """ PREFIX_LINK = """
{% if record.pk %} {% if record.pk %}
<a href="{{ record.get_absolute_url }}">{{ record.prefix }}</a> <a href="{{ record.get_absolute_url }}" id="prefix_{{ record.pk }}">{{ record.prefix }}</a>
{% else %} {% else %}
<a href="{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.site %}&site={{ object.site.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}">{{ record.prefix }}</a> <a href="{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.site %}&site={{ object.site.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}">{{ record.prefix }}</a>
{% endif %} {% endif %}
""" """
PREFIX_COPY_BUTTON = """
{% copy_content record.pk prefix="prefix_" %}
"""
PREFIX_LINK_WITH_DEPTH = """ PREFIX_LINK_WITH_DEPTH = """
{% load helpers %} {% load helpers %}
{% if record.depth %} {% if record.depth %}
@ -40,7 +48,7 @@ PREFIX_LINK_WITH_DEPTH = """
IPADDRESS_LINK = """ IPADDRESS_LINK = """
{% if record.pk %} {% if record.pk %}
<a href="{{ record.get_absolute_url }}">{{ record.address }}</a> <a href="{{ record.get_absolute_url }}" id="ipaddress_{{ record.pk }}">{{ record.address }}</a>
{% elif perms.ipam.add_ipaddress %} {% elif perms.ipam.add_ipaddress %}
<a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.tenant %}&tenant={{ object.tenant.pk }}{% endif %}" class="btn btn-sm btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available</a> <a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.tenant %}&tenant={{ object.tenant.pk }}{% endif %}" class="btn btn-sm btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available</a>
{% else %} {% else %}
@ -48,6 +56,10 @@ IPADDRESS_LINK = """
{% endif %} {% endif %}
""" """
IPADDRESS_COPY_BUTTON = """
{% copy_content record.pk prefix="ipaddress_" %}
"""
IPADDRESS_ASSIGN_LINK = """ IPADDRESS_ASSIGN_LINK = """
<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?{% if request.GET.interface %}interface={{ request.GET.interface }}{% elif request.GET.vminterface %}vminterface={{ request.GET.vminterface }}{% endif %}&return_url={{ request.GET.return_url }}">{{ record }}</a> <a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?{% if request.GET.interface %}interface={{ request.GET.interface }}{% elif request.GET.vminterface %}vminterface={{ request.GET.vminterface }}{% endif %}&return_url={{ request.GET.return_url }}">{{ record }}</a>
""" """
@ -99,7 +111,11 @@ class RIRTable(NetBoxTable):
class AggregateTable(TenancyColumnsMixin, NetBoxTable): class AggregateTable(TenancyColumnsMixin, NetBoxTable):
prefix = tables.Column( prefix = tables.Column(
linkify=True, linkify=True,
verbose_name='Aggregate' verbose_name='Aggregate',
attrs={
# Allow the aggregate to be copied to the clipboard
'a': {'id': lambda record: f"aggregate_{record.pk}"}
}
) )
date_added = tables.DateColumn( date_added = tables.DateColumn(
format="Y-m-d", format="Y-m-d",
@ -116,6 +132,9 @@ class AggregateTable(TenancyColumnsMixin, NetBoxTable):
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='ipam:aggregate_list' url_name='ipam:aggregate_list'
) )
actions = columns.ActionsColumn(
extra_buttons=AGGREGATE_COPY_BUTTON
)
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = Aggregate model = Aggregate
@ -242,6 +261,9 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable):
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='ipam:prefix_list' url_name='ipam:prefix_list'
) )
actions = columns.ActionsColumn(
extra_buttons=PREFIX_COPY_BUTTON
)
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = Prefix model = Prefix
@ -348,6 +370,9 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='ipam:ipaddress_list' url_name='ipam:ipaddress_list'
) )
actions = columns.ActionsColumn(
extra_buttons=IPADDRESS_COPY_BUTTON
)
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = IPAddress model = IPAddress

View File

@ -70,6 +70,10 @@ class VLANGroupTable(NetBoxTable):
url_params={'group_id': 'pk'}, url_params={'group_id': 'pk'},
verbose_name='VLANs' verbose_name='VLANs'
) )
utilization = columns.UtilizationColumn(
orderable=False,
verbose_name='Utilization'
)
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='ipam:vlangroup_list' url_name='ipam:vlangroup_list'
) )
@ -81,9 +85,9 @@ class VLANGroupTable(NetBoxTable):
model = VLANGroup model = VLANGroup
fields = ( fields = (
'pk', 'id', 'name', 'scope_type', 'scope', 'min_vid', 'max_vid', 'vlan_count', 'slug', 'description', 'pk', 'id', 'name', 'scope_type', 'scope', 'min_vid', 'max_vid', 'vlan_count', 'slug', 'description',
'tags', 'created', 'last_updated', 'actions', 'tags', 'created', 'last_updated', 'actions', 'utilization',
) )
default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description') default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'utilization', 'description')
# #

View File

@ -992,6 +992,12 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'fhrpgroup_id': [fhrp_groups[0].pk, fhrp_groups[1].pk]} params = {'fhrpgroup_id': [fhrp_groups[0].pk, fhrp_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_assigned(self):
params = {'assigned': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
params = {'assigned': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_assigned_to_interface(self): def test_assigned_to_interface(self):
params = {'assigned_to_interface': 'true'} params = {'assigned_to_interface': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)

View File

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

View File

@ -121,7 +121,7 @@ def add_available_vlans(vlans, vlan_group=None):
}) })
vlans = list(vlans) + new_vlans vlans = list(vlans) + new_vlans
vlans.sort(key=lambda v: v.vid if type(v) == VLAN else v['vid']) vlans.sort(key=lambda v: v.vid if type(v) is VLAN else v['vid'])
return vlans return vlans

View File

@ -1,6 +1,7 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Prefetch from django.db.models import F, Prefetch
from django.db.models.expressions import RawSQL from django.db.models.expressions import RawSQL
from django.db.models.functions import Round
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -9,6 +10,7 @@ from circuits.models import Provider
from dcim.filtersets import InterfaceFilterSet from dcim.filtersets import InterfaceFilterSet
from dcim.models import Interface, Site from dcim.models import Interface, Site
from netbox.views import generic from netbox.views import generic
from tenancy.views import ObjectContactsView
from utilities.utils import count_related from utilities.utils import count_related
from utilities.views import ViewTab, register_model_view from utilities.views import ViewTab, register_model_view
from virtualization.filtersets import VMInterfaceFilterSet from virtualization.filtersets import VMInterfaceFilterSet
@ -197,7 +199,7 @@ class RIRBulkDeleteView(generic.BulkDeleteView):
# #
class ASNRangeListView(generic.ObjectListView): class ASNRangeListView(generic.ObjectListView):
queryset = ASNRange.objects.all() queryset = ASNRange.objects.annotate_asn_counts()
filterset = filtersets.ASNRangeFilterSet filterset = filtersets.ASNRangeFilterSet
filterset_form = forms.ASNRangeFilterForm filterset_form = forms.ASNRangeFilterForm
table = tables.ASNRangeTable table = tables.ASNRangeTable
@ -214,7 +216,7 @@ class ASNRangeASNsView(generic.ObjectChildrenView):
child_model = ASN child_model = ASN
table = tables.ASNTable table = tables.ASNTable
filterset = filtersets.ASNFilterSet filterset = filtersets.ASNFilterSet
template_name = 'ipam/asnrange/asns.html' template_name = 'generic/object_children.html'
tab = ViewTab( tab = ViewTab(
label=_('ASNs'), label=_('ASNs'),
badge=lambda x: x.get_child_asns().count(), badge=lambda x: x.get_child_asns().count(),
@ -246,18 +248,14 @@ class ASNRangeBulkImportView(generic.BulkImportView):
class ASNRangeBulkEditView(generic.BulkEditView): class ASNRangeBulkEditView(generic.BulkEditView):
queryset = ASNRange.objects.annotate( queryset = ASNRange.objects.annotate_asn_counts()
site_count=count_related(Site, 'asns')
)
filterset = filtersets.ASNRangeFilterSet filterset = filtersets.ASNRangeFilterSet
table = tables.ASNRangeTable table = tables.ASNRangeTable
form = forms.ASNRangeBulkEditForm form = forms.ASNRangeBulkEditForm
class ASNRangeBulkDeleteView(generic.BulkDeleteView): class ASNRangeBulkDeleteView(generic.BulkDeleteView):
queryset = ASNRange.objects.annotate( queryset = ASNRange.objects.annotate_asn_counts()
site_count=count_related(Site, 'asns')
)
filterset = filtersets.ASNRangeFilterSet filterset = filtersets.ASNRangeFilterSet
table = tables.ASNRangeTable table = tables.ASNRangeTable
@ -818,7 +816,6 @@ class IPAddressAssignView(generic.ObjectView):
table = None table = None
if form.is_valid(): if form.is_valid():
addresses = self.queryset.prefetch_related('vrf', 'tenant') addresses = self.queryset.prefetch_related('vrf', 'tenant')
# Limit to 100 results # Limit to 100 results
addresses = filtersets.IPAddressFilterSet(request.POST, addresses).qs[:100] addresses = filtersets.IPAddressFilterSet(request.POST, addresses).qs[:100]
@ -868,7 +865,7 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView):
child_model = IPAddress child_model = IPAddress
table = tables.IPAddressTable table = tables.IPAddressTable
filterset = filtersets.IPAddressFilterSet filterset = filtersets.IPAddressFilterSet
template_name = 'ipam/ipaddress/ip_addresses.html' template_name = 'generic/object_children.html'
tab = ViewTab( tab = ViewTab(
label=_('Related IPs'), label=_('Related IPs'),
badge=lambda x: x.get_related_ips().count(), badge=lambda x: x.get_related_ips().count(),
@ -885,9 +882,7 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView):
# #
class VLANGroupListView(generic.ObjectListView): class VLANGroupListView(generic.ObjectListView):
queryset = VLANGroup.objects.annotate( queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
vlan_count=count_related(VLAN, 'group')
)
filterset = filtersets.VLANGroupFilterSet filterset = filtersets.VLANGroupFilterSet
filterset_form = forms.VLANGroupFilterForm filterset_form = forms.VLANGroupFilterForm
table = tables.VLANGroupTable table = tables.VLANGroupTable
@ -895,7 +890,7 @@ class VLANGroupListView(generic.ObjectListView):
@register_model_view(VLANGroup) @register_model_view(VLANGroup)
class VLANGroupView(generic.ObjectView): class VLANGroupView(generic.ObjectView):
queryset = VLANGroup.objects.all() queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
related_models = ( related_models = (
@ -937,18 +932,14 @@ class VLANGroupBulkImportView(generic.BulkImportView):
class VLANGroupBulkEditView(generic.BulkEditView): class VLANGroupBulkEditView(generic.BulkEditView):
queryset = VLANGroup.objects.annotate( queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
vlan_count=count_related(VLAN, 'group')
)
filterset = filtersets.VLANGroupFilterSet filterset = filtersets.VLANGroupFilterSet
table = tables.VLANGroupTable table = tables.VLANGroupTable
form = forms.VLANGroupBulkEditForm form = forms.VLANGroupBulkEditForm
class VLANGroupBulkDeleteView(generic.BulkDeleteView): class VLANGroupBulkDeleteView(generic.BulkDeleteView):
queryset = VLANGroup.objects.annotate( queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
vlan_count=count_related(VLAN, 'group')
)
filterset = filtersets.VLANGroupFilterSet filterset = filtersets.VLANGroupFilterSet
table = tables.VLANGroupTable table = tables.VLANGroupTable
@ -971,7 +962,6 @@ class FHRPGroupView(generic.ObjectView):
queryset = FHRPGroup.objects.all() queryset = FHRPGroup.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
# Get assigned interfaces # Get assigned interfaces
members_table = tables.FHRPGroupAssignmentTable( members_table = tables.FHRPGroupAssignmentTable(
data=FHRPGroupAssignment.objects.restrict(request.user, 'view').filter(group=instance), data=FHRPGroupAssignment.objects.restrict(request.user, 'view').filter(group=instance),
@ -1085,7 +1075,7 @@ class VLANInterfacesView(generic.ObjectChildrenView):
child_model = Interface child_model = Interface
table = tables.VLANDevicesTable table = tables.VLANDevicesTable
filterset = InterfaceFilterSet filterset = InterfaceFilterSet
template_name = 'ipam/vlan/interfaces.html' template_name = 'generic/object_children.html'
tab = ViewTab( tab = ViewTab(
label=_('Device Interfaces'), label=_('Device Interfaces'),
badge=lambda x: x.get_interfaces().count(), badge=lambda x: x.get_interfaces().count(),
@ -1103,7 +1093,7 @@ class VLANVMInterfacesView(generic.ObjectChildrenView):
child_model = VMInterface child_model = VMInterface
table = tables.VLANVirtualMachinesTable table = tables.VLANVirtualMachinesTable
filterset = VMInterfaceFilterSet filterset = VMInterfaceFilterSet
template_name = 'ipam/vlan/vminterfaces.html' template_name = 'generic/object_children.html'
tab = ViewTab( tab = ViewTab(
label=_('VM Interfaces'), label=_('VM Interfaces'),
badge=lambda x: x.get_vminterfaces().count(), badge=lambda x: x.get_vminterfaces().count(),
@ -1300,6 +1290,11 @@ class L2VPNBulkDeleteView(generic.BulkDeleteView):
table = tables.L2VPNTable table = tables.L2VPNTable
@register_model_view(L2VPN, 'contacts')
class L2VPNContactsView(ObjectContactsView):
queryset = L2VPN.objects.all()
# #
# L2VPN terminations # L2VPN terminations
# #

View File

@ -60,7 +60,7 @@ class TokenAuthentication(authentication.TokenAuthentication):
user = token.user user = token.user
# When LDAP authentication is active try to load user data from LDAP directory # When LDAP authentication is active try to load user data from LDAP directory
if settings.REMOTE_AUTH_BACKEND == 'netbox.authentication.LDAPBackend': if 'netbox.authentication.LDAPBackend' in settings.REMOTE_AUTH_BACKEND:
from netbox.authentication import LDAPBackend from netbox.authentication import LDAPBackend
ldap_backend = LDAPBackend() ldap_backend = LDAPBackend()

View File

@ -11,6 +11,7 @@ from rest_framework.reverse import reverse
from rest_framework.views import APIView from rest_framework.views import APIView
from rq.worker import Worker from rq.worker import Worker
from extras.plugins.utils import get_installed_plugins
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
@ -61,19 +62,11 @@ class StatusView(APIView):
installed_apps[app_config.name] = version installed_apps[app_config.name] = version
installed_apps = {k: v for k, v in sorted(installed_apps.items())} installed_apps = {k: v for k, v in sorted(installed_apps.items())}
# Gather installed plugins
plugins = {}
for plugin_name in settings.PLUGINS:
plugin_name = plugin_name.rsplit('.', 1)[-1]
plugin_config = apps.get_app_config(plugin_name)
plugins[plugin_name] = getattr(plugin_config, 'version', None)
plugins = {k: v for k, v in sorted(plugins.items())}
return Response({ return Response({
'django-version': DJANGO_VERSION, 'django-version': DJANGO_VERSION,
'installed-apps': installed_apps, 'installed-apps': installed_apps,
'netbox-version': settings.VERSION, 'netbox-version': settings.VERSION,
'plugins': plugins, 'plugins': get_installed_plugins(),
'python-version': platform.python_version(), 'python-version': platform.python_version(),
'rq-workers-running': Worker.count(get_connection('default')), 'rq-workers-running': Worker.count(get_connection('default')),
}) })

View File

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

View File

@ -156,8 +156,11 @@ class RemoteUserBackend(_RemoteUserBackend):
try: try:
group_list.append(Group.objects.get(name=name)) group_list.append(Group.objects.get(name=name))
except Group.DoesNotExist: except Group.DoesNotExist:
logging.error( if settings.REMOTE_AUTH_AUTO_CREATE_GROUPS:
f"Could not assign group {name} to remotely-authenticated user {user}: Group not found") group_list.append(Group.objects.create(name=name))
else:
logging.error(
f"Could not assign group {name} to remotely-authenticated user {user}: Group not found")
if group_list: if group_list:
user.groups.set(group_list) user.groups.set(group_list)
logger.debug( logger.debug(

View File

@ -28,6 +28,17 @@ PARAMS = (
), ),
}, },
), ),
ConfigParam(
name='BANNER_MAINTENANCE',
label=_('Maintenance banner'),
default='NetBox is currently in maintenance mode. Functionality may be limited.',
description=_('Additional content to display when in maintenance mode'),
field_kwargs={
'widget': forms.Textarea(
attrs={'class': 'vLargeTextField'}
),
},
),
ConfigParam( ConfigParam(
name='BANNER_TOP', name='BANNER_TOP',
label=_('Top banner'), label=_('Top banner'),

View File

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

View File

@ -78,7 +78,10 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
def _get_custom_fields(self, content_type): def _get_custom_fields(self, content_type):
return CustomField.objects.filter(content_types=content_type).filter( return CustomField.objects.filter(content_types=content_type).filter(
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE ui_visibility__in=[
CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE,
CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET,
]
) )
def _get_form_field(self, customfield): def _get_form_field(self, customfield):

View File

@ -3,19 +3,21 @@ import uuid
from urllib import parse from urllib import parse
from django.conf import settings from django.conf import settings
from django.contrib import auth from django.contrib import auth, messages
from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_ from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.db import ProgrammingError from django.db import connection, ProgrammingError
from django.db.utils import InternalError
from django.http import Http404, HttpResponseRedirect from django.http import Http404, HttpResponseRedirect
from extras.context_managers import change_logging from extras.context_managers import change_logging
from netbox.config import clear_config from netbox.config import clear_config, get_config
from netbox.views import handler_500 from netbox.views import handler_500
from utilities.api import is_api_request, rest_api_server_error from utilities.api import is_api_request, rest_api_server_error
__all__ = ( __all__ = (
'CoreMiddleware', 'CoreMiddleware',
'MaintenanceModeMiddleware',
'RemoteUserMiddleware', 'RemoteUserMiddleware',
) )
@ -47,6 +49,9 @@ class CoreMiddleware:
# Attach the unique request ID as an HTTP header. # Attach the unique request ID as an HTTP header.
response['X-Request-ID'] = request.id response['X-Request-ID'] = request.id
# Enable the Vary header to help with caching of HTMX responses
response['Vary'] = 'HX-Request'
# If this is an API request, attach an HTTP header annotating the API version (e.g. '3.5'). # If this is an API request, attach an HTTP header annotating the API version (e.g. '3.5').
if is_api_request(request): if is_api_request(request):
response['API-Version'] = settings.REST_FRAMEWORK_VERSION response['API-Version'] = settings.REST_FRAMEWORK_VERSION
@ -166,3 +171,47 @@ class RemoteUserMiddleware(RemoteUserMiddleware_):
groups = [] groups = []
logger.debug(f"Groups are {groups}") logger.debug(f"Groups are {groups}")
return groups return groups
class MaintenanceModeMiddleware:
"""
Middleware that checks if the application is in maintenance mode
and restricts write-related operations to the database.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if get_config().MAINTENANCE_MODE:
self._set_session_type(
allow_write=request.path_info.startswith(settings.MAINTENANCE_EXEMPT_PATHS)
)
return self.get_response(request)
@staticmethod
def _set_session_type(allow_write):
"""
Prevent any write-related database operations.
Args:
allow_write (bool): If True, write operations will be permitted.
"""
with connection.cursor() as cursor:
mode = 'READ WRITE' if allow_write else 'READ ONLY'
cursor.execute(f'SET SESSION CHARACTERISTICS AS TRANSACTION {mode};')
def process_exception(self, request, exception):
"""
Prevent any write-related database operations if an exception is raised.
"""
if get_config().MAINTENANCE_MODE and isinstance(exception, InternalError):
error_message = 'NetBox is currently operating in maintenance mode and is unable to perform write ' \
'operations. Please try again later.'
if is_api_request(request):
return rest_api_server_error(request, error=error_message)
messages.error(request, error_message)
return HttpResponseRedirect(request.path_info)

View File

@ -197,11 +197,15 @@ class CustomFieldsMixin(models.Model):
data = {} data = {}
for field in CustomField.objects.get_for_model(self): for field in CustomField.objects.get_for_model(self):
# Skip fields that are hidden if 'omit_hidden' is set
if omit_hidden and field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN:
continue
value = self.custom_field_data.get(field.name) value = self.custom_field_data.get(field.name)
# Skip fields that are hidden if 'omit_hidden' is set
if omit_hidden:
if field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN:
continue
if field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET and not value:
continue
data[field] = field.deserialize(value) data[field] = field.deserialize(value)
return data return data
@ -227,6 +231,8 @@ class CustomFieldsMixin(models.Model):
for cf in visible_custom_fields: for cf in visible_custom_fields:
value = self.custom_field_data.get(cf.name) value = self.custom_field_data.get(cf.name)
if value in (None, []) and cf.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET:
continue
value = cf.deserialize(value) value = cf.deserialize(value)
groups[cf.group_name][cf] = value groups[cf.group_name][cf] = value
@ -436,6 +442,19 @@ class SyncedDataMixin(models.Model):
return ret return ret
def delete(self, *args, **kwargs):
from core.models import AutoSyncRecord
# Delete AutoSyncRecord
content_type = ContentType.objects.get_for_model(self)
AutoSyncRecord.objects.filter(
datafile=self.data_file,
object_type=content_type,
object_id=self.pk
).delete()
return super().delete(*args, **kwargs)
def resolve_data_file(self): def resolve_data_file(self):
""" """
Determine the designated DataFile object identified by its parent DataSource and its path. Returns None if Determine the designated DataFile object identified by its parent DataSource and its path. Returns None if

View File

@ -46,7 +46,7 @@ ORGANIZATION_MENU = Menu(
get_model_item('tenancy', 'contact', _('Contacts')), get_model_item('tenancy', 'contact', _('Contacts')),
get_model_item('tenancy', 'contactgroup', _('Contact Groups')), get_model_item('tenancy', 'contactgroup', _('Contact Groups')),
get_model_item('tenancy', 'contactrole', _('Contact Roles')), get_model_item('tenancy', 'contactrole', _('Contact Roles')),
get_model_item('tenancy', 'contactassignment', _('Contact Assignments'), actions=[]), get_model_item('tenancy', 'contactassignment', _('Contact Assignments'), actions=['import']),
), ),
), ),
), ),
@ -102,7 +102,7 @@ CONNECTIONS_MENU = Menu(
label=_('Connections'), label=_('Connections'),
items=( items=(
get_model_item('dcim', 'cable', _('Cables'), actions=['import']), get_model_item('dcim', 'cable', _('Cables'), actions=['import']),
get_model_item('wireless', 'wirelesslink', _('Wireless Links'), actions=['import']), get_model_item('wireless', 'wirelesslink', _('Wireless Links')),
MenuItem( MenuItem(
link='dcim:interface_connections_list', link='dcim:interface_connections_list',
link_text=_('Interface Connections'), link_text=_('Interface Connections'),
@ -301,12 +301,14 @@ CUSTOMIZATION_MENU = Menu(
MenuItem( MenuItem(
link='extras:report_list', link='extras:report_list',
link_text=_('Reports'), link_text=_('Reports'),
permissions=['extras.view_report'] permissions=['extras.view_report'],
buttons=get_model_buttons('extras', "reportmodule", actions=['add'])
), ),
MenuItem( MenuItem(
link='extras:script_list', link='extras:script_list',
link_text=_('Scripts'), link_text=_('Scripts'),
permissions=['extras.view_script'] permissions=['extras.view_script'],
buttons=get_model_buttons('extras', "scriptmodule", actions=['add'])
), ),
), ),
), ),

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